diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 0a1b76c94e4..620aca41217 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -482,6 +482,30 @@ can still fail when a recorded session has no surviving rollout file. | `PUT, DELETE /api/custom-models/{id}` | Edit or delete one custom model | 400 invalid id/fields; 404 not found; 409 duplicate model | | `GET, PUT /api/selected-models` | Read provider allowlists and availability, or replace one allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Read preset summaries or choose preset/all/custom mode | 400 invalid mode or unsupported preset; 404 unknown provider; PUT 409 `initial_model_selection_pending` | +| `PUT /api/model-settings` | Edit one routed model's capability axes in place | 400 missing provider/modelId, unknown or native/combo provider, malformed field, or an invalid default effort for the resulting ladder | + +`PUT /api/model-settings` takes `{ provider, modelId }` plus any of `contextWindow`, +`inputModalities`, `reasoningEfforts`, and `defaultReasoningEffort`. It writes the +provider-level per-model maps the runtime already reads (`modelContextWindows`, +`modelCapabilities..inputModalities`, `modelReasoningEfforts`, +`modelDefaultReasoningEfforts`), so an edited row keeps its discovery provenance instead of +being replaced by a custom model. `inputModalities` accepts `text`, `image`, and `audio`; +`reasoningEfforts` must be a subset of the efforts the runtime knows. + +Every field is optional, and `null` **clears** the declaration rather than writing a default, +which hands the fact back to the registry, the catalog, and the provider. An empty +`inputModalities` array also clears, while an empty `reasoningEfforts` array is stored as an +explicit "this model has no reasoning rungs" override — it does not clear. +An emptied per-model map is removed instead of left as `{}`. A request that changes nothing +answers `changed: false` with the stored state, which is how an editor distinguishes "no +override to restore" from "restored one". A request that does change something persists the +config, drops that provider's cached `/models` result, and reports `catalogRefresh` — the gather +bakes resolved hints into the rows it caches, and a cache hit may only lower a configured window, +so a raised or cleared override would otherwise read back the previous answer for the whole TTL. +Only routed providers are addressable: `openai` is the +native passthrough lane and `combo` is a synthetic row, so neither is a provider whose per-model +maps these facts come from. Display name is not part of this route; use +`PUT /api/providers/{provider}/model-display-names`. A manual model replaces the Models dashboard row with the same provider and model ID. For OpenAI, the manual row keeps `openai/` and supports the same visibility controls diff --git a/gui/src/components/ModelSettingsDialog.tsx b/gui/src/components/ModelSettingsDialog.tsx new file mode 100644 index 00000000000..46e80007f80 --- /dev/null +++ b/gui/src/components/ModelSettingsDialog.tsx @@ -0,0 +1,395 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { confirmAction } from "../action-dialogs"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { readJsonOrThrow } from "../fetch-json"; +import { useT, type TKey } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { CUSTOM_OPTION, REASONING_EFFORT_LEVELS, type ModelRow } from "../pages/models-shared"; + +/** + * The per-model overrides on ONE routed row, in the shape the row already reports them. + * + * Three axes, and no fourth. Display name is absent on purpose: the row has its own editor for + * it, with its own provenance display, and a second control writing the same key is two + * statements that can drift apart. This dialog owns what nothing else edits — the context + * window, the modality declaration, and the reasoning ladder — so "restore" means "clear the + * capability overrides", which is a promise it can keep. + * + * Every field is optional and clearing one is a distinct intent from writing a value: an empty + * window sends `null`, which hands the fact back to the registry, the catalog and the provider + * default instead of pinning today's answer as an override that outlives its source. + */ + +/** The modalities the API accepts. `video` is absent: the wire carries text and image parts. */ +const MODALITIES = ["text", "image", "audio"] as const; + +const CONTEXT_PRESETS = ["100000", "128000", "200000", "256000", "352000", "500000", "1000000"]; + +const REQUEST_TIMEOUT_MS = 60_000; + +type Phase = "ready" | "saving" | "restoring" | "unknown"; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** What the row reported when the dialog opened: the form's start, and the save diff's baseline. */ +interface SettingsBaseline { + contextWindow: string; + modalities: string[]; + reasoning: boolean; + ladder: string[]; + defaultEffort: string; +} + +function baselineOf(row: ModelRow): SettingsBaseline { + const reasoning = row.reasoningOverridden === true; + return { + contextWindow: row.contextWindow ? String(row.contextWindow) : "", + // The DECLARATION, never the row's own `inputModalities`. That one is the catalog value: + // pre-filling from it made an undeclared model look declared, and because an untouched field + // is not submitted, every save came back as "nothing changed" and the row never moved. + modalities: Array.isArray(row.inputModalitiesDeclared) ? [...row.inputModalitiesDeclared] : [], + reasoning, + // The ladder only counts as the operator's when the config really overrides it: OpenCodex + // writes the registry ladder into the config itself, so key presence would tick this box on + // every model that merely inherits its levels. + ladder: reasoning && Array.isArray(row.reasoningEfforts) ? [...row.reasoningEfforts] : [], + defaultEffort: reasoning ? (row.defaultReasoningEffort ?? "") : "", + }; +} + +function sortedKey(values: readonly string[]): string { + return [...values].sort().join(","); +} + +/** undefined when the draft is not a usable window, null when the operator cleared it. */ +function parseContextWindow(raw: string): number | null | undefined { + const trimmed = raw.trim(); + if (!trimmed) return null; + const value = Number(trimmed.replace(/[_,\s]/g, "")); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +interface ModelSettingsDialogProps { + row: ModelRow; + apiBase: string; + /** Reload the catalog. Resolves false when the reload failed, so the dialog can say so. */ + onRefresh: (signal: AbortSignal) => Promise; + onFeedback: (ok: boolean, message: string) => void; + onClose: () => void; +} + +export default function ModelSettingsDialog({ row, apiBase, onRefresh, onFeedback, onClose }: ModelSettingsDialogProps) { + const t = useT(); + const id = useId(); + const dialogRef = useRef(null); + const submitRef = useRef(null); + const requestRef = useRef(null); + // State rather than a ref: the form's start values are read during render to seed the fields, + // and a ref read in render is exactly what the React Compiler lint refuses. + const [baseline] = useState(() => baselineOf(row)); + const [phase, setPhase] = useState("ready"); + const [contextDraft, setContextDraft] = useState(() => baseline.contextWindow); + const [customContext, setCustomContext] = useState(() => ( + baseline.contextWindow !== "" && !CONTEXT_PRESETS.includes(baseline.contextWindow) + )); + const [modalities, setModalities] = useState(() => [...baseline.modalities]); + const [reasoning, setReasoning] = useState(() => baseline.reasoning); + const [ladder, setLadder] = useState(() => [...baseline.ladder]); + const [defaultEffort, setDefaultEffort] = useState(() => baseline.defaultEffort); + const [errorKey, setErrorKey] = useState(null); + const busy = phase !== "ready"; + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + return () => { if (dialog?.open) dialog.close(); }; + }, []); + + useEffect(() => { + if (phase === "unknown") submitRef.current?.focus(); + }, [phase]); + + const toggleReasoning = (next: boolean) => { + setReasoning(next); + if (!next) return; + // First enable seeds from what the model advertises rather than the full shared ladder: a + // provider that accepts only a subset would otherwise be handed levels its API rejects. + if (ladder.length > 0) return; + const advertised = Array.isArray(row.reasoningEfforts) ? row.reasoningEfforts : undefined; + setLadder(advertised && advertised.length > 0 ? [...advertised] : [...REASONING_EFFORT_LEVELS]); + }; + + const toggleLevel = (level: string, on: boolean) => { + setLadder(prev => (on ? [...prev, level] : prev.filter(value => value !== level))); + // The default has to stay inside the ladder, so dropping its level drops it too. + if (!on && defaultEffort === level) setDefaultEffort(""); + }; + + const requestClose = () => { + if (!busy) onClose(); + }; + + const send = async (patch: Record, restoring: boolean) => { + const bounded = createBoundedFetch(REQUEST_TIMEOUT_MS); + requestRef.current = bounded; + setPhase(restoring ? "restoring" : "saving"); + setErrorKey(null); + let confirmed = false; + try { + const response = await fetch(apiBase + "/api/model-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: row.provider, modelId: row.id, ...patch }), + signal: bounded.signal, + }); + const result = await readJsonOrThrow(response); + bounded.signal.throwIfAborted(); + // Validate the receipt against the request: a 200 for another row would otherwise be + // reported as this row's save, and the reload would appear to disagree with the toast. + if (!isRecord(result) || result.ok !== true || result.provider !== row.provider || result.modelId !== row.id) { + throw new Error("invalid model-settings receipt"); + } + if (requestRef.current !== bounded) return; + confirmed = true; + const changed = result.changed === false; + if (!await onRefresh(bounded.signal)) throw new Error("catalog refresh failed"); + bounded.signal.throwIfAborted(); + if (requestRef.current !== bounded) return; + if (restoring) { + onFeedback(true, t(changed ? "models.settingsNothingToRestore" : "models.settingsRestored", { model: row.namespaced })); + } else { + onFeedback(true, t("models.settingsSaved", { model: row.namespaced })); + } + onClose(); + } catch { + if (requestRef.current !== bounded) return; + setPhase("unknown"); + setErrorKey(confirmed ? "models.settingsRefreshFailed" : "models.settingsSaveFailed"); + } finally { + bounded.clear(); + if (requestRef.current === bounded) requestRef.current = null; + } + }; + + const submit = () => { + if (busy) return; + const parsedWindow = parseContextWindow(contextDraft); + if (parsedWindow === undefined) { setErrorKey("models.contextInvalid"); return; } + const base = baseline; + const patch: Record = {}; + if (contextDraft.trim() !== base.contextWindow) patch.contextWindow = parsedWindow; + if (sortedKey(modalities) !== sortedKey(base.modalities)) { + patch.inputModalities = modalities.length > 0 ? [...modalities] : null; + } + const ladderKey = reasoning ? sortedKey(ladder) : ""; + if (reasoning !== base.reasoning || ladderKey !== sortedKey(base.ladder)) { + patch.reasoningEfforts = reasoning ? [...ladder] : null; + } + const nextDefault = reasoning && ladder.includes(defaultEffort) ? defaultEffort : ""; + if (nextDefault !== base.defaultEffort) patch.defaultReasoningEffort = nextDefault || null; + // Only what the operator actually touched is submitted. Writing an untouched field would + // convert a fact the row merely inherits into a stored override, which is the failure this + // whole surface exists to avoid. + if (Object.keys(patch).length === 0) { onClose(); return; } + void send(patch, false); + }; + + const restore = async () => { + if (busy) return; + const confirmed = await confirmAction({ + message: t("models.settingsRestoreConfirm", { model: row.namespaced }), + confirmLabel: t("models.settingsRestore"), + tone: "danger", + }); + if (!confirmed) return; + // Every axis at once, and it writes immediately rather than resetting the form: a form-level + // reset would still need a save, and that save would re-pin the values it had just restored. + void send({ contextWindow: null, inputModalities: null, reasoningEfforts: null, defaultReasoningEffort: null }, true); + }; + + const inheritedModalities = Array.isArray(row.inputModalities) ? row.inputModalities.join(", ") : ""; + + return ( + { event.preventDefault(); requestClose(); }} + > + + + + {errorKey && {t(errorKey)}} + +
+
+ {t("models.customFieldModelId")} + {row.namespaced} +
+ + + +
+ {t("models.customFieldModalities")} +
+ {MODALITIES.map(modality => ( + + ))} +
+ {modalities.length === 0 && ( + // No box ticked is a real state — undeclared — not an empty model. Say what is + // being followed instead, or the blank row reads as "this model accepts nothing". + + {t("models.settingsModalitiesInherit", { + modalities: inheritedModalities || t("models.settingsModalitiesUnknown"), + })} + + )} +
+ +
+ {t("models.customFieldReasoning")} +
+ +
+ {reasoning && ( +
+ {REASONING_EFFORT_LEVELS.map(level => ( + + ))} +
+ )} +
+ + {reasoning && ( +