feat: wrap enabled-model edits in a save & re-verify flow

Editing an enabled model invalidated its verification record, so the
backend rejected the save with MODEL_NOT_AVAILABLE. The editor now
detects changed enabled models (mirroring configuration_hash inputs plus
credential rotation) and runs the documented disable & save -> test ->
enable & save sequence as one click, temporarily repointing defaults
when the edited model is the default primary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-22 08:35:46 +08:00
parent cec1fa04d7
commit 1ff30b677a
3 changed files with 373 additions and 29 deletions
+234 -28
View File
@@ -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<ReturnType<typeof readApiError>>;
type PutRegistryResult =
| { ok: true; body: GetModelRegistryResponse }
| { ok: false; status: number; error: ApiErrorInfo };
async function putRegistry(
payload: PutModelRegistryRequest
): Promise<PutRegistryResult> {
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<string>("zhipu-glm");
const [selectedProviderIndex, setSelectedProviderIndex] = useState(0);
const [flowStep, setFlowStep] = useState<string | null>(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() {
</div>
)}
{reverifyRefs.length > 0 && (
<div className="rounded-md border border-[var(--color-warning)]/30 bg-[var(--color-warning)]/5 px-4 py-3 text-xs">
<p className="font-medium">
Changed models require re-verification:{" "}
{reverifyRefs.map(modelRefKey).join(", ")}
</p>
<p className="mt-1 text-muted-foreground">
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.
</p>
</div>
)}
<div className="flex items-center gap-2 border-t border-border pt-3">
<Button
type="button"
onClick={() => void save()}
onClick={() =>
reverifyRefs.length > 0
? void reverifyAndSave(reverifyRefs)
: void save()
}
disabled={saving || !dirty}
>
{saving ? "Saving…" : "Save registry"}
{saving
? (flowStep ?? "Saving…")
: reverifyRefs.length > 0
? "Save & re-verify"
: "Save registry"}
</Button>
<Button
type="button"
+90 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import type { AdapterParameterSpec } from "@/lib/modelRegistry";
import type {
AdapterParameterSpec,
GetModelRegistryResponse,
ModelConfig,
RegistryV4,
} from "@/lib/modelRegistry";
import {
modelsNeedingReverify,
PROVIDER_TEMPLATES,
providerDraftFromTemplate,
} from "@/lib/registryDraft";
@@ -75,3 +81,86 @@ describe("providerDraftFromTemplate", () => {
expect(draft.models).toEqual([]);
});
});
describe("modelsNeedingReverify", () => {
function registryWith(modelOverrides: Partial<ModelConfig["runtime"]> = {}) {
const provider = providerDraftFromTemplate(
PROVIDER_TEMPLATES.find((t) => t.id === "zhipu-glm")!,
SPECS,
new Set()
);
provider.models[0].enabled = true;
Object.assign(provider.models[0].runtime, modelOverrides);
return {
version: 4,
revision: 1,
state: "active",
defaults: { primary: null, auxiliary: null },
providers: [provider],
} as unknown as RegistryV4;
}
function dataFor(registry: RegistryV4): GetModelRegistryResponse {
return { revision: 1, registry } as unknown as GetModelRegistryResponse;
}
it("returns nothing when the enabled model is unchanged", () => {
const registry = registryWith();
expect(
modelsNeedingReverify(dataFor(registry), structuredClone(registry), new Map())
).toEqual([]);
});
it("flags an enabled model whose runtime parameters changed", () => {
const saved = registryWith();
const draft = structuredClone(saved);
draft.providers[0].models[0].runtime.temperature = 0.5;
expect(modelsNeedingReverify(dataFor(saved), draft, new Map())).toEqual([
{ provider_id: "zhipu-glm", model_key: "glm-5.2" },
]);
});
it("flags an enabled model when the provider base URL changed", () => {
const saved = registryWith();
const draft = structuredClone(saved);
draft.providers[0].base_url = "https://example.com/v1";
expect(
modelsNeedingReverify(dataFor(saved), draft, new Map())
).toHaveLength(1);
});
it("flags a newly enabled model even without config changes", () => {
const saved = registryWith();
saved.providers[0].models[0].enabled = false;
const draft = structuredClone(saved);
draft.providers[0].models[0].enabled = true;
expect(
modelsNeedingReverify(dataFor(saved), draft, new Map())
).toHaveLength(1);
});
it("flags enabled models when their credential is rotated", () => {
const saved = registryWith();
const draft = structuredClone(saved);
const writes = new Map([["zhipu-glm-credential", "sk-new-secret"]]);
expect(
modelsNeedingReverify(dataFor(saved), draft, writes)
).toHaveLength(1);
});
it("ignores changes to disabled models or disabled providers", () => {
const saved = registryWith();
const disabledModel = structuredClone(saved);
disabledModel.providers[0].models[0].enabled = false;
disabledModel.providers[0].models[0].runtime.temperature = 0.5;
expect(
modelsNeedingReverify(dataFor(saved), disabledModel, new Map())
).toEqual([]);
const disabledProvider = structuredClone(saved);
disabledProvider.providers[0].enabled = false;
disabledProvider.providers[0].base_url = "https://example.com/v1";
expect(
modelsNeedingReverify(dataFor(saved), disabledProvider, new Map())
).toEqual([]);
});
});
+49
View File
@@ -214,3 +214,52 @@ export function registryDirty(
JSON.stringify(draft) !== JSON.stringify(saved)
);
}
/**
* Enabled draft models whose verification-defining inputs changed relative
* to the saved registry (design doc 7.1 item 7 and 9.2 rule 5). The backend
* rejects saving an enabled model without a passing verification for the
* current `configuration_hash` (adapter, base URL, upstream model id, all
* runtime parameters, capabilities and limits), `credential_revision`, and
* adapter spec revision, so these models need the wrapped "disable & save →
* test → enable & save" re-verify sequence.
*/
export function modelsNeedingReverify(
data: GetModelRegistryResponse,
draft: RegistryV4,
credentialWrites: ReadonlyMap<string, string>
): ModelRef[] {
const saved = data.registry;
const refs: ModelRef[] = [];
for (const provider of draft.providers) {
if (!provider.enabled) continue;
const savedProvider = saved.providers.find((p) => p.id === provider.id);
const stagedSecret = provider.auth.credential_id
? credentialWrites.get(provider.auth.credential_id)
: undefined;
const credentialRotated = !!stagedSecret;
const providerConfigChanged =
!savedProvider ||
savedProvider.adapter !== provider.adapter ||
savedProvider.base_url !== provider.base_url ||
JSON.stringify(savedProvider.runtime) !==
JSON.stringify(provider.runtime);
for (const model of provider.models) {
if (!model.enabled) continue;
const savedModel = savedProvider?.models.find(
(entry) => entry.key === model.key
);
if (
!savedModel ||
!savedModel.enabled ||
providerConfigChanged ||
credentialRotated ||
savedModel.upstream_model_id !== model.upstream_model_id ||
JSON.stringify(savedModel.runtime) !== JSON.stringify(model.runtime)
) {
refs.push({ provider_id: provider.id, model_key: model.key });
}
}
}
return refs;
}