From 359bc5a28385cc7b70469421a515b046d71c8608 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 30 Jun 2026 16:29:27 -0400
Subject: [PATCH] feat(ml): default to SigLIP 2 (new installs) + model
dropdown, no free-text (#1203)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Migration 0069: new installs default to SigLIP 2 (so400m, 512px, 1152-d drop-in)
— UPDATE applies ONLY where no image is embedded yet (fresh install), so an
existing library is NOT silently invalidated; it switches deliberately via the
dropdown → Re-embed → Retrain. Column server_defaults moved to SigLIP 2.
- GET /api/ml/embedder-models: server-authoritative supported list (SigLIP 2 512
recommended / 384 faster / SigLIP 1 384 original) so the UI never free-types.
- GpuAgentCard: the two name/version text fields → a single model dropdown;
Save sets name+version from the picked option (the current model is always
selectable even if off-list).
- embedder.py DEFAULT_MODEL_NAME unchanged (stays the baked local-dir SigLIP 1)
to avoid a local-dir/weights mismatch; SigLIP 2 loads by HF name, cached on the
ml-worker's persistent HF_HOME.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
---
alembic/versions/0069_default_siglip2.py | 51 +++++++++++++++++++
backend/app/api/ml_admin.py | 30 +++++++++++
backend/app/models/ml_settings.py | 6 ++-
.../src/components/settings/GpuAgentCard.vue | 44 ++++++++--------
frontend/src/stores/ml.js | 8 ++-
tests/test_api_ml_admin.py | 25 ++++++---
6 files changed, 133 insertions(+), 31 deletions(-)
create mode 100644 alembic/versions/0069_default_siglip2.py
diff --git a/alembic/versions/0069_default_siglip2.py b/alembic/versions/0069_default_siglip2.py
new file mode 100644
index 0000000..7bef8b1
--- /dev/null
+++ b/alembic/versions/0069_default_siglip2.py
@@ -0,0 +1,51 @@
+"""default the embedder to SigLIP 2 — for FRESH installs only (#1203)
+
+Make SigLIP 2 (so400m, 512px; a 1152-d drop-in) the default embedder. New
+installs start on it. An EXISTING library is NOT touched: flipping its stored
+embedder version would mark every embedding stale (the scorer is version-gated)
+and kill suggestions until a full re-embed+retrain — so an existing instance
+switches deliberately via Settings → GPU agent → Embedding model → Re-embed →
+Retrain. We detect "fresh" by the absence of any embedded image.
+
+Revision ID: 0069
+Revises: 0068
+Create Date: 2026-06-30
+"""
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "0069"
+down_revision: Union[str, None] = "0068"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+_NEW_NAME = "google/siglip2-so400m-patch16-512"
+_NEW_VERSION = "siglip2-so400m-patch16-512"
+_OLD_NAME = "google/siglip-so400m-patch14-384"
+_OLD_VERSION = "siglip-so400m-patch14-384"
+
+
+def upgrade() -> None:
+ # Fresh install (nothing embedded yet) → adopt SigLIP 2.
+ op.execute(
+ f"""
+ UPDATE ml_settings SET
+ embedder_model_name = '{_NEW_NAME}',
+ embedder_model_version = '{_NEW_VERSION}'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM image_record WHERE siglip_embedding IS NOT NULL
+ )
+ """
+ )
+ op.alter_column("ml_settings", "embedder_model_name", server_default=_NEW_NAME)
+ op.alter_column(
+ "ml_settings", "embedder_model_version", server_default=_NEW_VERSION
+ )
+
+
+def downgrade() -> None:
+ op.alter_column("ml_settings", "embedder_model_name", server_default=_OLD_NAME)
+ op.alter_column(
+ "ml_settings", "embedder_model_version", server_default=_OLD_VERSION
+ )
diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py
index 054fa69..5afeee8 100644
--- a/backend/app/api/ml_admin.py
+++ b/backend/app/api/ml_admin.py
@@ -23,6 +23,36 @@ _EDITABLE = (
)
+# Supported embedders for the Settings dropdown — all 1152-d so a swap is a
+# drop-in (re-embed + retrain, no schema change). Server-authoritative so the UI
+# never free-types a model name.
+SUPPORTED_EMBEDDERS = (
+ {
+ "name": "google/siglip2-so400m-patch16-512",
+ "version": "siglip2-so400m-patch16-512",
+ "label": "SigLIP 2 · so400m · 512px (recommended)",
+ "dim": 1152,
+ },
+ {
+ "name": "google/siglip2-so400m-patch16-384",
+ "version": "siglip2-so400m-patch16-384",
+ "label": "SigLIP 2 · so400m · 384px (faster)",
+ "dim": 1152,
+ },
+ {
+ "name": "google/siglip-so400m-patch14-384",
+ "version": "siglip-so400m-patch14-384",
+ "label": "SigLIP 1 · so400m · 384px (original)",
+ "dim": 1152,
+ },
+)
+
+
+@ml_admin_bp.route("/embedder-models", methods=["GET"])
+async def embedder_models():
+ return jsonify({"models": list(SUPPORTED_EMBEDDERS)})
+
+
@ml_admin_bp.route("/settings", methods=["GET"])
async def get_settings():
from sqlalchemy import select
diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py
index 1d7c729..e415d9f 100644
--- a/backend/app/models/ml_settings.py
+++ b/backend/app/models/ml_settings.py
@@ -71,14 +71,16 @@ class MLSettings(Base):
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.92
)
+ # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
+ # existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column(
- String(128), nullable=False, default="siglip-so400m-patch14-384"
+ String(128), nullable=False, default="siglip2-so400m-patch16-512"
)
# The HF model NAME the embedder loads (server CPU embed + announced to the
# GPU agent in the lease). Operator-settable so the embedder is a choice, not
# a hardcode (#1190): set name + version together, then re-embed + retrain.
embedder_model_name: Mapped[str] = mapped_column(
- String(128), nullable=False, default="google/siglip-so400m-patch14-384"
+ String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
diff --git a/frontend/src/components/settings/GpuAgentCard.vue b/frontend/src/components/settings/GpuAgentCard.vue
index 45f1638..37432bb 100644
--- a/frontend/src/components/settings/GpuAgentCard.vue
+++ b/frontend/src/components/settings/GpuAgentCard.vue
@@ -117,15 +117,12 @@
the suggest cut; 0.92 recommended.
-
- Embedding model (advanced)
+
+ Embedding model
-
-
@@ -139,12 +136,11 @@
>Re-embed library (GPU)
- Changing the model means a DIFFERENT embedding space. After saving a new
- model + version, run Re-embed library (the GPU agent re-embeds
- whole images + concept crops), then Retrain heads. Suggestions
- degrade until both finish. SigLIP 2 (google/siglip2-so400m-patch16-512,
- version siglip2-so400m-patch16-512) is a 1152-d drop-in at
- 512px — no schema change.
+ Switching the model is a DIFFERENT embedding space. After Save model,
+ run Re-embed library (the GPU agent re-embeds whole images + concept
+ crops), then Retrain heads — suggestions degrade until both finish.
+ SigLIP 2 (512px) is a 1152-d drop-in over SigLIP 1; new installs default to
+ it. Your existing library stays on its current model until you re-embed.
@@ -173,8 +169,8 @@ const savingThreshold = ref(false)
const autoApply = ref(true)
const autoThreshold = ref(0.92)
const savingAuto = ref(false)
-const modelName = ref('')
-const modelVersion = ref('')
+const modelItems = ref([])
+const selectedModel = ref(null)
const savingModel = ref(false)
const reembedding = ref(false)
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
@@ -204,18 +200,26 @@ onMounted(async () => {
autoThreshold.value = ml.settings.ccip_auto_apply_threshold
}
if (ml.settings?.embedder_model_name != null) {
- modelName.value = ml.settings.embedder_model_name
- modelVersion.value = ml.settings.embedder_model_version
+ const items = await ml.embedderModels()
+ // Make sure the current model is selectable even if it's not in the list.
+ const cur = ml.settings.embedder_model_name
+ if (!items.some((m) => m.name === cur)) {
+ items.push({ name: cur, version: ml.settings.embedder_model_version, label: `${cur} (current)` })
+ }
+ modelItems.value = items
+ selectedModel.value = cur
}
} catch { /* non-fatal */ }
})
async function onSaveModel() {
+ const opt = modelItems.value.find((m) => m.name === selectedModel.value)
+ if (!opt) return
savingModel.value = true
try {
await ml.patchSettings({
- embedder_model_name: modelName.value.trim(),
- embedder_model_version: modelVersion.value.trim(),
+ embedder_model_name: opt.name,
+ embedder_model_version: opt.version,
})
toast({ text: 'Embedding model saved — now Re-embed library, then Retrain heads', type: 'success' })
} catch (e) {
diff --git a/frontend/src/stores/ml.js b/frontend/src/stores/ml.js
index c67e82e..8c01ba6 100644
--- a/frontend/src/stores/ml.js
+++ b/frontend/src/stores/ml.js
@@ -22,8 +22,14 @@ export const useMLStore = defineStore('ml', () => {
await api.post('/api/ml/backfill')
}
+ // Server-authoritative list of supported embedders for the model dropdown.
+ async function embedderModels() {
+ const r = await api.get('/api/ml/embedder-models')
+ return r.models || []
+ }
+
return {
settings, loading, error,
- loadSettings, patchSettings, triggerBackfill
+ loadSettings, patchSettings, triggerBackfill, embedderModels
}
})
diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py
index caa676a..5602f8c 100644
--- a/tests/test_api_ml_admin.py
+++ b/tests/test_api_ml_admin.py
@@ -34,25 +34,34 @@ async def test_get_and_patch_settings(client):
@pytest.mark.asyncio
-async def test_embedder_model_settable_and_empty_rejected(client):
- # #1190: the embedder model name + version are operator-settable (a swap),
- # and neither may be blanked.
+async def test_embedder_model_default_settable_and_empty_rejected(client):
+ # #1203: fresh installs default to SigLIP 2 (migration 0069, no embeddings).
+ # The model is operator-settable (a swap) and neither field may be blanked.
body = await (await client.get("/api/ml/settings")).get_json()
- assert body["embedder_model_name"] == "google/siglip-so400m-patch14-384"
+ assert body["embedder_model_name"] == "google/siglip2-so400m-patch16-512"
ok = await client.patch("/api/ml/settings", json={
- "embedder_model_name": "google/siglip2-so400m-patch16-512",
- "embedder_model_version": "siglip2-so400m-patch16-512",
+ "embedder_model_name": "google/siglip-so400m-patch14-384",
+ "embedder_model_version": "siglip-so400m-patch14-384",
})
assert ok.status_code == 200
out = await ok.get_json()
- assert out["embedder_model_name"] == "google/siglip2-so400m-patch16-512"
- assert out["embedder_model_version"] == "siglip2-so400m-patch16-512"
+ assert out["embedder_model_name"] == "google/siglip-so400m-patch14-384"
bad = await client.patch("/api/ml/settings", json={"embedder_model_name": " "})
assert bad.status_code == 400
+@pytest.mark.asyncio
+async def test_embedder_models_list(client):
+ # #1203: the dropdown reads the supported-model list from the server.
+ body = await (await client.get("/api/ml/embedder-models")).get_json()
+ assert any(
+ m["name"] == "google/siglip2-so400m-patch16-512" for m in body["models"]
+ )
+ assert all({"name", "version", "label"} <= set(m) for m in body["models"])
+
+
@pytest.mark.asyncio
async def test_video_settings_default_and_patch(client):
"""#747: video frame-sampling knobs are exposed + patchable."""