From 607b851e72e93b9ddaa18201995c6ac74655eacd Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:32:10 +0200 Subject: [PATCH 01/23] feat(routing): add routing profile management endpoints --- .../management/routing-profile-routes.ts | 99 ++++++++++++++++++- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 9144f9eb4..0eab4926a 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,22 @@ 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 }; +} + export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promise { - const { req, url, config } = ctx; + const { req, url, config, deps, convergeCodexCatalog } = ctx; if (url.pathname === "/api/routing-profiles" && req.method === "GET") { const profiles = listRoutingProfileIds(config).map(id => profileDto(config, id)).filter( @@ -141,6 +166,70 @@ 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 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 nextProfiles = { ...(config.routingProfiles ?? {}) }; + nextProfiles[id] = storedProfile(id, body.profile as OcxRoutingProfileConfig); + config.routingProfiles = nextProfiles; + const saveConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + saveConfig(config); + reconcileLiveStateStores(); + const catalogRefresh = await convergeCodexCatalog(); + 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 saveConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + saveConfig(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) { From f91fcf775b3fbce9a92eda7daeae6fceccd49361 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:32:47 +0200 Subject: [PATCH 02/23] feat(gui): add routing profile editor data model --- gui/src/routing-profile-editor-data.ts | 229 +++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 gui/src/routing-profile-editor-data.ts diff --git a/gui/src/routing-profile-editor-data.ts b/gui/src/routing-profile-editor-data.ts new file mode 100644 index 000000000..73c358f19 --- /dev/null +++ b/gui/src/routing-profile-editor-data.ts @@ -0,0 +1,229 @@ +export type UnknownEvidenceMode = "allow" | "penalize" | "exclude"; +export type OptionalBoolean = "" | "true" | "false"; + +export type RoutingProfileCandidate = { + provider: string; + model: string; +}; + +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: RoutingProfileCandidate[]; + 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: [{ 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 })), + 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 function routingProfilePutBody(draft: RoutingProfileDraft): { + id: 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 { + id: draft.id.trim(), + 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); +} From 8cebab7db25b63bb77633b7efc67076c786707c0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:35:05 +0200 Subject: [PATCH 03/23] feat(gui): add routing profiles editor --- gui/src/pages/RoutingProfiles.tsx | 580 +++++++++++++++++++++++++----- 1 file changed, 497 insertions(+), 83 deletions(-) diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 7732dabf0..0d302e16b 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -1,18 +1,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { + modelOptionsForProvider, + newRoutingProfileDraft, + routingProfileDraftFromDto, + routingProfilePutBody, + routingProfileResponseError, + routingProfileResponseSucceeded, + type ModelOption, + type OptionalBoolean, + type RoutingProfileDraft, + type RoutingProfileDto, + type UnknownEvidenceMode, +} from "../routing-profile-editor-data"; 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 +41,29 @@ 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_REQUIREMENTS = ["minContextWindow", "minQuotaHeadroom"] as const; +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 +72,74 @@ 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 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 => ( + !!profile + && typeof profile === "object" + && !Array.isArray(profile) + && typeof (profile as { id?: unknown }).id === "string" + && typeof (profile as { model?: unknown }).model === "string" + && typeof (profile as { revision?: unknown }).revision === "string" + && Array.isArray((profile as { candidates?: unknown }).candidates) + )).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 = `${provider}\u0000${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(""); + const [statusOk, setStatusOk] = useState(false); + const [saving, setSaving] = useState(false); const [context, setContext] = useState(""); const [tools, setTools] = useState(false); const [image, setImage] = useState(false); @@ -78,52 +147,84 @@ 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); + setStatusOk(ok); + }, []); + + useEffect(() => { + if (!status || !statusOk) return; + const timer = window.setTimeout(() => { + setStatus(""); + setStatusOk(false); + }, 5000); + return () => window.clearTimeout(timer); + }, [status, statusOk]); + 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(""); + setStatusOk(false); 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 +236,124 @@ 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(""); + setStatusOk(false); + clearDryRun(); + }; + + const cancelEdit = () => { + if (selected) { + setDraft(routingProfileDraftFromDto(selected)); + setStatus(""); + setStatusOk(false); + return; + } + selectProfile(profiles[0] ?? null); + }; + + const saveProfile = async () => { + if (!draft || saving) return; + setSaving(true); + setStatus(""); + setStatusOk(false); + try { + const body = routingProfilePutBody(draft); + const response = await fetch(`${apiBase}/api/routing-profiles`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await response.json().catch(() => null) as unknown; + const serverError = routingProfileResponseError(data); + if (!response.ok || serverError || !routingProfileResponseSucceeded(data)) { + notify(serverError ?? 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("common.remove")} ${selected.id}?`)) return; + setSaving(true); + setStatus(""); + setStatusOk(false); + try { + const response = await fetch( + `${apiBase}/api/routing-profiles?id=${encodeURIComponent(selected.id)}`, + { method: "DELETE" }, + ); + const data = await response.json().catch(() => null) as unknown; + const serverError = routingProfileResponseError(data); + if (!response.ok || serverError || !routingProfileResponseSucceeded(data)) { + notify(serverError ?? 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 { + 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, + { provider: firstProvider, model: 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 +379,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) ?? `dry-run ${response.status}`); return; } const result = await response.json() as DryRunResult; @@ -184,19 +397,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} : null} - {profiles.length === 0 && !loadError ? ( -
{t("routing.empty")}
- ) : ( + {profiles.length > 0 ? (
{profiles.map(profile => (
- )} + ) : null} + + {draft ? ( +
{ + event.preventDefault(); + void saveProfile(); + }} + > +
+

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

+ {selected ? {t("routing.revision")}: {selected.revision} : null} +
- {selected ? ( -
-

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

-
- {t("routing.candidates")} +
+ + +
+ +
+ {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}
From 455a112c74bbbd6b208edf51935c159a231a0d69 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:35:33 +0200 Subject: [PATCH 04/23] test(gui): cover routing profile editor payloads --- tests/routing-profile-editor-data.test.ts | 101 ++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/routing-profile-editor-data.test.ts diff --git a/tests/routing-profile-editor-data.test.ts b/tests/routing-profile-editor-data.test.ts new file mode 100644 index 000000000..d894c79cc --- /dev/null +++ b/tests/routing-profile-editor-data.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { + modelOptionsForProvider, + newRoutingProfileDraft, + routingProfileDraftFromDto, + routingProfilePutBody, + routingProfileResponseError, + routingProfileResponseSucceeded, + type RoutingProfileDto, +} from "../gui/src/routing-profile-editor-data"; + +const profile: RoutingProfileDto = { + id: "fast", + alias: "ocx/fast", + model: "ocx/fast", + revision: "abc123", + candidates: [ + { provider: "anthropic", model: "claude-sonnet-5" }, + { provider: "openai", model: "gpt-5.6" }, + ], + require: { + minContextWindow: 128000, + minQuotaHeadroom: 0.2, + tools: true, + imageInput: false, + reasoningEffort: "high", + }, + optimize: { latency: 0.55, health: 0.25, cost: 0.1, quota: 0.1 }, + limits: { maxEstimatedCostUsd: 0.5 }, + unknownEvidence: { + capability: "exclude", + health: "penalize", + quota: "allow", + cost: "penalize", + }, +}; + +describe("routing profile editor data", () => { + 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" }]); + 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); + + expect(body).toEqual({ + id: "fast", + 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("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)).toMatchObject({ + id: "balanced", + profile: { + candidates: [{ provider: "openai", model: "gpt-5.6" }], + require: { tools: false }, + }, + }); + expect(routingProfilePutBody(draft).profile).not.toHaveProperty("limits"); + expect(routingProfilePutBody(draft).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" }]); + }); +}); From 4241d883db0b29a7b17d8e38b98e02bcb112c2cb Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:36:06 +0200 Subject: [PATCH 05/23] test(routing): cover routing profile CRUD --- .../routing-profile-management-editor.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/routing-profile-management-editor.test.ts diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts new file mode 100644 index 000000000..06173249f --- /dev/null +++ b/tests/routing-profile-management-editor.test.ts @@ -0,0 +1,164 @@ +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", + 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", + 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("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); + }); +}); From 80c9dac2ad27eefcf55188c70ae41f86f3ea727e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:36:32 +0200 Subject: [PATCH 06/23] test(gui): require routing profile editor wiring --- tests/routing-intelligence-ui.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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(" Date: Thu, 6 Aug 2026 09:36:53 +0200 Subject: [PATCH 07/23] docs(routing): document dashboard profile editor --- .../docs/guides/routing-profile-editor.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs-site/src/content/docs/guides/routing-profile-editor.md 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..24cb5c548 --- /dev/null +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -0,0 +1,68 @@ +--- +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 **+ 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 replaces one profile. +- `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", + "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" + } + } +} +``` From 3ba5c4a2a5c056cb76f45f05523df98d7d731a12 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:42:07 +0200 Subject: [PATCH 08/23] fix(routing): prevent create-mode profile overwrites --- src/server/management/routing-profile-routes.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 0eab4926a..f8b232dcd 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -182,6 +182,17 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis 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); + } const issues = routingProfileIssues(id, body.profile, config, { excludeProfileId: id }); if (issues.length > 0) { return jsonResponse({ From e4986e18aba387dda81214f054c602bbb13d1448 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:44:14 +0200 Subject: [PATCH 09/23] fix(gui): send explicit routing profile write modes --- gui/src/pages/RoutingProfiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 0d302e16b..4b089bb87 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -270,7 +270,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const response = await fetch(`${apiBase}/api/routing-profiles`, { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify({ ...body, mode: selected ? "update" : "create" }), }); const data = await response.json().catch(() => null) as unknown; const serverError = routingProfileResponseError(data); From 1b229a0c70199337f8380fe14ab6c809419822ce Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:44:54 +0200 Subject: [PATCH 10/23] test(routing): reject duplicate create-mode profiles --- .../routing-profile-management-editor.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts index 06173249f..0014fada4 100644 --- a/tests/routing-profile-management-editor.test.ts +++ b/tests/routing-profile-management-editor.test.ts @@ -75,6 +75,7 @@ describe("routing profile management editor API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ id: "balanced", + mode: "create", profile: { alias: "ocx/balanced", candidates: [ @@ -125,6 +126,7 @@ describe("routing profile management editor API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ id: "broken", + mode: "create", profile: { candidates: [{ provider: "missing", model: "m1" }] }, }), }); @@ -143,6 +145,31 @@ describe("routing profile management editor API", () => { 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("DELETE removes a profile, persists, and refreshes the catalog", async () => { const config = baseConfig(); let saves = 0; From a71034cbcb83fd80ff951d4ed55558e926994a2f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:45:14 +0200 Subject: [PATCH 11/23] docs(routing): document explicit profile write modes --- docs-site/src/content/docs/guides/routing-profile-editor.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 24cb5c548..0f7852dcf 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -39,7 +39,7 @@ Unsaved edits are not used by dry-run. Save the profile first so the displayed r The editor uses these endpoints: - `GET /api/routing-profiles` lists normalized profiles and revisions. -- `PUT /api/routing-profiles` creates or replaces one profile. +- `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. @@ -48,6 +48,7 @@ Example save payload: ```json { "id": "fast", + "mode": "create", "profile": { "alias": "ocx/fast", "candidates": [ From 0874ef81d6d41e533068532936915a7f7f9a939a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:54:21 +0200 Subject: [PATCH 12/23] test(gui): assert refreshed routing editor values --- gui/tests/routing-profiles.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/gui/tests/routing-profiles.test.tsx b/gui/tests/routing-profiles.test.tsx index 64bc1863b..99571de94 100644 --- a/gui/tests/routing-profiles.test.tsx +++ b/gui/tests/routing-profiles.test.tsx @@ -142,6 +142,12 @@ async function mountPage(): Promise<{ container: HTMLDivElement; root: Root }> { return { container, root }; } +function requirementSelect(container: HTMLDivElement, key: string): HTMLSelectElement | null { + const label = [...container.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 +278,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 +330,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(); @@ -344,4 +350,3 @@ test("routing ignores a stale load body that finishes after a newer retry", asyn }); } }); - From 94d3530fb291b7db71c4b059700b064c109ea01a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:59:40 +0200 Subject: [PATCH 13/23] fix(gui): avoid i18n lint on model dedupe key --- gui/src/pages/RoutingProfiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 4b089bb87..19f028e05 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -105,7 +105,7 @@ function parseModels(raw: unknown): ModelOption[] { : ""; if (!provider || !id || provider === "combo" || provider === "policy") continue; if ((row as { disabled?: unknown }).disabled === true) continue; - const key = `${provider}\u0000${id}`; + const key = JSON.stringify([provider, id]); if (seen.has(key)) continue; seen.add(key); models.push({ provider, id }); From 0acda36e47cf68a7a92e5d032554f00763902b69 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:13:38 +0200 Subject: [PATCH 14/23] chore(ci): expose temporary React Doctor diagnostics --- .github/workflows/react-doctor.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 23e0a4777..0448d2006 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -56,3 +56,11 @@ jobs: comment: false review-comments: false commit-status: false + + # Temporary diagnostic: React Doctor writes its detailed report only to + # the job summary. Echo it into the normal log so the exact findings can + # be addressed while this draft PR is under construction. + - name: Print React Doctor report + if: always() + shell: bash + run: cat "$GITHUB_STEP_SUMMARY" From ba120665026a3936e2373c4305ed5c14ae2c8bef Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:17:16 +0200 Subject: [PATCH 15/23] chore(ci): remove temporary React Doctor diagnostics --- .github/workflows/react-doctor.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 0448d2006..bbac3663a 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -55,12 +55,4 @@ jobs: blocking: warning comment: false review-comments: false - commit-status: false - - # Temporary diagnostic: React Doctor writes its detailed report only to - # the job summary. Echo it into the normal log so the exact findings can - # be addressed while this draft PR is under construction. - - name: Print React Doctor report - if: always() - shell: bash - run: cat "$GITHUB_STEP_SUMMARY" + commit-status: false \ No newline at end of file From d77c5b7e3228151e969d9a5230589de726eb6d92 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:17:54 +0200 Subject: [PATCH 16/23] chore(ci): restore workflow byte-for-byte --- .github/workflows/react-doctor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index bbac3663a..23e0a4777 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -55,4 +55,4 @@ jobs: blocking: warning comment: false review-comments: false - commit-status: false \ No newline at end of file + commit-status: false From 7538fe00c97dff3c9078704a1c1f6d5ae08039f4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:03:30 +0200 Subject: [PATCH 17/23] fix(routing): route config saves through the guarded ClaudeCode-preserving wrapper The PUT and DELETE routing-profile handlers assigned the guarded saver to a local named \saveConfig\, which trips the config-save-boundary guard that forbids bare saveConfig calls from live-config management writers. Alias it to \save\ like the other management routes so test 4/4 goes green. --- src/server/management/routing-profile-routes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index f8b232dcd..6b3931a21 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -207,8 +207,8 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis const nextProfiles = { ...(config.routingProfiles ?? {}) }; nextProfiles[id] = storedProfile(id, body.profile as OcxRoutingProfileConfig); config.routingProfiles = nextProfiles; - const saveConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; - saveConfig(config); + const save = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + save(config); reconcileLiveStateStores(); const catalogRefresh = await convergeCodexCatalog(); const profile = profileDto(config, id)!; @@ -234,8 +234,8 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis delete nextProfiles[id]; if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles; else delete config.routingProfiles; - const saveConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; - saveConfig(config); + const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + saveConfigPreservingClaudeCodeSafe(config); reconcileLiveStateStores(); const catalogRefresh = await convergeCodexCatalog(); return jsonResponse({ success: true, id, catalogRefresh }, 200, req, config); From fbef064926b165c4ae8dc9578a5e8cbb2c0e1eae Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:15:43 +0200 Subject: [PATCH 18/23] refactor(routing): simplify routing editor state and numeric requirement specs - Replace the inline min/max/step ternary chain in the numeric requirement inputs with a per-key spec table (single source for constraints). - Merge the status/statusOk state pair into one {message, ok} | null state, collapsing the notify helper and the repeated reset pairs. Behavior-preserving: same input attributes emitted, same notice behavior (errors persist, success auto-clears after 5s). --- gui/src/pages/RoutingProfiles.tsx | 44 +++++++++++++------------------ 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 19f028e05..9c97a2e93 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -59,7 +59,11 @@ const BOOLEAN_REQUIREMENTS = [ "encryptedCodexTasks", ] as const; const STRING_REQUIREMENTS = ["reasoningEffort", "serviceTier"] as const; -const NUMERIC_REQUIREMENTS = ["minContextWindow", "minQuotaHeadroom"] as const; +const NUMERIC_REQUIREMENT_SPEC = { + minContextWindow: { min: 1, 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"]; @@ -137,8 +141,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const [loadError, setLoadError] = useState(""); const [selected, setSelected] = useState(null); const [draft, setDraft] = useState(null); - const [status, setStatus] = useState(""); - const [statusOk, setStatusOk] = useState(false); + const [status, setStatus] = useState<{ message: string; ok: boolean } | null>(null); const [saving, setSaving] = useState(false); const [context, setContext] = useState(""); const [tools, setTools] = useState(false); @@ -152,18 +155,14 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const dryRunGenerationRef = useRef(0); const notify = useCallback((message: string, ok: boolean) => { - setStatus(message); - setStatusOk(ok); + setStatus({ message, ok }); }, []); useEffect(() => { - if (!status || !statusOk) return; - const timer = window.setTimeout(() => { - setStatus(""); - setStatusOk(false); - }, 5000); + if (!status?.ok) return; + const timer = window.setTimeout(() => setStatus(null), 5000); return () => window.clearTimeout(timer); - }, [status, statusOk]); + }, [status]); const clearDryRun = useCallback(() => { dryRunGenerationRef.current += 1; @@ -176,8 +175,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { selectedRef.current = profile; setSelected(profile); setDraft(profile ? routingProfileDraftFromDto(profile) : null); - setStatus(""); - setStatusOk(false); + setStatus(null); clearDryRun(); }, [clearDryRun]); @@ -245,16 +243,14 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { selectedRef.current = null; setSelected(null); setDraft(newRoutingProfileDraft(firstProvider, firstModel)); - setStatus(""); - setStatusOk(false); + setStatus(null); clearDryRun(); }; const cancelEdit = () => { if (selected) { setDraft(routingProfileDraftFromDto(selected)); - setStatus(""); - setStatusOk(false); + setStatus(null); return; } selectProfile(profiles[0] ?? null); @@ -263,8 +259,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const saveProfile = async () => { if (!draft || saving) return; setSaving(true); - setStatus(""); - setStatusOk(false); + setStatus(null); try { const body = routingProfilePutBody(draft); const response = await fetch(`${apiBase}/api/routing-profiles`, { @@ -291,8 +286,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { if (!selected || saving) return; if (!window.confirm(`${t("common.remove")} ${selected.id}?`)) return; setSaving(true); - setStatus(""); - setStatusOk(false); + setStatus(null); try { const response = await fetch( `${apiBase}/api/routing-profiles?id=${encodeURIComponent(selected.id)}`, @@ -415,7 +409,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) {

{t("routing.subtitle")}

{loadError ? {t("routing.loadFailed")}: {loadError} : null} - {status ? {status} : null} + {status ? {status.message} : null} {profiles.length > 0 ? (
@@ -534,9 +528,9 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { setDraft(current => current ? { ...current, From 1cca4c853c2e0b1bf61bbb0cbfc461a0a778cd0a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:19:48 +0200 Subject: [PATCH 19/23] fix(routing): give minContextWindow spec a uniform max member The numeric requirement spec object has a union shape ({min, step} for minContextWindow vs {min, max, step} for minQuotaHeadroom), so indexing [NUMERIC_REQUIREMENT_SPEC[key].max] fails the GUI typecheck. Add max: undefined to the minContextWindow entry so both members share the same shape; behavior is unchanged (max={undefined} renders no max attribute). --- gui/src/pages/RoutingProfiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 9c97a2e93..818d1e00e 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -60,7 +60,7 @@ const BOOLEAN_REQUIREMENTS = [ ] as const; const STRING_REQUIREMENTS = ["reasoningEffort", "serviceTier"] as const; const NUMERIC_REQUIREMENT_SPEC = { - minContextWindow: { min: 1, step: 1 }, + 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; From 4bfc4cc4b99c9880dc1a42e3923ea931e29bacce Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:54:22 +0200 Subject: [PATCH 20/23] fix(routing): satisfy react-doctor on the routing editor Three findings from react-doctor 0.9.3: - saveProfile/removeProfile read the fetch Response body before checking response.ok, so an HTTP error payload was parsed as success. Route both through readJsonIfOk (which checks res.ok before consuming) and read the structured error body explicitly on the non-OK branch. - the candidate card used an index-derived React key. Draft candidates now carry a stable client-side key (newDraftCandidate) that is stripped by routingProfilePutBody and never reaches the server; add/update keep it. react-doctor --scope changed --base upstream/dev: No issues found. Verified: root+GUI typecheck, GUI lint, 610 GUI tests, 128 focused server tests. --- gui/src/pages/RoutingProfiles.tsx | 31 +++++++++++++++-------- gui/src/routing-profile-editor-data.ts | 26 ++++++++++++++++--- tests/routing-profile-editor-data.test.ts | 7 ++++- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index 818d1e00e..4862a1099 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { modelOptionsForProvider, + newDraftCandidate, newRoutingProfileDraft, routingProfileDraftFromDto, routingProfilePutBody, @@ -12,6 +13,7 @@ import { type RoutingProfileDto, type UnknownEvidenceMode, } from "../routing-profile-editor-data"; +import { readJsonIfOk } from "../fetch-json"; import { Notice } from "../ui"; import { useT } from "../i18n/shared"; @@ -267,10 +269,14 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { headers: { "content-type": "application/json" }, body: JSON.stringify({ ...body, mode: selected ? "update" : "create" }), }); - const data = await response.json().catch(() => null) as unknown; - const serverError = routingProfileResponseError(data); - if (!response.ok || serverError || !routingProfileResponseSucceeded(data)) { - notify(serverError ?? t("routing.loadFailed"), false); + 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); @@ -292,10 +298,14 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { `${apiBase}/api/routing-profiles?id=${encodeURIComponent(selected.id)}`, { method: "DELETE" }, ); - const data = await response.json().catch(() => null) as unknown; - const serverError = routingProfileResponseError(data); - if (!response.ok || serverError || !routingProfileResponseSucceeded(data)) { - notify(serverError ?? t("routing.loadFailed"), false); + 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; @@ -319,6 +329,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { if (candidateIndex !== index) return candidate; if (field === "provider") { return { + ...candidate, provider: value, model: providerDefaults[value] ?? modelOptionsForProvider(models, value)[0]?.id @@ -336,7 +347,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { ...current, candidates: [ ...current.candidates, - { provider: firstProvider, model: firstModel }, + newDraftCandidate(firstProvider, firstModel), ], } : current); }; @@ -474,7 +485,7 @@ export default function RoutingProfiles({ apiBase }: { apiBase: string }) { const candidateProviders = [...new Set([candidate.provider, ...providerNames])].filter(Boolean); const listId = `routing-model-options-${index}`; return ( -
+
))} diff --git a/gui/src/routing-profile-editor-data.ts b/gui/src/routing-profile-editor-data.ts index 2bfa043c2..030825bf1 100644 --- a/gui/src/routing-profile-editor-data.ts +++ b/gui/src/routing-profile-editor-data.ts @@ -184,8 +184,16 @@ function compactRecord(record: Record): Record return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); } -export function routingProfilePutBody(draft: RoutingProfileDraft): { +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({ @@ -203,7 +211,9 @@ export function routingProfilePutBody(draft: RoutingProfileDraft): { 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 => ({ diff --git a/gui/tests/routing-profiles.test.tsx b/gui/tests/routing-profiles.test.tsx index 99571de94..28e195204 100644 --- a/gui/tests/routing-profiles.test.tsx +++ b/gui/tests/routing-profiles.test.tsx @@ -143,7 +143,13 @@ async function mountPage(): Promise<{ container: HTMLDivElement; root: Root }> { } function requirementSelect(container: HTMLDivElement, key: string): HTMLSelectElement | null { - const label = [...container.querySelectorAll("label")] + // 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; } @@ -350,3 +356,29 @@ 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 6b3931a21..5b75d88db 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -156,8 +156,63 @@ function storedProfile( 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. + */ +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.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) { + claudeCode.modelMap = Object.fromEntries( + Object.entries(claudeCode.modelMap).map(([source, model]) => [source, migrateAgentReference(model)]), + ); + } + config.claudeCode = claudeCode; + } + return shouldSyncClaudeAgentDefs; +} export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, convergeCodexCatalog } = 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( @@ -193,6 +248,23 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis 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({ @@ -204,13 +276,24 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis }, 400, req, config); } + const previousProfile = mode === "update" ? getRoutingProfile(config, id) : undefined; 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, diff --git a/tests/routing-profile-editor-data.test.ts b/tests/routing-profile-editor-data.test.ts index d6bfbc31f..d0c15c64b 100644 --- a/tests/routing-profile-editor-data.test.ts +++ b/tests/routing-profile-editor-data.test.ts @@ -50,10 +50,12 @@ describe("routing profile editor data", () => { test("round-trips normalized DTO values into a PUT payload", () => { const draft = routingProfileDraftFromDto(profile); - const body = routingProfilePutBody(draft); + const body = routingProfilePutBody(draft, "update", profile.revision); expect(body).toEqual({ + mode: "update", id: "fast", + expectedRevision: "abc123", profile: { alias: "ocx/fast", candidates: profile.candidates, @@ -71,6 +73,15 @@ describe("routing profile editor data", () => { }); }); + 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 "; @@ -78,15 +89,16 @@ describe("routing profile editor data", () => { draft.require.serviceTier = " "; draft.limits.maxEstimatedCostUsd = ""; - expect(routingProfilePutBody(draft)).toMatchObject({ + expect(routingProfilePutBody(draft, "create")).toMatchObject({ + mode: "create", id: "balanced", profile: { candidates: [{ provider: "openai", model: "gpt-5.6" }], require: { tools: false }, }, }); - expect(routingProfilePutBody(draft).profile).not.toHaveProperty("limits"); - expect(routingProfilePutBody(draft).profile).not.toHaveProperty("alias"); + expect(routingProfilePutBody(draft, "create").profile).not.toHaveProperty("limits"); + expect(routingProfilePutBody(draft, "create").profile).not.toHaveProperty("alias"); }); test("extracts both string and structured management errors", () => { diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts index 0014fada4..5094be5af 100644 --- a/tests/routing-profile-management-editor.test.ts +++ b/tests/routing-profile-management-editor.test.ts @@ -170,6 +170,139 @@ describe("routing profile management editor API", () => { 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.injectionModel = "ocx/fast"; + config.shadowCallIntercept = { model: "ocx/fast" }; + config.claudeCode = { + enabled: true, + model: "ocx/fast", + smallFastModel: "a/m1", + }; + 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.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(saves).toBe(1); + }); + test("DELETE removes a profile, persists, and refreshes the catalog", async () => { const config = baseConfig(); let saves = 0; From bcec06ded2215f3c77f894d38f65b751db6f981f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:28:53 +0200 Subject: [PATCH 22/23] fix(routing): migrate subagentModelFallback and modelMap keys on alias change CodeRabbit follow-up on cf3d4b562: migrateProfileModelReferences rewrote subagentModels and claudeCode.modelMap values but missed the sibling config.subagentModelFallback chain and modelMap keys, which are the inbound ids matched for reroute in src/claude/inbound.ts. Both now follow an alias rename, with the migration regression test extended to cover them. --- src/server/management/routing-profile-routes.ts | 10 +++++++++- tests/routing-profile-management-editor.test.ts | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 5b75d88db..bd7ea725c 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -186,6 +186,9 @@ function migrateProfileModelReferences( 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; } @@ -203,8 +206,13 @@ function migrateProfileModelReferences( ); } 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]) => [source, migrateAgentReference(model)]), + Object.entries(claudeCode.modelMap).map(([source, model]) => [ + migrateAgentReference(source), + migrateAgentReference(model), + ]), ); } config.claudeCode = claudeCode; diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts index 5094be5af..2819daff1 100644 --- a/tests/routing-profile-management-editor.test.ts +++ b/tests/routing-profile-management-editor.test.ts @@ -260,12 +260,14 @@ describe("routing profile management editor API", () => { 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", { @@ -296,10 +298,12 @@ describe("routing profile management editor API", () => { 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); }); From fcd713bfef2ef229817226037302527755bac7bf Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:36:46 +0200 Subject: [PATCH 23/23] fix(routing): reject modelMap key collisions on profile alias change CodeRabbit follow-up on bcec06ded: migrating modelMap keys via Object.fromEntries silently drops a mapping when the map already contains the new alias as a key with a different target. Detect that collision before any mutation and reject the update with 409 alias_reference_conflict so no mapping is lost; regression test covers both keys with different targets. --- .../management/routing-profile-routes.ts | 38 +++++++++++++++++ .../routing-profile-management-editor.test.ts | 42 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index bd7ea725c..36687c01b 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -156,6 +156,33 @@ function storedProfile( 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 @@ -285,6 +312,17 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis } 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; diff --git a/tests/routing-profile-management-editor.test.ts b/tests/routing-profile-management-editor.test.ts index 2819daff1..7f92b52e0 100644 --- a/tests/routing-profile-management-editor.test.ts +++ b/tests/routing-profile-management-editor.test.ts @@ -307,6 +307,48 @@ describe("routing profile management editor API", () => { 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;