Files
FabledCurator/tests/test_api_gpu.py
T
bvandeusen 4daa3f2790
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m33s
feat(ml): operator model swap — GPU re-embed + embedder as a setting (#1190)
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
2026-06-30 10:24:30 -04:00

151 lines
5.6 KiB
Python

"""GPU-job HTTP API (#114): bearer auth + lease/submit round-trip + backfill."""
import pytest
from backend.app.models import ImageRecord
from backend.app.services.ml.gpu_jobs import GpuJobService
from backend.app.services.ml.regions import RegionService
pytestmark = pytest.mark.integration
async def _img(db, sha) -> ImageRecord:
img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
return img
@pytest.mark.asyncio
async def test_agent_endpoints_require_bearer(client, db):
resp = await client.post("/api/gpu/jobs/lease", json={"agent_id": "a1"})
assert resp.status_code == 401
# A wrong token is also rejected.
await (await client.post("/api/gpu/token/rotate")).get_json()
bad = await client.post(
"/api/gpu/jobs/lease", json={"agent_id": "a1"},
headers={"Authorization": "Bearer nope"},
)
assert bad.status_code == 401
@pytest.mark.asyncio
async def test_lease_submit_round_trip(client, db):
img = await _img(db, "a" * 64)
await GpuJobService(db).enqueue(img.id, "ccip")
await db.commit()
token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
hdr = {"Authorization": f"Bearer {token}"}
leased = await client.post(
"/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
)
assert leased.status_code == 200
jobs = (await leased.get_json())["jobs"]
assert len(jobs) == 1
j = jobs[0]
assert j["image_id"] == img.id and j["task"] == "ccip"
assert j["image_url"].startswith("/images/")
submitted = await client.post("/api/gpu/jobs/submit", json={
"agent_id": "a1", "job_id": j["job_id"],
"regions": [{
"kind": "figure", "bbox": [0.1, 0.1, 0.4, 0.4],
"ccip_embedding": [0.1] * 768, "embedding_version": "ccip-test",
}],
}, headers=hdr)
assert submitted.status_code == 200
assert (await submitted.get_json())["stored"] == 1
# Job closed (read on the app's own connection via the status endpoint).
st = await (await client.get("/api/gpu/status")).get_json()
assert st["done"] == 1 and st["pending"] == 0 and st["leased"] == 0
# Region persisted with its CCIP vector.
regs = await RegionService(db).get_regions(img.id, kinds=["figure"])
assert len(regs) == 1 and len(list(regs[0].ccip_embedding)) == 768
@pytest.mark.asyncio
async def test_lease_announces_embed_model_then_submit_embedding(client, db):
# Whole-image GPU re-embed (#1190): the lease announces the embedder model so
# the agent loads the right one, and submit_embedding writes it back onto
# image_record with its version stamp.
img = await _img(db, "b" * 64)
await GpuJobService(db).enqueue(img.id, "embed")
await db.commit()
token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
hdr = {"Authorization": f"Bearer {token}"}
leased = await client.post(
"/api/gpu/jobs/lease", json={"agent_id": "a1", "batch_size": 5}, headers=hdr,
)
j = (await leased.get_json())["jobs"][0]
assert j["task"] == "embed"
assert j["embed_model_name"] and j["embed_version"] # server-announced model
submitted = await client.post("/api/gpu/jobs/submit_embedding", json={
"agent_id": "a1", "job_id": j["job_id"],
"embedding": [0.2] * 1152, "embedding_version": "siglip2-test-v9",
}, headers=hdr)
assert submitted.status_code == 200
st = await (await client.get("/api/gpu/status")).get_json()
assert st["done"] == 1 and st["leased"] == 0
await db.refresh(img)
assert img.siglip_model_version == "siglip2-test-v9"
assert img.siglip_embedding is not None and len(list(img.siglip_embedding)) == 1152
@pytest.mark.asyncio
async def test_submit_with_stale_lease_is_409(client, db):
img = await _img(db, "b" * 64)
await GpuJobService(db).enqueue(img.id, "ccip")
await db.commit()
token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
hdr = {"Authorization": f"Bearer {token}"}
j = (await (await client.post(
"/api/gpu/jobs/lease", json={"agent_id": "a1"}, headers=hdr,
)).get_json())["jobs"][0]
# A different agent can't submit someone else's lease.
resp = await client.post("/api/gpu/jobs/submit", json={
"agent_id": "other", "job_id": j["job_id"], "regions": [],
}, headers=hdr)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_backfill_enqueues_then_is_idempotent(db):
await _img(db, "c" * 64)
await _img(db, "d" * 64)
await db.commit()
from backend.app.tasks.ml import enqueue_gpu_backfill
n = enqueue_gpu_backfill("ccip") # sync task, own session
assert n >= 2
assert enqueue_gpu_backfill("ccip") == 0 # all already pending
@pytest.mark.asyncio
async def test_release_hands_job_back_to_pending(client, db):
img = await _img(db, "e" * 64)
await GpuJobService(db).enqueue(img.id, "ccip")
await db.commit()
token = (await (await client.post("/api/gpu/token/rotate")).get_json())["token"]
hdr = {"Authorization": f"Bearer {token}"}
j = (await (await client.post(
"/api/gpu/jobs/lease", json={"agent_id": "a1"}, headers=hdr,
)).get_json())["jobs"][0]
resp = await client.post("/api/gpu/jobs/release", json={
"agent_id": "a1", "job_ids": [j["job_id"]],
}, headers=hdr)
assert resp.status_code == 200 and (await resp.get_json())["released"] == 1
st = await (await client.get("/api/gpu/status")).get_json()
assert st["pending"] == 1 and st["leased"] == 0