diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md new file mode 100644 index 000000000..5dbc73767 --- /dev/null +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -0,0 +1,69 @@ +--- +title: Routing Profile Editor +description: Create, edit, validate, dry-run, and remove routing policy profiles from the OpenCodex dashboard. +--- + +The **Routing** page in the OpenCodex dashboard can manage `config.routingProfiles` without editing `config.json` by hand. + +## Create a profile + +1. Open **Routing** in the dashboard. +2. Select **Create profile**. +3. Enter an `id`. The canonical model id is `policy/`. +4. Add one or more explicit provider/model candidates. +5. Configure optional requirements, scoring weights, cost limits, and unknown-evidence behavior. +6. Save the profile. + +Profile ids are immutable after creation. To use a different id, create a new profile and remove the old one after updating callers. + +## Validation and persistence + +The dashboard sends the same profile object used by `config.routingProfiles` to the management API. The server validates the complete candidate before writing it: + +- ids and aliases must follow the routing-profile naming and collision rules; +- every candidate provider must exist and be enabled; +- duplicate candidates are rejected; +- numeric limits and requirements must stay inside their supported ranges; and +- at least one optimization weight must be positive. + +A successful save persists the profile through the normal config writer, reconciles live state, and refreshes the model catalog. Validation failures leave the previous configuration unchanged and are shown in the editor. + +## Dry-run a saved profile + +Select a saved profile and use **Dry-run evaluation** to add request evidence such as context-window size, tool use, image input, or structured output. Dry-run evaluates eligibility and scoring but never sends an upstream model request. + +Unsaved edits are not used by dry-run. Save the profile first so the displayed revision and evaluation refer to the same configuration. + +## Management API + +The editor uses these endpoints: + +- `GET /api/routing-profiles` lists normalized profiles and revisions. +- `PUT /api/routing-profiles` creates or updates one profile. Send `mode: "create"` or `mode: "update"`; create mode refuses to overwrite an existing id. +- `DELETE /api/routing-profiles?id=` removes one profile. +- `POST /api/routing-profiles/dry-run` evaluates a saved profile without dispatching upstream. + +Example save payload: + +```json +{ + "id": "fast", + "mode": "create", + "profile": { + "alias": "ocx/fast", + "candidates": [ + { "provider": "anthropic", "model": "claude-sonnet-5" }, + { "provider": "openai", "model": "gpt-5.6" } + ], + "require": { "tools": true, "minContextWindow": 128000 }, + "optimize": { "latency": 0.55, "health": 0.25, "cost": 0.1, "quota": 0.1 }, + "limits": { "maxEstimatedCostUsd": 0.5 }, + "unknownEvidence": { + "capability": "exclude", + "health": "penalize", + "quota": "penalize", + "cost": "penalize" + } + } +} +``` diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4d633e10..bf022e1d7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -20,6 +20,13 @@ export const de: Record = { "routing.empty": "Keine Routing-Profile konfiguriert. Fügen Sie `routingProfiles` zur config.json hinzu.", "routing.revision": "rev", "routing.detail": "Profil", + "routing.createProfile": "Profil erstellen", + "routing.dryRunError": "Trockenlauf fehlgeschlagen (HTTP {status})", + "routing.removeConfirm": "Profil {id} entfernen?", + "routing.unknownEvidence.allow": "zulassen", + "routing.unknownEvidence.penalize": "bestrafen", + "routing.unknownEvidence.exclude": "ausschließen", + "routing.removeCandidate": "Kandidat {provider}/{model} entfernen", "routing.candidates": "Kandidaten", "routing.require": "Harte Anforderungen", "routing.optimize": "Optimierungsgewichte", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index df67c7502..0b18b4cd2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -62,6 +62,13 @@ export const en = { "routing.empty": "No routing profiles configured. Add `routingProfiles` to config.json.", "routing.revision": "rev", "routing.detail": "Profile", + "routing.createProfile": "Create profile", + "routing.dryRunError": "Dry-run failed (HTTP {status})", + "routing.removeConfirm": "Remove profile {id}?", + "routing.unknownEvidence.allow": "allow", + "routing.unknownEvidence.penalize": "penalize", + "routing.unknownEvidence.exclude": "exclude", + "routing.removeCandidate": "Remove candidate {provider}/{model}", "routing.candidates": "Candidates", "routing.require": "Hard requirements", "routing.optimize": "Optimization weights", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index eb98a71eb..c93d3de09 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -20,6 +20,13 @@ export const ja: Record = { "routing.empty": "ルーティングプロファイルが設定されていません。config.json に `routingProfiles` を追加してください。", "routing.revision": "rev", "routing.detail": "プロファイル", + "routing.createProfile": "プロファイルを作成", + "routing.dryRunError": "ドライラン失敗 (HTTP {status})", + "routing.removeConfirm": "プロファイル {id} を削除しますか?", + "routing.unknownEvidence.allow": "許可", + "routing.unknownEvidence.penalize": "ペナルティ", + "routing.unknownEvidence.exclude": "除外", + "routing.removeCandidate": "候補 {provider}/{model} を削除", "routing.candidates": "候補", "routing.require": "必須要件", "routing.optimize": "最適化ウェイト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 339cdbe97..377176ebb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -20,6 +20,13 @@ export const ko: Record = { "routing.empty": "라우팅 프로필이 구성되지 않았습니다. config.json에 `routingProfiles`를 추가하세요.", "routing.revision": "rev", "routing.detail": "프로필", + "routing.createProfile": "프로필 만들기", + "routing.dryRunError": "드라이런 실패 (HTTP {status})", + "routing.removeConfirm": "프로필 {id}을(를) 제거할까요?", + "routing.unknownEvidence.allow": "허용", + "routing.unknownEvidence.penalize": "불이익", + "routing.unknownEvidence.exclude": "제외", + "routing.removeCandidate": "후보 {provider}/{model} 제거", "routing.candidates": "후보", "routing.require": "필수 요구사항", "routing.optimize": "최적화 가중치", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7904e27c3..6bd3e7b74 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -20,6 +20,13 @@ export const ru: Record = { "routing.empty": "Профили маршрутизации не настроены. Добавьте `routingProfiles` в config.json.", "routing.revision": "rev", "routing.detail": "Профиль", + "routing.createProfile": "Создать профиль", + "routing.dryRunError": "Ошибка пробного запуска (HTTP {status})", + "routing.removeConfirm": "Удалить профиль {id}?", + "routing.unknownEvidence.allow": "разрешить", + "routing.unknownEvidence.penalize": "штрафовать", + "routing.unknownEvidence.exclude": "исключить", + "routing.removeCandidate": "Удалить кандидата {provider}/{model}", "routing.candidates": "Кандидаты", "routing.require": "Жёсткие требования", "routing.optimize": "Веса оптимизации", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8b16dbe6c..81b918d4e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -20,6 +20,13 @@ export const zh: Record = { "routing.empty": "未配置路由策略。请在 config.json 中添加 `routingProfiles`。", "routing.revision": "rev", "routing.detail": "配置文件", + "routing.createProfile": "创建配置文件", + "routing.dryRunError": "试运行失败 (HTTP {status})", + "routing.removeConfirm": "删除配置文件 {id}?", + "routing.unknownEvidence.allow": "允许", + "routing.unknownEvidence.penalize": "惩罚", + "routing.unknownEvidence.exclude": "排除", + "routing.removeCandidate": "移除候选 {provider}/{model}", "routing.candidates": "候选", "routing.require": "硬性要求", "routing.optimize": "优化权重", diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 7732dabf0..e8832d23c 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -1,18 +1,22 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { + modelOptionsForProvider, + newDraftCandidate, + newRoutingProfileDraft, + routingProfileDraftFromDto, + routingProfilePutBody, + routingProfileResponseError, + routingProfileResponseSucceeded, + type ModelOption, + type OptionalBoolean, + type RoutingProfileDraft, + type RoutingProfileDto, + type UnknownEvidenceMode, +} from "../routing-profile-editor-data"; +import { readJsonIfOk } from "../fetch-json"; import { Notice } from "../ui"; import { useT } from "../i18n/shared"; -type ProfileDto = { - id: string; - model: string; - revision: string; - candidates: Array<{ provider: string; model: string }>; - require: Record; - optimize: Record; - limits: Record; - unknownEvidence: Record; -}; - type DryRunCandidate = { provider: string; model: string; @@ -39,6 +43,33 @@ type DryRunResult = { trace?: { profile?: { revision?: string } }; }; +type ProviderDto = { + disabled?: boolean; + defaultModel?: string; +}; + +type ConfigDto = { + providers?: Record; +}; + +const BOOLEAN_REQUIREMENTS = [ + "tools", + "imageInput", + "structuredOutput", + "localOnly", + "remoteAllowed", + "encryptedCodexTasks", +] as const; +const STRING_REQUIREMENTS = ["reasoningEffort", "serviceTier"] as const; +const NUMERIC_REQUIREMENT_SPEC = { + minContextWindow: { min: 1, max: undefined, step: 1 }, + minQuotaHeadroom: { min: 0, max: 1, step: "any" }, +} as const; +const NUMERIC_REQUIREMENTS = Object.keys(NUMERIC_REQUIREMENT_SPEC) as Array; +const OPTIMIZE_KEYS = ["latency", "health", "cost", "quota"] as const; +const UNKNOWN_EVIDENCE_KEYS = ["capability", "health", "quota", "cost"] as const; +const UNKNOWN_EVIDENCE_OPTIONS: UnknownEvidenceMode[] = ["allow", "penalize", "exclude"]; + function fmtMs(value: number | undefined, unavailable: string): string { return value === undefined ? unavailable : `${Math.round(value)}ms`; } @@ -47,30 +78,82 @@ function fmtRate(value: number | null | undefined, unavailable: string): string return value === null || value === undefined ? unavailable : `${Math.round(value * 100)}%`; } -function pickSelectedProfile(next: ProfileDto[], current: ProfileDto | null): ProfileDto | null { - if (current) { - const refreshed = next.find(profile => profile.id === current.id); - if (refreshed) return refreshed; +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function parseProfiles(raw: unknown): RoutingProfileDto[] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const profiles = (raw as { profiles?: unknown }).profiles; + if (!Array.isArray(profiles)) return []; + return profiles.filter((profile): profile is RoutingProfileDto => { + if (!isPlainObject(profile)) return false; + // Validate the complete DTO shape: routingProfileDraftFromDto dereferences + // these nested objects, so a management response that omits any of them + // must be rejected here rather than crash the load path. + return typeof profile.id === "string" + && typeof profile.model === "string" + && typeof profile.revision === "string" + && Array.isArray(profile.candidates) + && isPlainObject(profile.require) + && isPlainObject(profile.optimize) + && isPlainObject(profile.limits) + && isPlainObject(profile.unknownEvidence); + }).map(profile => ({ ...profile, alias: profile.alias ?? null })); +} + +function parseModels(raw: unknown): ModelOption[] { + const rows = Array.isArray(raw) + ? raw + : raw && typeof raw === "object" && Array.isArray((raw as { models?: unknown }).models) + ? (raw as { models: unknown[] }).models + : []; + const seen = new Set(); + const models: ModelOption[] = []; + for (const row of rows) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const provider = typeof (row as { provider?: unknown }).provider === "string" + ? (row as { provider: string }).provider.trim() + : ""; + const id = typeof (row as { id?: unknown }).id === "string" + ? (row as { id: string }).id.trim() + : ""; + if (!provider || !id || provider === "combo" || provider === "policy") continue; + if ((row as { disabled?: unknown }).disabled === true) continue; + const key = JSON.stringify([provider, id]); + if (seen.has(key)) continue; + seen.add(key); + models.push({ provider, id }); } - return next[0] ?? null; + return models; } -function shouldClearDryRunOnSelectionChange( - current: ProfileDto | null, - next: ProfileDto | null, -): boolean { - if (!current) return false; - if (!next) return true; - return current.id !== next.id || current.revision !== next.revision; +function selectedAfterLoad( + profiles: RoutingProfileDto[], + currentId: string | null, + preferredId?: string, +): RoutingProfileDto | null { + const requestedId = preferredId ?? currentId; + if (requestedId) { + const match = profiles.find(profile => profile.id === requestedId); + if (match) return match; + } + return profiles[0] ?? null; } export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const t = useT(); const unavailable = t("routing.unavailable"); - const [profiles, setProfiles] = useState([]); + const [profiles, setProfiles] = useState([]); const [analytics, setAnalytics] = useState(null); + const [providerNames, setProviderNames] = useState([]); + const [providerDefaults, setProviderDefaults] = useState>({}); + const [models, setModels] = useState([]); const [loadError, setLoadError] = useState(""); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); + const [draft, setDraft] = useState(null); + const [status, setStatus] = useState<{ message: string; ok: boolean } | null>(null); + const [saving, setSaving] = useState(false); const [context, setContext] = useState(""); const [tools, setTools] = useState(false); const [image, setImage] = useState(false); @@ -78,52 +161,79 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const [dryRunResult, setDryRunResult] = useState(null); const [dryRunError, setDryRunError] = useState(""); const [running, setRunning] = useState(false); - const selectedRef = useRef(null); + const selectedRef = useRef(null); + const loadGenerationRef = useRef(0); const dryRunGenerationRef = useRef(0); + const notify = useCallback((message: string, ok: boolean) => { + setStatus({ message, ok }); + }, []); + + useEffect(() => { + if (!status?.ok) return; + const timer = window.setTimeout(() => setStatus(null), 5000); + return () => window.clearTimeout(timer); + }, [status]); + const clearDryRun = useCallback(() => { dryRunGenerationRef.current += 1; setDryRunResult(null); setDryRunError(""); + setRunning(false); }, []); - const selectProfile = useCallback((profile: ProfileDto | null) => { + const selectProfile = useCallback((profile: RoutingProfileDto | null) => { selectedRef.current = profile; setSelected(profile); + setDraft(profile ? routingProfileDraftFromDto(profile) : null); + setStatus(null); clearDryRun(); }, [clearDryRun]); - const loadGenerationRef = useRef(0); - - const load = useCallback(async () => { + const load = useCallback(async (preferredId?: string) => { const generation = ++loadGenerationRef.current; setLoadError(""); try { - const [profilesRes, analyticsRes] = await Promise.all([ + const [profilesRes, analyticsRes, configRes, modelsRes] = await Promise.all([ fetch(`${apiBase}/api/routing-profiles`), fetch(`${apiBase}/api/routing-analytics`), + fetch(`${apiBase}/api/config`), + fetch(`${apiBase}/api/models`), ]); - if (generation !== loadGenerationRef.current) return; if (!profilesRes.ok) throw new Error(`load-${profilesRes.status}`); - const profilesJson = await profilesRes.json() as { profiles?: ProfileDto[] }; - if (generation !== loadGenerationRef.current) return; - let analyticsJson: Analytics | null = null; - if (analyticsRes.ok) { - analyticsJson = await analyticsRes.json() as Analytics; - if (generation !== loadGenerationRef.current) return; - } - // Apply state only after every body await, and only while this load is still current. + const [profilesJson, analyticsJson, configJson, modelsJson] = await Promise.all([ + profilesRes.json() as Promise, + analyticsRes.ok ? analyticsRes.json() as Promise : Promise.resolve(null), + configRes.ok ? configRes.json() as Promise : Promise.resolve({} as ConfigDto), + modelsRes.ok ? modelsRes.json() as Promise : Promise.resolve([]), + ]); if (generation !== loadGenerationRef.current) return; - const next = profilesJson.profiles ?? []; + + const nextProfiles = parseProfiles(profilesJson); const current = selectedRef.current; - const refreshed = pickSelectedProfile(next, current); + const refreshed = selectedAfterLoad(nextProfiles, current?.id ?? null, preferredId); + const configuredProviders = configJson.providers ?? {}; + const nextProviderNames = Object.entries(configuredProviders) + .filter(([, provider]) => provider.disabled !== true) + .map(([name]) => name) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const nextDefaults = Object.fromEntries( + Object.entries(configuredProviders) + .filter(([, provider]) => provider.disabled !== true && typeof provider.defaultModel === "string") + .map(([name, provider]) => [name, provider.defaultModel!.trim()]), + ); + selectedRef.current = refreshed; - setProfiles(next); + setProfiles(nextProfiles); setSelected(refreshed); - if (shouldClearDryRunOnSelectionChange(current, refreshed)) { + setDraft(refreshed ? routingProfileDraftFromDto(refreshed) : null); + setAnalytics(analyticsJson); + setProviderNames(nextProviderNames); + setProviderDefaults(nextDefaults); + setModels(parseModels(modelsJson)); + if (!current || !refreshed || current.id !== refreshed.id || current.revision !== refreshed.revision) { clearDryRun(); } - setAnalytics(analyticsJson); } catch (error) { if (generation !== loadGenerationRef.current) return; setLoadError(error instanceof Error ? error.message : String(error)); @@ -135,6 +245,129 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { return () => window.clearTimeout(timer); }, [load]); + const firstProvider = providerNames[0] ?? ""; + const firstModel = providerDefaults[firstProvider] + ?? modelOptionsForProvider(models, firstProvider)[0]?.id + ?? ""; + + const startCreate = () => { + selectedRef.current = null; + setSelected(null); + setDraft(newRoutingProfileDraft(firstProvider, firstModel)); + setStatus(null); + clearDryRun(); + }; + + const cancelEdit = () => { + if (selected) { + setDraft(routingProfileDraftFromDto(selected)); + setStatus(null); + return; + } + selectProfile(profiles[0] ?? null); + }; + + const saveProfile = async () => { + if (!draft || saving) return; + setSaving(true); + setStatus(null); + try { + const body = routingProfilePutBody(draft, selected ? "update" : "create", selected?.revision); + const response = await fetch(`${apiBase}/api/routing-profiles`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await readJsonIfOk(response); + if (!response.ok) { + const errorBody = await response.json().catch(() => null) as unknown; + notify(routingProfileResponseError(errorBody) ?? t("routing.loadFailed"), false); + return; + } + if (!routingProfileResponseSucceeded(data)) { + notify(routingProfileResponseError(data) ?? t("routing.loadFailed"), false); + return; + } + await load(body.id); + notify(t("common.ok"), true); + } catch (error) { + notify(error instanceof Error ? error.message : t("routing.loadFailed"), false); + } finally { + setSaving(false); + } + }; + + const removeProfile = async () => { + if (!selected || saving) return; + if (!window.confirm(t("routing.removeConfirm", { id: selected.id }))) return; + setSaving(true); + setStatus(null); + try { + const response = await fetch( + `${apiBase}/api/routing-profiles?id=${encodeURIComponent(selected.id)}`, + { method: "DELETE" }, + ); + const data = await readJsonIfOk(response); + if (!response.ok) { + const errorBody = await response.json().catch(() => null) as unknown; + notify(routingProfileResponseError(errorBody) ?? t("routing.loadFailed"), false); + return; + } + if (!routingProfileResponseSucceeded(data)) { + notify(routingProfileResponseError(data) ?? t("routing.loadFailed"), false); + return; + } + selectedRef.current = null; + await load(); + notify(t("common.ok"), true); + } catch (error) { + notify(error instanceof Error ? error.message : t("routing.loadFailed"), false); + } finally { + setSaving(false); + } + }; + + const updateCandidate = ( + index: number, + field: "provider" | "model", + value: string, + ) => { + setDraft(current => { + if (!current) return current; + const candidates = current.candidates.map((candidate, candidateIndex) => { + if (candidateIndex !== index) return candidate; + if (field === "provider") { + return { + ...candidate, + provider: value, + model: providerDefaults[value] + ?? modelOptionsForProvider(models, value)[0]?.id + ?? "", + }; + } + return { ...candidate, model: value }; + }); + return { ...current, candidates }; + }); + }; + + const addCandidate = () => { + setDraft(current => current ? { + ...current, + candidates: [ + ...current.candidates, + newDraftCandidate(firstProvider, firstModel), + ], + } : current); + }; + + const removeCandidate = (index: number) => { + setDraft(current => current ? { + ...current, + candidates: current.candidates.filter((_, candidateIndex) => candidateIndex !== index), + } : current); + }; + const runDryRun = async () => { if (!selected) return; const generation = ++dryRunGenerationRef.current; @@ -160,15 +393,9 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { }); if (generation !== dryRunGenerationRef.current) return; if (!response.ok) { - let message = `dry-run ${response.status}`; - try { - const body = await response.json() as { error?: { message?: string } }; - message = body.error?.message ?? message; - } catch { - // Keep the status fallback when the error body is not JSON. - } + const body = await response.json().catch(() => null) as unknown; if (generation !== dryRunGenerationRef.current) return; - setDryRunError(message); + setDryRunError(routingProfileResponseError(body) ?? t("routing.dryRunError", { status: response.status })); return; } const result = await response.json() as DryRunResult; @@ -184,19 +411,27 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { } }; + const selectedModelOptions = draft?.candidates.map( + candidate => modelOptionsForProvider(models, candidate.provider), + ) ?? []; + return (

{t("routing.title")}

- +
+ + +

{t("routing.subtitle")}

{loadError ? {t("routing.loadFailed")}: {loadError} : null} + {status ? {status.message} : null} - {profiles.length === 0 && !loadError ? ( -
{t("routing.empty")}
- ) : ( + {profiles.length > 0 ? (
{profiles.map(profile => (
- )} + ) : null} - {selected ? ( -
-

{t("routing.detail")}: {selected.model}

-
- {t("routing.candidates")} + {draft ? ( +
{ + event.preventDefault(); + void saveProfile(); + }} + > +
+

{t("routing.detail")}: {selected?.model ?? policy/…}

+ {selected ? {t("routing.revision")}: {selected.revision} : null} +
+ +
+ + +
+ +
+ {t("routing.candidates")} +
+ {draft.candidates.map((candidate, index) => { + const candidateProviders = [...new Set([candidate.provider, ...providerNames])].filter(Boolean); + const listId = `routing-model-options-${index}`; + return ( +
+
+ + +
+ +
+ ); + })} + +
+
+ +
+ {t("routing.require")}
- {selected.candidates.map(candidate => ( -
- {candidate.provider}/{candidate.model} -
+ {NUMERIC_REQUIREMENTS.map(key => ( + + ))} + {STRING_REQUIREMENTS.map(key => ( + + ))} + {BOOLEAN_REQUIREMENTS.map(key => ( + ))}
-
- {([ - ["routing.require", selected.require, true], - ["routing.optimize", selected.optimize, false], - ["routing.limits", selected.limits, true], - ["routing.unknownEvidence", selected.unknownEvidence, false], - ] as const).map(([labelKey, value, allowEmpty]) => ( -
- {t(labelKey)} -
-                {allowEmpty && Object.keys(value).length === 0
-                  ? t("routing.none")
-                  : JSON.stringify(value, null, 2)}
-              
+ + +
+ {t("routing.optimize")} +
+ {OPTIMIZE_KEYS.map(key => ( + + ))}
- ))} -
+ + +
+ {t("routing.limits")} + +
+ +
+ {t("routing.unknownEvidence")} +
+ {UNKNOWN_EVIDENCE_KEYS.map(key => ( + + ))} +
+
+ +
+ + + {selected ? ( + + ) : null} +
+ ) : null}
diff --git a/gui/src/routing-profile-editor-data.ts b/gui/src/routing-profile-editor-data.ts new file mode 100644 index 000000000..030825bf1 --- /dev/null +++ b/gui/src/routing-profile-editor-data.ts @@ -0,0 +1,259 @@ +export type UnknownEvidenceMode = "allow" | "penalize" | "exclude"; +export type OptionalBoolean = "" | "true" | "false"; + +export type RoutingProfileCandidate = { + provider: string; + model: string; +}; + +/** + * Draft-only candidate carrying a stable client-side identity for list keys. + * The key never reaches the server: `routingProfilePutBody` strips it. + */ +export type RoutingProfileDraftCandidate = RoutingProfileCandidate & { key: string }; + +let draftCandidateKey = 0; +function newDraftCandidateKey(): string { + draftCandidateKey += 1; + return `candidate-${draftCandidateKey}`; +} + +/** Create a draft candidate with a fresh stable key. */ +export function newDraftCandidate( + provider: string, + model: string, +): RoutingProfileDraftCandidate { + return { provider, model, key: newDraftCandidateKey() }; +} + +export type RoutingProfileDto = { + id: string; + alias: string | null; + model: string; + revision: string; + candidates: RoutingProfileCandidate[]; + require: { + minContextWindow?: number; + minQuotaHeadroom?: number; + tools?: boolean; + imageInput?: boolean; + structuredOutput?: boolean; + reasoningEffort?: string; + serviceTier?: string; + localOnly?: boolean; + remoteAllowed?: boolean; + encryptedCodexTasks?: boolean; + }; + optimize: { + latency: number; + health: number; + cost: number; + quota: number; + }; + limits: { + maxEstimatedCostUsd?: number; + }; + unknownEvidence: Record<"capability" | "health" | "quota" | "cost", UnknownEvidenceMode>; +}; + +export type RoutingProfileDraft = { + id: string; + alias: string; + candidates: RoutingProfileDraftCandidate[]; + require: { + minContextWindow: string; + minQuotaHeadroom: string; + tools: OptionalBoolean; + imageInput: OptionalBoolean; + structuredOutput: OptionalBoolean; + reasoningEffort: string; + serviceTier: string; + localOnly: OptionalBoolean; + remoteAllowed: OptionalBoolean; + encryptedCodexTasks: OptionalBoolean; + }; + optimize: { + latency: string; + health: string; + cost: string; + quota: string; + }; + limits: { + maxEstimatedCostUsd: string; + }; + unknownEvidence: Record<"capability" | "health" | "quota" | "cost", UnknownEvidenceMode>; +}; + +export type ModelOption = { + provider: string; + id: string; +}; + +const DEFAULT_OPTIMIZE = { + latency: "0.55", + health: "0.25", + cost: "0.1", + quota: "0.1", +} as const; + +const DEFAULT_UNKNOWN_EVIDENCE = { + capability: "exclude", + health: "penalize", + quota: "penalize", + cost: "penalize", +} as const; + +function optionalBoolean(value: boolean | undefined): OptionalBoolean { + if (value === true) return "true"; + if (value === false) return "false"; + return ""; +} + +function numberInput(value: number | undefined): string { + return value === undefined ? "" : String(value); +} + +export function newRoutingProfileDraft( + provider = "", + model = "", +): RoutingProfileDraft { + return { + id: "", + alias: "", + candidates: [newDraftCandidate(provider, model)], + require: { + minContextWindow: "", + minQuotaHeadroom: "", + tools: "", + imageInput: "", + structuredOutput: "", + reasoningEffort: "", + serviceTier: "", + localOnly: "", + remoteAllowed: "", + encryptedCodexTasks: "", + }, + optimize: { ...DEFAULT_OPTIMIZE }, + limits: { maxEstimatedCostUsd: "" }, + unknownEvidence: { ...DEFAULT_UNKNOWN_EVIDENCE }, + }; +} + +export function routingProfileDraftFromDto(profile: RoutingProfileDto): RoutingProfileDraft { + return { + id: profile.id, + alias: profile.alias ?? "", + candidates: profile.candidates.map(candidate => ({ ...candidate, key: newDraftCandidateKey() })), + require: { + minContextWindow: numberInput(profile.require.minContextWindow), + minQuotaHeadroom: numberInput(profile.require.minQuotaHeadroom), + tools: optionalBoolean(profile.require.tools), + imageInput: optionalBoolean(profile.require.imageInput), + structuredOutput: optionalBoolean(profile.require.structuredOutput), + reasoningEffort: profile.require.reasoningEffort ?? "", + serviceTier: profile.require.serviceTier ?? "", + localOnly: optionalBoolean(profile.require.localOnly), + remoteAllowed: optionalBoolean(profile.require.remoteAllowed), + encryptedCodexTasks: optionalBoolean(profile.require.encryptedCodexTasks), + }, + optimize: { + latency: String(profile.optimize.latency), + health: String(profile.optimize.health), + cost: String(profile.optimize.cost), + quota: String(profile.optimize.quota), + }, + limits: { + maxEstimatedCostUsd: numberInput(profile.limits.maxEstimatedCostUsd), + }, + unknownEvidence: { ...profile.unknownEvidence }, + }; +} + +function optionalNumber(value: string): number | undefined { + const trimmed = value.trim(); + return trimmed ? Number(trimmed) : undefined; +} + +function draftBoolean(value: OptionalBoolean): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +function compactRecord(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} + +export type RoutingProfileWriteMode = "create" | "update"; + +export function routingProfilePutBody( + draft: RoutingProfileDraft, + mode: RoutingProfileWriteMode, + expectedRevision?: string, +): { + mode: RoutingProfileWriteMode; + id: string; + expectedRevision?: string; + profile: Record; +} { + const require = compactRecord({ + minContextWindow: optionalNumber(draft.require.minContextWindow), + minQuotaHeadroom: optionalNumber(draft.require.minQuotaHeadroom), + tools: draftBoolean(draft.require.tools), + imageInput: draftBoolean(draft.require.imageInput), + structuredOutput: draftBoolean(draft.require.structuredOutput), + reasoningEffort: draft.require.reasoningEffort.trim() || undefined, + serviceTier: draft.require.serviceTier.trim() || undefined, + localOnly: draftBoolean(draft.require.localOnly), + remoteAllowed: draftBoolean(draft.require.remoteAllowed), + encryptedCodexTasks: draftBoolean(draft.require.encryptedCodexTasks), + }); + const maxEstimatedCostUsd = optionalNumber(draft.limits.maxEstimatedCostUsd); + + return { + mode, + id: draft.id.trim(), + ...(mode === "update" && expectedRevision ? { expectedRevision } : {}), + profile: { + ...(draft.alias.trim() ? { alias: draft.alias.trim() } : {}), + candidates: draft.candidates.map(candidate => ({ + provider: candidate.provider.trim(), + model: candidate.model.trim(), + })), + ...(Object.keys(require).length > 0 ? { require } : {}), + optimize: { + latency: Number(draft.optimize.latency), + health: Number(draft.optimize.health), + cost: Number(draft.optimize.cost), + quota: Number(draft.optimize.quota), + }, + ...(maxEstimatedCostUsd !== undefined + ? { limits: { maxEstimatedCostUsd } } + : {}), + unknownEvidence: { ...draft.unknownEvidence }, + }, + }; +} + +export function routingProfileResponseError(data: unknown): string | undefined { + if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; + const error = (data as { error?: unknown }).error; + if (typeof error === "string" && error.trim()) return error; + if (error && typeof error === "object" && !Array.isArray(error)) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message; + } + return undefined; +} + +export function routingProfileResponseSucceeded(data: unknown): boolean { + return !!data && typeof data === "object" && !Array.isArray(data) + && (data as { success?: unknown }).success === true; +} + +export function modelOptionsForProvider( + models: ModelOption[], + provider: string, +): ModelOption[] { + return models.filter(model => model.provider === provider); +} diff --git a/gui/tests/routing-profiles.test.tsx b/gui/tests/routing-profiles.test.tsx index 64bc1863b..28e195204 100644 --- a/gui/tests/routing-profiles.test.tsx +++ b/gui/tests/routing-profiles.test.tsx @@ -142,6 +142,18 @@ async function mountPage(): Promise<{ container: HTMLDivElement; root: Root }> { return { container, root }; } +function requirementSelect(container: HTMLDivElement, key: string): HTMLSelectElement | null { + // Scope the lookup to the "Hard requirements" fieldset so keys that also + // appear in the optimize/unknown-evidence fieldsets (health, cost, quota) + // cannot produce a false match. + const fieldset = [...container.querySelectorAll("fieldset")] + .find(candidate => candidate.querySelector("legend")?.textContent === "Hard requirements"); + const scope: ParentNode = fieldset ?? container; + const label = [...scope.querySelectorAll("label")] + .find(candidate => candidate.querySelector("code")?.textContent === key); + return label?.querySelector("select") ?? null; +} + test("routing page loads profiles, analytics, and marks the dry-run selection", async () => { const dryRunBodies: unknown[] = []; installFetch((url, init) => { @@ -272,7 +284,7 @@ test("routing refreshes the selected profile after reload", async () => { await act(async () => { retry!.click(); }); await tick(3); expect(container.textContent).toContain("rev-def"); - expect(container.textContent).toContain("\"imageInput\": true"); + expect(requirementSelect(container, "imageInput")?.value).toBe("true"); } finally { await act(async () => { root.unmount(); }); } @@ -324,7 +336,7 @@ test("routing ignores a stale load body that finishes after a newer retry", asyn await tick(4); expect(container.textContent).toContain("rev-def"); - expect(container.textContent).toContain("\"imageInput\": true"); + expect(requirementSelect(container, "imageInput")?.value).toBe("true"); await act(async () => { releaseStale(); @@ -345,3 +357,28 @@ test("routing ignores a stale load body that finishes after a newer retry", asyn } }); +test("routing rejects a profile missing a required nested object instead of crashing the load", async () => { + const malformed = { + ...PROFILE, + id: "malformed", + // require is a required DTO field; omitting it must drop the profile. + require: undefined, + }; + installFetch((url, init) => { + if (url.endsWith("/api/routing-profiles") && (init?.method ?? "GET") === "GET") { + return Response.json({ profiles: [PROFILE, malformed] }); + } + if (url.endsWith("/api/routing-analytics")) { + return Response.json(ANALYTICS); + } + return new Response("missing", { status: 404 }); + }); + + const { container, root } = await mountPage(); + try { + expect(container.textContent).toContain("balanced"); + expect(container.textContent).not.toContain("malformed"); + } finally { + await act(async () => { root.unmount(); }); + } +}); diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 9144f9eb4..36687c01b 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -1,12 +1,20 @@ /** * Routing-profile management API (RI-04). * - * - `GET /api/routing-profiles` - normalized profiles with revisions - * - `POST /api/routing-profiles/dry-run` - deterministic dry-run evaluation + * - `GET /api/routing-profiles` - normalized profiles with revisions + * - `PUT /api/routing-profiles` - create or replace one validated profile + * - `DELETE /api/routing-profiles?id=` - remove one profile + * - `POST /api/routing-profiles/dry-run` - deterministic dry-run evaluation * (never dispatches an upstream request) */ -import { listRoutingProfileIds, getRoutingProfile, policyPublicModelId } from "../../routing/profile"; +import { + getRoutingProfile, + listRoutingProfileIds, + normalizeRoutingProfile, + policyPublicModelId, + routingProfileIssues, +} from "../../routing/profile"; import { evaluatePolicyProfile, type PolicyCandidateEvidence, type PolicyRequestEvidence } from "../../routing/evaluator"; import { candidateCapabilityEvidence } from "../../routing/capability"; import { policyCandidateHealthEvidence } from "../../routing/health"; @@ -16,17 +24,20 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { getEffectiveActiveCodexAccountId } from "../../codex/routing"; import { getAccountSet } from "../../oauth/store"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { saveConfigPreservingClaudeCode } from "../../config"; +import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { isPlainRecord } from "./shared"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; -import type { OcxConfig } from "../../types"; +import type { OcxConfig, OcxRoutingProfileConfig } from "../../types"; function profileDto(config: Parameters[0], id: string): Record | null { const profile = getRoutingProfile(config, id); if (!profile) return null; return { id, + alias: profile.alias, model: policyPublicModelId(id, profile), revision: profile.revision, candidates: profile.candidates, @@ -131,8 +142,112 @@ function assembleCandidateEvidence( })); } +function storedProfile( + id: string, + raw: OcxRoutingProfileConfig, +): OcxRoutingProfileConfig { + const normalized = normalizeRoutingProfile(id, raw); + const { + id: _id, + revision: _revision, + alias, + ...profile + } = normalized; + return alias === null ? profile : { ...profile, alias }; +} + +/** + * Rewrite config references from one public model id to another (alias change + * on update). Mirrors the /api/combos migration: model-valued config that + * still names the old alias must follow it, or requests fall through to + * ordinary routing and send the obsolete alias upstream. + */ +/** + * Detect a modelMap key collision that the alias migration would silently + * resolve by dropping one mapping: the map already contains the new public + * model as a key with a different target than the old-alias key's target. + */ +function modelMapMigrationCollision( + config: OcxConfig, + oldPublicModel: string, + newPublicModel: string, +): string | null { + const map = config.claudeCode?.modelMap; + if (!map) return null; + if (oldPublicModel === newPublicModel) return null; + const oldTarget = map[oldPublicModel]; + if (oldTarget === undefined) return null; + const newTarget = map[newPublicModel]; + if (newTarget === undefined) return null; + if (oldTarget === newTarget) return null; + return `modelMap already maps \"${newPublicModel}\" to \"${newTarget}\"; renaming \"${oldPublicModel}\" (→ \"${newTarget}\") would drop one mapping. Resolve the conflict and retry.`; +} + +/** + * Rewrite config references from one public model id to another (alias change + * on update). Mirrors the /api/combos migration: model-valued config that + * still names the old alias must follow it, or requests fall through to + * ordinary routing and send the obsolete alias upstream. + */ +function migrateProfileModelReferences( + config: OcxConfig, + oldPublicModel: string, + newPublicModel: string, +): boolean { + if (oldPublicModel === newPublicModel) return false; + const migrateReference = (model: string): string => ( + model === oldPublicModel ? newPublicModel : model + ); + let shouldSyncClaudeAgentDefs = false; + const migrateAgentReference = (model: string): string => { + const migrated = migrateReference(model); + if (migrated !== model) shouldSyncClaudeAgentDefs = true; + return migrated; + }; + const migrateReferences = (models: string[]): string[] => [ + ...new Set(models.map(migrateReference)), + ]; + if (config.disabledModels) { + config.disabledModels = migrateReferences(config.disabledModels); + } + if (config.subagentModels) { + config.subagentModels = [...new Set(config.subagentModels.map(migrateAgentReference))]; + } + if (config.subagentModelFallback) { + config.subagentModelFallback = [...new Set(config.subagentModelFallback.map(migrateAgentReference))]; + } + if (config.injectionModel && config.injectionModel === oldPublicModel) { + config.injectionModel = newPublicModel; + } + if (config.shadowCallIntercept?.model && config.shadowCallIntercept.model === oldPublicModel) { + config.shadowCallIntercept = { ...config.shadowCallIntercept, model: newPublicModel }; + } + if (config.claudeCode) { + const claudeCode = { ...config.claudeCode }; + for (const field of ["model", "smallFastModel"] as const) { + if (claudeCode[field]) claudeCode[field] = migrateAgentReference(claudeCode[field]); + } + if (claudeCode.tierModels) { + claudeCode.tierModels = Object.fromEntries( + Object.entries(claudeCode.tierModels).map(([tier, model]) => [tier, migrateAgentReference(model)]), + ); + } + if (claudeCode.modelMap) { + // Keys are the inbound ids matched for reroute (src/claude/inbound.ts); + // an old-alias key must follow the rename or that request stops intercepting. + claudeCode.modelMap = Object.fromEntries( + Object.entries(claudeCode.modelMap).map(([source, model]) => [ + migrateAgentReference(source), + migrateAgentReference(model), + ]), + ); + } + config.claudeCode = claudeCode; + } + return shouldSyncClaudeAgentDefs; +} export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promise { - const { req, url, config } = ctx; + const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/routing-profiles" && req.method === "GET") { const profiles = listRoutingProfileIds(config).map(id => profileDto(config, id)).filter( @@ -141,6 +256,120 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis return jsonResponse({ profiles }, 200, req, config); } + if (url.pathname === "/api/routing-profiles" && req.method === "PUT") { + let rawBody: unknown; + try { + rawBody = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, req, config); + } + if (!isPlainRecord(rawBody)) { + return jsonResponse({ error: "request body must be an object" }, 400, req, config); + } + const body = rawBody as Record; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (!id) { + return jsonResponse({ error: { code: "missing_profile_id", message: "id is required" } }, 400, req, config); + } + const mode = body.mode === "create" || body.mode === "update" ? body.mode : null; + if (!mode) { + return jsonResponse({ error: { code: "invalid_profile_mode", message: "mode must be create or update" } }, 400, req, config); + } + const exists = Object.hasOwn(config.routingProfiles ?? {}, id); + if (mode === "create" && exists) { + return jsonResponse({ error: { code: "profile_exists", message: `routing profile already exists: ${id}` } }, 409, req, config); + } + if (mode === "update" && !exists) { + return jsonResponse({ error: { code: "unknown_profile", message: `unknown routing profile: ${id}` } }, 404, req, config); + } + if (mode === "update") { + const expectedRevision = typeof body.expectedRevision === "string" && body.expectedRevision.trim() + ? body.expectedRevision.trim() + : undefined; + if (expectedRevision) { + const current = getRoutingProfile(config, id); + if (current && current.revision !== expectedRevision) { + return jsonResponse({ + error: { + code: "profile_revision_conflict", + message: `routing profile ${id} changed since it was loaded; reload and retry`, + currentRevision: current.revision, + }, + }, 409, req, config); + } + } + } + const issues = routingProfileIssues(id, body.profile, config, { excludeProfileId: id }); + if (issues.length > 0) { + return jsonResponse({ + error: { + code: "invalid_profile", + message: issues[0]!.message, + issues, + }, + }, 400, req, config); + } + + const previousProfile = mode === "update" ? getRoutingProfile(config, id) : undefined; + if (mode === "update" && previousProfile) { + const oldPublicModel = policyPublicModelId(id, previousProfile); + const newProfile = normalizeRoutingProfile(id, body.profile as OcxRoutingProfileConfig); + const newPublicModel = policyPublicModelId(id, newProfile); + const collision = modelMapMigrationCollision(config, oldPublicModel, newPublicModel); + if (collision) { + return jsonResponse({ + error: { code: "alias_reference_conflict", message: collision }, + }, 409, req, config); + } + } + const nextProfiles = { ...(config.routingProfiles ?? {}) }; + nextProfiles[id] = storedProfile(id, body.profile as OcxRoutingProfileConfig); + config.routingProfiles = nextProfiles; + // An alias change on update renames the public model id; rewrite config + // references (disabledModels, subagentModels, injectionModel, + // shadowCallIntercept, claudeCode) so they follow the new alias. + let shouldSyncClaudeAgentDefs = false; + if (previousProfile) { + const oldPublicModel = policyPublicModelId(id, previousProfile); + const newPublicModel = policyPublicModelId(id, getRoutingProfile(config, id)!); + shouldSyncClaudeAgentDefs = migrateProfileModelReferences(config, oldPublicModel, newPublicModel); + } + const save = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + save(config); + reconcileLiveStateStores(); + const catalogRefresh = await convergeCodexCatalog(); + if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort(); + const profile = profileDto(config, id)!; + return jsonResponse({ + success: true, + id, + model: profile.model, + profile, + catalogRefresh, + }, 200, req, config); + } + + if (url.pathname === "/api/routing-profiles" && req.method === "DELETE") { + const id = url.searchParams.get("id")?.trim(); + if (!id) { + return jsonResponse({ error: "id query param is required" }, 400, req, config); + } + if (!Object.hasOwn(config.routingProfiles ?? {}, id)) { + return jsonResponse({ error: "unknown routing profile" }, 404, req, config); + } + + const nextProfiles = { ...(config.routingProfiles ?? {}) }; + delete nextProfiles[id]; + if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles; + else delete config.routingProfiles; + const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + saveConfigPreservingClaudeCodeSafe(config); + reconcileLiveStateStores(); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ success: true, id, catalogRefresh }, 200, req, config); + } + if (url.pathname === "/api/routing-profiles/dry-run" && req.method === "POST") { let rawBody: unknown; try { rawBody = await readManagementJsonBody(req); } catch (error) { diff --git a/tests/routing-intelligence-ui.test.ts b/tests/routing-intelligence-ui.test.ts index c4508f13a..10376fc78 100644 --- a/tests/routing-intelligence-ui.test.ts +++ b/tests/routing-intelligence-ui.test.ts @@ -17,9 +17,13 @@ test("routing is a first-class dashboard page with a registered hash", () => { expect(resolveAppHashChange("routing").replaceTo).toBeNull(); }); -test("Routing page wires profiles, dry-run, and analytics against management APIs", () => { +test("Routing page wires profile CRUD, dry-run, and analytics against management APIs", () => { const page = readFileSync(join(guiRoot, "pages", "RoutingProfiles.tsx"), "utf8"); expect(page).toContain("/api/routing-profiles"); + expect(page).toContain('method: "PUT"'); + expect(page).toContain('method: "DELETE"'); + expect(page).toContain("routingProfilePutBody"); + expect(page).toContain(" { + test("creates a usable draft with one candidate and stable defaults", () => { + const draft = newRoutingProfileDraft("openai", "gpt-5.6"); + expect(draft.candidates).toEqual([ + { provider: "openai", model: "gpt-5.6", key: expect.stringMatching(/^candidate-\d+$/) }, + ]); + // Keys are unique per created candidate. + expect(newRoutingProfileDraft("openai", "gpt-5.6").candidates[0]!.key) + .not.toBe(draft.candidates[0]!.key); + expect(draft.optimize).toEqual({ latency: "0.55", health: "0.25", cost: "0.1", quota: "0.1" }); + expect(draft.unknownEvidence.capability).toBe("exclude"); + }); + + test("round-trips normalized DTO values into a PUT payload", () => { + const draft = routingProfileDraftFromDto(profile); + const body = routingProfilePutBody(draft, "update", profile.revision); + + expect(body).toEqual({ + mode: "update", + id: "fast", + expectedRevision: "abc123", + profile: { + alias: "ocx/fast", + candidates: profile.candidates, + require: { + minContextWindow: 128000, + minQuotaHeadroom: 0.2, + tools: true, + imageInput: false, + reasoningEffort: "high", + }, + optimize: profile.optimize, + limits: { maxEstimatedCostUsd: 0.5 }, + unknownEvidence: profile.unknownEvidence, + }, + }); + }); + + test("carries expectedRevision on update and omits it on create", () => { + const draft = newRoutingProfileDraft("openai", "gpt-5.6"); + const updateBody = routingProfilePutBody(draft, "update", "rev-1"); + expect(updateBody).toMatchObject({ mode: "update", expectedRevision: "rev-1" }); + const createBody = routingProfilePutBody(draft, "create"); + expect(createBody).toMatchObject({ mode: "create" }); + expect(createBody).not.toHaveProperty("expectedRevision"); + }); + + test("omits blank optional fields while retaining explicit false", () => { + const draft = newRoutingProfileDraft("openai", "gpt-5.6"); + draft.id = " balanced "; + draft.require.tools = "false"; + draft.require.serviceTier = " "; + draft.limits.maxEstimatedCostUsd = ""; + + expect(routingProfilePutBody(draft, "create")).toMatchObject({ + mode: "create", + id: "balanced", + profile: { + candidates: [{ provider: "openai", model: "gpt-5.6" }], + require: { tools: false }, + }, + }); + expect(routingProfilePutBody(draft, "create").profile).not.toHaveProperty("limits"); + expect(routingProfilePutBody(draft, "create").profile).not.toHaveProperty("alias"); + }); + + test("extracts both string and structured management errors", () => { + expect(routingProfileResponseError({ error: "plain failure" })).toBe("plain failure"); + expect(routingProfileResponseError({ error: { message: "validation failure" } })).toBe("validation failure"); + expect(routingProfileResponseError({ success: true })).toBeUndefined(); + expect(routingProfileResponseSucceeded({ success: true })).toBe(true); + expect(routingProfileResponseSucceeded({ success: false })).toBe(false); + }); + + test("filters model suggestions by provider", () => { + expect(modelOptionsForProvider([ + { provider: "openai", id: "gpt-5.6" }, + { provider: "anthropic", id: "claude-sonnet-5" }, + ], "anthropic")).toEqual([{ provider: "anthropic", id: "claude-sonnet-5" }]); + }); +}); diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts new file mode 100644 index 000000000..7f92b52e0 --- /dev/null +++ b/tests/routing-profile-management-editor.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../src/server/management-api"; +import { ManagementRequest } from "./helpers/management-auth"; +import type { OcxConfig } from "../src/types"; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-profile-editor-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +function baseConfig(): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1", "m2"] }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, + }, + routingProfiles: { + fast: { + alias: "ocx/fast", + candidates: [{ provider: "a", model: "m1" }], + }, + }, + }; +} + +function deps(onSave: () => void = () => {}, onRefresh: () => void = () => {}) { + return { + saveConfigPreservingClaudeCode: () => onSave(), + createManagementConvergeCodex: () => async () => { + onRefresh(); + return { + kind: "catalog-only" as const, + catalogRefresh: { + status: "committed" as const, + changed: false, + degraded: false, + notices: [], + }, + }; + }, + }; +} + +describe("routing profile management editor API", () => { + test("GET exposes the configured alias for editor round-trips", async () => { + const config = baseConfig(); + const req = new ManagementRequest("http://localhost/api/routing-profiles", { method: "GET" }); + const response = await handleManagementAPI(req, new URL(req.url), config, deps()); + expect(response?.status).toBe(200); + const body = await response!.json() as { profiles?: Array<{ id?: string; alias?: string | null }> }; + expect(body.profiles?.[0]).toMatchObject({ id: "fast", alias: "ocx/fast" }); + }); + + test("PUT creates a validated normalized profile and refreshes the catalog", async () => { + const config = baseConfig(); + let saves = 0; + let refreshes = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "balanced", + mode: "create", + profile: { + alias: "ocx/balanced", + candidates: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + require: { tools: false, minContextWindow: 64000 }, + optimize: { latency: 2, health: 1, cost: 1, quota: 0 }, + limits: { maxEstimatedCostUsd: 0.25 }, + unknownEvidence: { + capability: "exclude", + health: "penalize", + quota: "allow", + cost: "penalize", + }, + }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }, () => { refreshes += 1; }), + ); + + expect(response?.status).toBe(200); + const body = await response!.json() as { + success?: boolean; + profile?: { alias?: string | null; optimize?: Record; revision?: string }; + }; + expect(body.success).toBe(true); + expect(body.profile?.alias).toBe("ocx/balanced"); + expect(body.profile?.optimize).toEqual({ latency: 0.5, health: 0.25, cost: 0.25, quota: 0 }); + expect(body.profile?.revision).toMatch(/^[0-9a-f]{16}$/); + expect(config.routingProfiles?.balanced).toMatchObject({ + alias: "ocx/balanced", + require: { tools: false, minContextWindow: 64000 }, + }); + expect(saves).toBe(1); + expect(refreshes).toBe(1); + }); + + test("PUT rejects invalid candidates without mutating or persisting", async () => { + const config = baseConfig(); + let saves = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "broken", + mode: "create", + profile: { candidates: [{ provider: "missing", model: "m1" }] }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }), + ); + + expect(response?.status).toBe(400); + const body = await response!.json() as { error?: { code?: string; issues?: unknown[] } }; + expect(body.error?.code).toBe("invalid_profile"); + expect(body.error?.issues?.length).toBeGreaterThan(0); + expect(config.routingProfiles).not.toHaveProperty("broken"); + expect(saves).toBe(0); + }); + + test("PUT create refuses to overwrite an existing profile", async () => { + const config = baseConfig(); + let saves = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "create", + profile: { candidates: [{ provider: "a", model: "m2" }] }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }), + ); + + expect(response?.status).toBe(409); + expect(await response!.json()).toMatchObject({ error: { code: "profile_exists" } }); + expect(config.routingProfiles?.fast?.candidates).toEqual([{ provider: "a", model: "m1" }]); + expect(saves).toBe(0); + }); + + test("PUT update replaces an existing profile, persists once, and refreshes the catalog", async () => { + const config = baseConfig(); + let saves = 0; + let refreshes = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "update", + profile: { + alias: "ocx/faster", + candidates: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + require: { tools: true, minContextWindow: 64000 }, + optimize: { latency: 2, health: 1, cost: 1, quota: 0 }, + }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }, () => { refreshes += 1; }), + ); + + expect(response?.status).toBe(200); + const body = await response!.json() as { + success?: boolean; + profile?: { alias?: string | null; candidates?: unknown[]; revision?: string }; + }; + expect(body.success).toBe(true); + expect(body.profile?.alias).toBe("ocx/faster"); + expect(body.profile?.candidates).toEqual([ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ]); + expect(body.profile?.revision).toMatch(/^[0-9a-f]{16}$/); + expect(config.routingProfiles?.fast).toMatchObject({ + alias: "ocx/faster", + candidates: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + require: { tools: true, minContextWindow: 64000 }, + }); + expect(saves).toBe(1); + expect(refreshes).toBe(1); + }); + + test("PUT update rejects a stale expectedRevision with 409 and does not persist", async () => { + const config = baseConfig(); + let saves = 0; + const current = await (async () => { + const req = new ManagementRequest("http://localhost/api/routing-profiles", { method: "GET" }); + const res = await handleManagementAPI(req, new URL(req.url), config, deps()); + const body = await res!.json() as { profiles?: Array<{ id: string; revision: string }> }; + return body.profiles!.find(p => p.id === "fast")!; + })(); + + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "update", + expectedRevision: "definitely-stale-revision", + profile: { candidates: [{ provider: "a", model: "m2" }] }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }), + ); + + expect(response?.status).toBe(409); + expect(await response!.json()).toMatchObject({ error: { code: "profile_revision_conflict" } }); + expect(config.routingProfiles?.fast?.candidates).toEqual([{ provider: "a", model: "m1" }]); + expect(saves).toBe(0); + expect(current.revision).toMatch(/^[0-9a-f]{16}$/); + }); + + test("PUT update migrates config references when the profile alias changes", async () => { + const config = baseConfig(); + config.disabledModels = ["ocx/fast"]; + config.subagentModels = ["ocx/fast", "a/m1"]; + config.subagentModelFallback = ["ocx/fast", "a/m1"]; + config.injectionModel = "ocx/fast"; + config.shadowCallIntercept = { model: "ocx/fast" }; + config.claudeCode = { + enabled: true, + model: "ocx/fast", + smallFastModel: "a/m1", + modelMap: { "ocx/fast": "a/m1", "a/m2": "ocx/fast" }, + }; + let saves = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "update", + expectedRevision: (await (async () => { + const getReq = new ManagementRequest("http://localhost/api/routing-profiles", { method: "GET" }); + const getRes = await handleManagementAPI(getReq, new URL(getReq.url), config, deps()); + const getBody = await getRes!.json() as { profiles?: Array<{ revision: string }> }; + return getBody.profiles![0]!.revision; + })()), + profile: { + alias: "ocx/faster", + candidates: [{ provider: "a", model: "m1" }], + }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }), + ); + + expect(response?.status).toBe(200); + expect(config.disabledModels).toEqual(["ocx/faster"]); + expect(config.subagentModels).toEqual(["ocx/faster", "a/m1"]); + expect(config.subagentModelFallback).toEqual(["ocx/faster", "a/m1"]); + expect(config.injectionModel).toBe("ocx/faster"); + expect(config.shadowCallIntercept?.model).toBe("ocx/faster"); + expect(config.claudeCode?.model).toBe("ocx/faster"); + expect(config.claudeCode?.smallFastModel).toBe("a/m1"); + expect(config.claudeCode?.modelMap).toEqual({ "ocx/faster": "a/m1", "a/m2": "ocx/faster" }); + expect(saves).toBe(1); + }); + + test("PUT update rejects a modelMap key collision instead of silently dropping a mapping", async () => { + const config = baseConfig(); + config.claudeCode = { + enabled: true, + modelMap: { + "ocx/fast": "a/m1", + "ocx/faster": "a/m2", + }, + }; + let saves = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "fast", + mode: "update", + expectedRevision: (await (async () => { + const getReq = new ManagementRequest("http://localhost/api/routing-profiles", { method: "GET" }); + const getRes = await handleManagementAPI(getReq, new URL(getReq.url), config, deps()); + const getBody = await getRes!.json() as { profiles?: Array<{ revision: string }> }; + return getBody.profiles![0]!.revision; + })()), + profile: { + alias: "ocx/faster", + candidates: [{ provider: "a", model: "m1" }], + }, + }), + }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }), + ); + + expect(response?.status).toBe(409); + expect(await response!.json()).toMatchObject({ error: { code: "alias_reference_conflict" } }); + expect(config.routingProfiles?.fast).toMatchObject({ alias: "ocx/fast" }); + expect(config.claudeCode?.modelMap).toEqual({ "ocx/fast": "a/m1", "ocx/faster": "a/m2" }); + expect(saves).toBe(0); + }); + + test("DELETE removes a profile, persists, and refreshes the catalog", async () => { + const config = baseConfig(); + let saves = 0; + let refreshes = 0; + const req = new ManagementRequest("http://localhost/api/routing-profiles?id=fast", { method: "DELETE" }); + const response = await handleManagementAPI( + req, + new URL(req.url), + config, + deps(() => { saves += 1; }, () => { refreshes += 1; }), + ); + + expect(response?.status).toBe(200); + expect(await response!.json()).toMatchObject({ success: true, id: "fast" }); + expect(config.routingProfiles).toBeUndefined(); + expect(saves).toBe(1); + expect(refreshes).toBe(1); + }); +});