4daa3f2790
Make the SigLIP embedder an operator choice (drop-in to SigLIP 2:
google/siglip2-so400m-patch16-512 is a verified 1152-d model at 512px → no
schema change, better small-cue fidelity). A swap = set model + re-embed +
retrain, all operator-driven; the GPU agent does the re-embed so it's fast.
- settings: embedder_model_name is now a setting (migration 0065) alongside the
existing embedder_model_version; both editable + validated (non-empty) in the
ml admin API. The server embedder loads by HF name (AutoImageProcessor/Model,
model-agnostic), preferring the pre-downloaded local dir for the default so
existing deploys don't re-download; rebuilds on a name change.
- agent: new 'embed' job = whole-image SigLIP embedding (mean-pool video frames)
under the lease-announced model → POST /jobs/submit_embedding writes
image_record.siglip_embedding + siglip_model_version. The lease now announces
the model FROM THE SETTING (not a constant).
- re-embed routing: enqueue_gpu_backfill('embed') selects unembedded + stale-
version images; 'siglip' now re-embeds concept crops whose version != current
(so a swap re-triggers crops, not just the never-embedded back-catalogue). The
CPU ml-worker backfill no longer re-embeds on a version mismatch (it can't
churn the library at 512px) — the GPU agent owns version re-embeds. Daily
'embed' + 'siglip' beats self-heal.
- scoring: score_image only bags embeddings in the CURRENT model's space (whole-
image gated by siglip_model_version, concept regions by embedding_version) so a
mid-swap stale vector isn't scored by new-space heads; legacy NULL = current.
- UI: GpuAgentCard "Embedding model (advanced)" — edit name/version, Save, and
"Re-embed library (GPU)" (queues embed + siglip); points at SigLIP 2.
Tests: lease announces model + submit_embedding round-trip; enqueue 'embed'
selects stale/unembedded; stale-version excluded from scoring; embedder model
settable + empty rejected; siglip gate updated to current-version concept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
99 lines
3.4 KiB
Python
99 lines
3.4 KiB
Python
"""HTTP client for the FabledCurator GPU-job API.
|
|
|
|
The agent's ONLY contact with FC — lease/submit/heartbeat/fail + fetch image
|
|
bytes, all over HTTP with the bearer token. No DB/Redis.
|
|
"""
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
|
|
class FcClient:
|
|
def __init__(self, base_url: str, token: str, agent_id: str):
|
|
self.base = base_url.rstrip("/")
|
|
self.agent_id = agent_id
|
|
self.s = requests.Session()
|
|
self.s.headers["Authorization"] = f"Bearer {token}"
|
|
# Many worker threads share this Session; the default pool (10) would
|
|
# throttle them + spam "connection pool is full". Size it for the cap.
|
|
adapter = HTTPAdapter(pool_connections=64, pool_maxsize=64)
|
|
self.s.mount("http://", adapter)
|
|
self.s.mount("https://", adapter)
|
|
|
|
def lease(self, batch_size: int) -> list[dict]:
|
|
r = self.s.post(
|
|
f"{self.base}/api/gpu/jobs/lease",
|
|
json={"agent_id": self.agent_id, "batch_size": batch_size},
|
|
timeout=30,
|
|
)
|
|
r.raise_for_status()
|
|
return r.json().get("jobs", [])
|
|
|
|
def submit(self, job_id: int, regions: list[dict], replace_kinds: list[str]) -> dict:
|
|
r = self.s.post(
|
|
f"{self.base}/api/gpu/jobs/submit",
|
|
json={
|
|
"agent_id": self.agent_id, "job_id": job_id,
|
|
"regions": regions, "replace_kinds": replace_kinds,
|
|
},
|
|
timeout=120,
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def submit_embedding(self, job_id: int, embedding: list, version: str) -> dict:
|
|
"""Post a whole-image SigLIP embedding (the 'embed' task) → image_record."""
|
|
r = self.s.post(
|
|
f"{self.base}/api/gpu/jobs/submit_embedding",
|
|
json={
|
|
"agent_id": self.agent_id, "job_id": job_id,
|
|
"embedding": embedding, "embedding_version": version,
|
|
},
|
|
timeout=120,
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
def heartbeat(self, job_ids: list[int]) -> None:
|
|
try:
|
|
self.s.post(
|
|
f"{self.base}/api/gpu/jobs/heartbeat",
|
|
json={"agent_id": self.agent_id, "job_ids": job_ids},
|
|
timeout=30,
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
def fail(self, job_id: int, error: str) -> None:
|
|
try:
|
|
self.s.post(
|
|
f"{self.base}/api/gpu/jobs/fail",
|
|
json={"agent_id": self.agent_id, "job_id": job_id, "error": error},
|
|
timeout=30,
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
def release(self, job_ids: list[int]) -> None:
|
|
# Graceful hand-back on stop so orphaned work is re-leased at once.
|
|
if not job_ids:
|
|
return
|
|
try:
|
|
self.s.post(
|
|
f"{self.base}/api/gpu/jobs/release",
|
|
json={"agent_id": self.agent_id, "job_ids": job_ids},
|
|
timeout=30,
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
def fetch_image(self, image_url: str) -> bytes:
|
|
# image_url is a server-relative path ("/images/...").
|
|
r = self.s.get(f"{self.base}{image_url}", timeout=180)
|
|
r.raise_for_status()
|
|
return r.content
|
|
|
|
def queue_status(self) -> dict:
|
|
r = self.s.get(f"{self.base}/api/gpu/status", timeout=15)
|
|
r.raise_for_status()
|
|
return r.json()
|