diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d3aee62c2c1..625e4daafcb 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -541,8 +541,10 @@ subscription with a warning when detection is inconclusive. See ## Compaction routing In **Dashboard → Overview → Compaction routing**, choose a model, which triggers it applies to, -and an optional reasoning effort, then click **Save**. Select **Use conversation model** and save -to remove the override. Changes apply to the next compaction request without restarting the proxy. +an optional reasoning effort, and whether the override covers all source models or only selected +sources. For selected sources, check individual models or provider-wide `provider/*` entries, +then click **Save**. Select **Use conversation model** and save to remove the override. Changes +apply to the next compaction request without restarting the proxy. Set `compactionRouting` in OpenCodex `config.json` to override the model Codex's compaction requests use. The setting is disabled when omitted. @@ -558,6 +560,16 @@ requests use. The setting is disabled when omitted. ``` `model` accepts native model IDs, provider-qualified model IDs, and configured combos. +Optional `sourceModels` limits the override to exact incoming model IDs or `provider/*` patterns, +for example `["kimi/*", "google-antigravity/*"]`. Matching is case-sensitive. Omit it for the +existing all-model behavior; empty, duplicate, or malformed lists are invalid. The Dashboard +source-scope picker can edit the allowlist and preserves saved selectors absent from the current +catalog. You can also edit it through `config.json` or `PUT /api/settings`. +This checks the incoming compaction model, not an inferred conversation model. If Codex sends a +bare native compaction model for a routed thread, it does not match those provider patterns and +keeps its existing route. This conservative behavior protects GPT but can leave some automatic +compactions on the native path. Ordinary non-compaction turns are never changed. + `reasoningEffort` is optional; omit it to preserve the incoming effort. Supported declarations are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. Existing provider effort rules still apply. The native `/responses/compact` endpoint keeps diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index 6875b7ab3fa..f1d1f235779 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -7,7 +7,7 @@ import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; import { comboModelId, parseComboList } from "../combo-workspace-data"; import { formatNamespacedModelId } from "../provider-icons"; -type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null; +type Setting = { model: string; reasoningEffort?: string; triggers?: string[]; sourceModels?: string[] } | null; const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; const TRIGGERS = ["manual", "auto"]; /** @@ -20,6 +20,16 @@ const TRIGGER_LABELS: Record = { "manual+auto": "compactionRouting.triggersBoth", "auto": "compactionRouting.triggersAuto", }; +/** + * "all" is sent as an omitted `sourceModels`, so the default save keeps the exact payload the + * unscoped override used. `selected` requires at least one checked selector: the config schema + * rejects an empty list and would silently drop the whole override. + */ +const SCOPE_CHOICES = ["all", "selected"] as const; +const SCOPE_LABELS: Record = { + "all": "compactionRouting.sourcesAll", + "selected": "compactionRouting.sourcesSelected", +}; function triggersToChoice(value: string[] | undefined): string { if (!value) return "manual"; @@ -62,6 +72,9 @@ function readSetting(payload: { compactionRouting?: unknown }): Setting { const effort = "reasoningEffort" in value ? value.reasoningEffort : undefined; if (effort !== undefined && (typeof effort !== "string" || !EFFORTS.includes(effort))) throw new Error("invalid effort"); const triggers = "triggers" in value ? value.triggers : undefined; + const sourceModels = "sourceModels" in value ? value.sourceModels : undefined; + if (sourceModels !== undefined && (!Array.isArray(sourceModels) || !sourceModels.length + || !sourceModels.every(entry => typeof entry === "string" && entry.trim()))) throw new Error("invalid sourceModels"); if (triggers !== undefined && (!Array.isArray(triggers) || triggers.length === 0 || !triggers.every(entry => typeof entry === "string" && TRIGGERS.includes(entry)) || new Set(triggers).size !== triggers.length)) throw new Error("invalid triggers"); @@ -69,19 +82,37 @@ function readSetting(payload: { compactionRouting?: unknown }): Setting { model: value.model, ...(effort ? { reasoningEffort: effort as string } : {}), ...(triggers ? { triggers: triggers as string[] } : {}), + ...(sourceModels ? { sourceModels: sourceModels as string[] } : {}), }; } -export default function CompactionRoutingPanel(props: { apiBase: string; models: ModelInfo[] }) { +/** A provider name says who is configured; the endpoint host says where bytes actually go. */ +function hostOf(baseUrl: string): string { + if (!baseUrl) return ""; + try { + return new URL(baseUrl).hostname || baseUrl; + } catch { + return baseUrl; + } +} + +function endpointLabel(name: string, hosts: ReadonlyMap): string { + const host = hosts.get(name); + return host ? `${name} (${host})` : name; +} + +export default function CompactionRoutingPanel(props: { apiBase: string; models: ModelInfo[]; providers?: Array<{ name: string; baseUrl: string }> }) { return ; } -function CompactionRoutingControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) { +function CompactionRoutingControls({ apiBase, models, providers: providerList = [] }: { apiBase: string; models: ModelInfo[]; providers?: Array<{ name: string; baseUrl: string }> }) { const t = useT(); const [saved, setSaved] = useState(undefined); const [model, setModel] = useState(""); const [effort, setEffort] = useState(""); const [triggers, setTriggers] = useState("manual"); + const [scope, setScope] = useState("all"); + const [sources, setSources] = useState([]); const [busy, setBusy] = useState(false); const [loadError, setLoadError] = useState(false); const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); @@ -94,8 +125,17 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model setModel(value?.model ?? ""); setEffort(value?.reasoningEffort ?? ""); setTriggers(triggersToChoice(value?.triggers)); + setScope(value?.sourceModels ? "selected" : "all"); + setSources(value?.sourceModels ?? []); }, []); + const toggleSource = (selector: string) => { + setSources(current => current.includes(selector) + ? current.filter(entry => entry !== selector) + : [...current, selector]); + setFeedback(null); + }; + const load = useCallback(async () => { if (pending.current) return; const request = createBoundedFetch(15_000); @@ -144,6 +184,7 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model model, ...(effort ? { reasoningEffort: effort } : {}), ...(choiceToTriggers(triggers) ? { triggers: choiceToTriggers(triggers) } : {}), + ...(scope === "selected" && sources.length > 0 ? { sourceModels: sources } : {}), } : null, }), @@ -166,10 +207,28 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const options = [{ value: "", label: t("compactionRouting.currentModel") }, ...[...new Set([...models.map(item => item.namespaced), ...(model ? [model] : [])])] .map(value => ({ value, label: formatNamespacedModelId(value, t) }))]; + const providerWildcards = [...new Set(models.map(item => item.provider).filter(Boolean))].map(provider => `${provider}/*`); + const knownSelectors = new Set([...providerWildcards, ...models.map(item => item.namespaced)]); + // Saved selectors the catalog no longer lists stay visible, so saving never drops them by + // omission and the operator removes them deliberately. + const savedOnlySelectors = (saved?.sourceModels ?? []).filter(selector => !knownSelectors.has(selector)); const disabled = busy || saved === undefined || loadError; + const savedScope = saved?.sourceModels ? "selected" : "all"; const dirty = model !== (saved?.model ?? "") || effort !== (saved?.reasoningEffort ?? "") - || triggers !== triggersToChoice(saved?.triggers); + || triggers !== triggersToChoice(saved?.triggers) + || scope !== savedScope + || (scope === "selected" + && (sources.length !== (saved?.sourceModels?.length ?? 0) + || sources.some(selector => !saved?.sourceModels?.includes(selector)))); + const scopeEmpty = scope === "selected" && sources.length === 0; + const sourceRow = (selector: string) => ( + + ); // Ask what the selection resolves to instead of reading its name. An aliased combo answers // here exactly like a prefixed one (#5216). const comboTargets = comboProviders.get(model); @@ -178,9 +237,21 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model // an ordinary provider named "combo" — worse than the alias gap this fixes. const isCombo = comboTargets !== undefined || model.startsWith(comboModelId("")); const namespace = model.slice(0, Math.max(model.indexOf("/"), 0)); - const provider = isCombo ? "" : (namespace || model); - const providers = comboTargets?.join(", ") || t("compactionRouting.comboProvidersUnknown"); + const endpointHosts = new Map(providerList.map(entry => [entry.name, hostOf(entry.baseUrl)] as const).filter(([, host]) => host)); + const provider = isCombo ? "" : endpointLabel(namespace || model, endpointHosts); + const providers = comboTargets?.map(name => endpointLabel(name, endpointHosts)).join(", ") || t("compactionRouting.comboProvidersUnknown"); const routesAutomatic = triggers !== "manual"; + const scopedSources = sources.join(", "); + // A scoped override only covers the checked sources, so the disclosure names them instead of + // claiming every request; an empty selection covers nothing and shows no destination claim. + const warningText = !model || scopeEmpty ? null + : isCombo + ? (scope === "selected" + ? t("compactionRouting.comboWarningScoped", { combo: model, providers, sources: scopedSources }) + : t("compactionRouting.comboWarning", { combo: model, providers })) + : (scope === "selected" + ? t("compactionRouting.providerWarningScoped", { provider, sources: scopedSources }) + : t("compactionRouting.providerWarning", { provider })); return (
@@ -190,27 +261,60 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
{t("compactionRouting.description")}
{t("compactionRouting.dataNotice")}
{t("compactionRouting.effortHint")}
+
{t("compactionRouting.sourcesHint")}
-
- ({ value, label: t(TRIGGER_LABELS[value]!) }))} - onChange={value => { setTriggers(value); setFeedback(null); }} /> - { setModel(value); if (!value) { setEffort(""); setTriggers("manual"); setScope("all"); setSources([]); } setFeedback(null); }} /> +
+
+ + ({ value, label: t(SCOPE_LABELS[value]!) }))} + onChange={value => { setScope(value); setFeedback(null); }} /> +
+
+ +