diff --git a/src/app/components/RegistryEditor.tsx b/src/app/components/RegistryEditor.tsx index a11e91b..bd9730e 100644 --- a/src/app/components/RegistryEditor.tsx +++ b/src/app/components/RegistryEditor.tsx @@ -23,6 +23,7 @@ import { enabledModelRefs, ID_PATTERN, modelRefKey, + modelsNeedingReverify, newModelDraft, PROVIDER_TEMPLATES, providerDraftFromTemplate, @@ -58,6 +59,30 @@ async function readApiError(response: Response): Promise<{ }; } +type ApiErrorInfo = Awaited>; + +type PutRegistryResult = + | { ok: true; body: GetModelRegistryResponse } + | { ok: false; status: number; error: ApiErrorInfo }; + +async function putRegistry( + payload: PutModelRegistryRequest +): Promise { + const response = await fetch("/api/model-registry", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + return { + ok: false, + status: response.status, + error: await readApiError(response), + }; + } + return { ok: true, body: (await response.json()) as GetModelRegistryResponse }; +} + const inputClass = "w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"; @@ -817,6 +842,7 @@ export function RegistryEditor() { ); const [selectedTemplate, setSelectedTemplate] = useState("zhipu-glm"); const [selectedProviderIndex, setSelectedProviderIndex] = useState(0); + const [flowStep, setFlowStep] = useState(null); const load = useCallback(async () => { setLoadError(null); @@ -857,49 +883,53 @@ export function RegistryEditor() { [data, draft, credentialWrites] ); + const reverifyRefs = useMemo( + () => + data && draft ? modelsNeedingReverify(data, draft, credentialWrites) : [], + [data, draft, credentialWrites] + ); + + const stagedWrites = useCallback((): CredentialWrite[] => { + const writes: CredentialWrite[] = []; + for (const [credentialId, secret] of credentialWrites) { + if (secret) { + writes.push({ + credential_id: credentialId, + operation: "replace", + secret_value: secret, + }); + } + } + return writes; + }, [credentialWrites]); + const save = useCallback(async () => { if (!data || !draft) return; setSaving(true); setSaveIssues(null); try { - const writes: CredentialWrite[] = []; - for (const [credentialId, secret] of credentialWrites) { - if (secret) { - writes.push({ - credential_id: credentialId, - operation: "replace", - secret_value: secret, - }); - } - } - const payload: PutModelRegistryRequest = { + const writes = stagedWrites(); + const result = await putRegistry({ expected_revision: data.revision, registry: draft, ...(writes.length > 0 ? { credential_writes: writes } : {}), - }; - const response = await fetch("/api/model-registry", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), }); - if (!response.ok) { - const error = await readApiError(response); - if (response.status === 409) { + if (!result.ok) { + if (result.status === 409) { toast.error("Registry changed elsewhere — reloaded the latest state."); await load(); return; } - setSaveIssues(error); - toast.error(error.message); + setSaveIssues(result.error); + toast.error(result.error.message); return; } - const body = (await response.json()) as GetModelRegistryResponse; - setData(body); - setDraft(structuredClone(body.registry)); + setData(result.body); + setDraft(structuredClone(result.body.registry)); setCredentialWrites(new Map()); setTestOutcomes({}); invalidateAvailableModels(); - toast.success(`Registry saved (revision ${body.revision}).`); + toast.success(`Registry saved (revision ${result.body.revision}).`); } catch (reason) { toast.error( reason instanceof Error ? reason.message : "Failed to save the registry." @@ -907,7 +937,161 @@ export function RegistryEditor() { } finally { setSaving(false); } - }, [credentialWrites, data, draft, load]); + }, [data, draft, stagedWrites, load]); + + /** Wrapped "disable & save → test → enable & save" re-verify sequence + * (design doc 7.1 item 7): the backend still sees two independent saves. */ + const reverifyAndSave = useCallback( + async (affected: ModelRef[]) => { + if (!data || !draft) return; + setSaving(true); + setSaveIssues(null); + const affectedKeys = new Set(affected.map(modelRefKey)); + const setAffectedEnabled = (registry: RegistryV4, enabled: boolean) => { + for (const provider of registry.providers) { + for (const model of provider.models) { + if (affectedKeys.has(`${provider.id}/${model.key}`)) { + model.enabled = enabled; + } + } + } + }; + const isAffected = (ref: ModelRef | null) => + ref !== null && affectedKeys.has(modelRefKey(ref)); + try { + setFlowStep("Saving with changed models disabled…"); + const draft1 = structuredClone(draft); + setAffectedEnabled(draft1, false); + const restorePrimary = draft1.defaults.primary; + const restoreAuxiliary = draft1.defaults.auxiliary; + if (isAffected(draft1.defaults.auxiliary)) { + draft1.defaults.auxiliary = null; + } + if (isAffected(draft1.defaults.primary)) { + const replacement = enabledModelRefs(draft1)[0]?.ref ?? null; + if (!replacement) { + toast.error( + "Editing the default primary model requires a second enabled model to take over temporarily." + ); + return; + } + draft1.defaults.primary = replacement; + } + const writes = stagedWrites(); + const save1 = await putRegistry({ + expected_revision: data.revision, + registry: draft1, + ...(writes.length > 0 ? { credential_writes: writes } : {}), + }); + if (!save1.ok) { + if (save1.status === 409) { + toast.error( + "Registry changed elsewhere — reloaded the latest state." + ); + await load(); + return; + } + setSaveIssues(save1.error); + toast.error(save1.error.message); + return; + } + const current = save1.body; + setData(current); + setDraft(structuredClone(current.registry)); + + const failures: string[] = []; + for (const ref of affected) { + const key = modelRefKey(ref); + setFlowStep(`Testing ${key}…`); + setTesting((currentTesting) => ({ ...currentTesting, [key]: true })); + try { + const response = await fetch("/api/model-registry/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + expected_registry_revision: current.revision, + model_ref: ref, + }), + }); + if (!response.ok) { + const error = await readApiError(response); + setTestOutcomes((currentOutcomes) => ({ + ...currentOutcomes, + [key]: { ok: false, message: error.message, code: error.code }, + })); + failures.push(`${key}: ${error.message}`); + continue; + } + const result = (await response.json()) as ProviderTestResponse; + if (result.ok) { + setTestOutcomes((currentOutcomes) => ({ + ...currentOutcomes, + [key]: { ok: true, result }, + })); + } else { + setTestOutcomes((currentOutcomes) => ({ + ...currentOutcomes, + [key]: { + ok: false, + message: "The provider rejected the test request.", + code: null, + }, + })); + failures.push(`${key}: the provider rejected the test request.`); + } + } finally { + setTesting((currentTesting) => ({ + ...currentTesting, + [key]: false, + })); + } + } + if (failures.length > 0) { + toast.error( + `Re-verification failed; the model stays disabled. ${failures[0]}` + ); + return; + } + + setFlowStep("Re-enabling verified models…"); + const draft2 = structuredClone(current.registry); + setAffectedEnabled(draft2, true); + draft2.defaults.primary = restorePrimary; + draft2.defaults.auxiliary = restoreAuxiliary; + const save2 = await putRegistry({ + expected_revision: current.revision, + registry: draft2, + }); + if (!save2.ok) { + if (save2.status === 409) { + toast.error( + "Registry changed elsewhere — reloaded the latest state." + ); + await load(); + return; + } + setSaveIssues(save2.error); + toast.error(save2.error.message); + return; + } + setData(save2.body); + setDraft(structuredClone(save2.body.registry)); + setCredentialWrites(new Map()); + invalidateAvailableModels(); + toast.success( + `Registry saved and re-verified (revision ${save2.body.revision}).` + ); + } catch (reason) { + toast.error( + reason instanceof Error ? reason.message : "Re-verification failed." + ); + } finally { + setFlowStep(null); + setSaving(false); + } + }, + [data, draft, stagedWrites, load] + ); const testModel = useCallback( async (ref: ModelRef) => { @@ -1234,13 +1418,35 @@ export function RegistryEditor() { )} + {reverifyRefs.length > 0 && ( +
+

+ Changed models require re-verification:{" "} + {reverifyRefs.map(modelRefKey).join(", ")} +

+

+ Saving will temporarily disable them, save, run provider tests, + then re-enable the ones that pass. They are unavailable to new + runs until re-verification completes. +

+
+ )} +