feat: save changed models as disabled instead of auto-testing

Saving no longer runs live provider tests: changed enabled models are
saved as disabled (defaults repointed when needed) and the user
re-verifies manually with the per-model Test button, then re-enables and
saves again. A successful test now refreshes the local availability so
re-enabling is a plain save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-22 10:11:04 +08:00
parent 1ff30b677a
commit 340136ebc1
3 changed files with 129 additions and 138 deletions
+78 -121
View File
@@ -842,7 +842,6 @@ 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);
@@ -889,6 +888,25 @@ export function RegistryEditor() {
[data, draft, credentialWrites]
);
/** Where the default primary will point after saving, if the edited model
* currently holds that default (null when no repoint is needed). */
const repointTarget = useMemo((): ModelRef | null => {
if (!data || !draft || reverifyRefs.length === 0) return null;
const primary = draft.defaults.primary;
if (!primary) return null;
const affectedKeys = new Set(reverifyRefs.map(modelRefKey));
if (!affectedKeys.has(modelRefKey(primary))) return null;
const remaining = structuredClone(draft);
for (const provider of remaining.providers) {
for (const model of provider.models) {
if (affectedKeys.has(`${provider.id}/${model.key}`)) {
model.enabled = false;
}
}
}
return enabledModelRefs(remaining)[0]?.ref ?? null;
}, [data, draft, reverifyRefs]);
const stagedWrites = useCallback((): CredentialWrite[] => {
const writes: CredentialWrite[] = [];
for (const [credentialId, secret] of credentialWrites) {
@@ -939,154 +957,70 @@ export function RegistryEditor() {
}
}, [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(
/** Save with unverifiable models disabled (design doc 9.2 rule 5): the
* backend only stores an enabled model alongside a passing verification,
* so changed models are saved as disabled. The user re-verifies them
* manually with the per-model Test button and re-enables afterwards. */
const saveDisablingChanged = 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;
const next = structuredClone(draft);
for (const provider of next.providers) {
for (const model of provider.models) {
if (affectedKeys.has(`${provider.id}/${model.key}`)) {
model.enabled = false;
}
}
}
if (isAffected(draft1.defaults.primary)) {
const replacement = enabledModelRefs(draft1)[0]?.ref ?? null;
if (isAffected(next.defaults.auxiliary)) {
next.defaults.auxiliary = null;
}
if (isAffected(next.defaults.primary)) {
const replacement = enabledModelRefs(next)[0]?.ref ?? null;
if (!replacement) {
toast.error(
"Editing the default primary model requires a second enabled model to take over temporarily."
"Editing the default primary model requires a second enabled model to take over."
);
return;
}
draft1.defaults.primary = replacement;
next.defaults.primary = replacement;
}
const writes = stagedWrites();
const save1 = await putRegistry({
const result = await putRegistry({
expected_revision: data.revision,
registry: draft1,
registry: next,
...(writes.length > 0 ? { credential_writes: writes } : {}),
});
if (!save1.ok) {
if (save1.status === 409) {
if (!result.ok) {
if (result.status === 409) {
toast.error(
"Registry changed elsewhere — reloaded the latest state."
);
await load();
return;
}
setSaveIssues(save1.error);
toast.error(save1.error.message);
setSaveIssues(result.error);
toast.error(result.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));
setData(result.body);
setDraft(structuredClone(result.body.registry));
setCredentialWrites(new Map());
invalidateAvailableModels();
toast.success(
`Registry saved and re-verified (revision ${save2.body.revision}).`
`Registry saved (revision ${result.body.revision}); changed models are disabled — test and re-enable them when ready.`
);
} catch (reason) {
toast.error(
reason instanceof Error ? reason.message : "Re-verification failed."
reason instanceof Error ? reason.message : "Failed to save the registry."
);
} finally {
setFlowStep(null);
setSaving(false);
}
},
@@ -1124,6 +1058,23 @@ export function RegistryEditor() {
...current,
[key]: { ok: true, result },
}));
// The backend wrote a passing verification row; refresh the local
// availability so re-enabling the model no longer counts as
// unverified (modelsNeedingReverify reads model_status).
setData((current) =>
current
? {
...current,
model_status: current.model_status.map((status) =>
status.model_ref.provider_id ===
result.model_ref.provider_id &&
status.model_ref.model_key === result.model_ref.model_key
? result.model_status
: status
),
}
: current
);
toast.success(`${key} passed the provider test.`);
invalidateAvailableModels();
} else {
@@ -1421,13 +1372,19 @@ export function RegistryEditor() {
{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:{" "}
Changed models will be saved as disabled:{" "}
{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.
An enabled model needs a passing provider test at its current
configuration. Saving disables the changed models
{repointTarget
? ` and points the default primary at ${modelRefKey(
repointTarget
)}`
: ""}
; use each model&apos;s Test button when ready, then re-enable and
save again.
</p>
</div>
)}
@@ -1437,15 +1394,15 @@ export function RegistryEditor() {
type="button"
onClick={() =>
reverifyRefs.length > 0
? void reverifyAndSave(reverifyRefs)
? void saveDisablingChanged(reverifyRefs)
: void save()
}
disabled={saving || !dirty}
>
{saving
? (flowStep ?? "Saving…")
? "Saving…"
: reverifyRefs.length > 0
? "Save & re-verify"
? "Save (changed models disabled)"
: "Save registry"}
</Button>
<Button
+32 -5
View File
@@ -100,11 +100,28 @@ describe("modelsNeedingReverify", () => {
} as unknown as RegistryV4;
}
function dataFor(registry: RegistryV4): GetModelRegistryResponse {
return { revision: 1, registry } as unknown as GetModelRegistryResponse;
function dataFor(
registry: RegistryV4,
verificationStatus: "none" | "passed" | "failed" | "stale" = "passed"
): GetModelRegistryResponse {
const model_status = registry.providers.flatMap((provider) =>
provider.models.map((model) => ({
model_ref: { provider_id: provider.id, model_key: model.key },
state: model.enabled ? "enabled" : "verified",
selectable: model.enabled,
reason_code: null,
verification: { status: verificationStatus },
effective_capabilities: {
tools: true,
vision: false,
structured_output: true,
},
}))
);
return { revision: 1, registry, model_status } as unknown as GetModelRegistryResponse;
}
it("returns nothing when the enabled model is unchanged", () => {
it("returns nothing when the enabled model is unchanged and verified", () => {
const registry = registryWith();
expect(
modelsNeedingReverify(dataFor(registry), structuredClone(registry), new Map())
@@ -129,16 +146,26 @@ describe("modelsNeedingReverify", () => {
).toHaveLength(1);
});
it("flags a newly enabled model even without config changes", () => {
it("flags enabling a model that has no passing verification", () => {
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())
modelsNeedingReverify(dataFor(saved, "none"), draft, new Map())
).toHaveLength(1);
});
it("allows re-enabling a model that passed its provider test", () => {
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, "passed"), draft, new Map())
).toEqual([]);
});
it("flags enabled models when their credential is rotated", () => {
const saved = registryWith();
const draft = structuredClone(saved);
+19 -12
View File
@@ -216,13 +216,13 @@ export function registryDirty(
}
/**
* 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.
* Draft-enabled models that cannot be saved as enabled (design doc 7.1 item
* 7 and 9.2 rule 5): either their verification-defining inputs changed
* relative to the saved registry (adapter, base URL, upstream model id, all
* runtime parameters, capabilities and limits — plus credential rotation),
* or the backend has no passing verification for the current configuration.
* Saving disables them; the user re-verifies with a manual provider test and
* re-enables afterwards.
*/
export function modelsNeedingReverify(
data: GetModelRegistryResponse,
@@ -246,18 +246,25 @@ export function modelsNeedingReverify(
JSON.stringify(provider.runtime);
for (const model of provider.models) {
if (!model.enabled) continue;
const ref = { provider_id: provider.id, model_key: model.key };
const savedModel = savedProvider?.models.find(
(entry) => entry.key === model.key
);
if (
const configChanged =
!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 });
JSON.stringify(savedModel.runtime) !== JSON.stringify(model.runtime);
if (configChanged) {
refs.push(ref);
continue;
}
// Config unchanged: enabling is allowed only when the backend already
// holds a passing verification for exactly this configuration.
const availability = availabilityFor(data, ref);
if (availability?.verification.status !== "passed") {
refs.push(ref);
}
}
}