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:
@@ -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"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user