diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 8a596e2a96..b0793bba96 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -494,6 +494,68 @@ Auto auth selects subscription when stored Claude auth is found, proxy when none subscription with a warning when detection is inconclusive. See [Claude Code auth mode](/guides/claude-code/#auth-mode). +## 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. + +Set `compactionRouting` in OpenCodex `config.json` to override the model Codex's compaction +requests use. The setting is disabled when omitted. + +```json +{ + "compactionRouting": { + "model": "provider/model-id", + "reasoningEffort": "low", + "triggers": ["manual"] + } +} +``` + +`model` accepts native model IDs, provider-qualified model IDs, and configured combos. +`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 +its existing behavior and does not forward reasoning settings. + +`triggers` names which compaction requests the override covers, using Codex's own +`compaction.trigger` values: `"manual"` for a `/compact` you typed, `"auto"` for the automatic +compaction Codex runs when a thread approaches its context limit. Omit `triggers` and the +override applies to manual `/compact` only, leaving automatic compaction exactly where it +routes today. Use `["auto"]` or `["manual", "auto"]` to route automatic compaction as well. + +Routing automatic compaction is what lets a long thread on a routed provider keep going when +the canonical OpenAI quota is exhausted. Codex picks a bare native model for the compaction +turn, and OpenCodex reserves that for an enabled canonical `openai` provider whenever one +exists, so the compaction fails with a quota error before the routed turn begins even though +the thread itself runs elsewhere. Naming `"auto"` here points the compaction at a +provider-qualified model with its own credentials and quota. + +OpenCodex changes only requests with explicit `request_kind: "compaction"` metadata whose +`compaction.trigger` is one of the values you listed, sent to `/v1/responses/compact` or to +`/v1/responses` with a `compaction_trigger` input item. Later conversation turns keep their +original routing and settings. Missing, malformed, or conflicting metadata does not activate the +override, including on older clients without trigger metadata; when several copies of the +metadata are supplied they must name the same trigger. WebSocket requests use each frame's +metadata rather than the connection's earlier handshake metadata. + +The selected model's provider receives the entire conversation for summarization, including +conversations that normally run on another provider. A combo selector sends it to every combo +target, including failover targets. The dashboard panel states this next to the model picker +and names the destination provider, or the combo's target providers, once a model is chosen. +When the override covers automatic compaction, that transfer happens without you asking for it, +at whatever point Codex decides to compact; the dashboard panel says so as well. +The override reuses the existing compaction handlers and summary formats. When the selected +model shares the conversation model's provider and account-routing identity (provider name, +Codex account mode, and account namespace), the request keeps the caller's credential and may +use that backend's native compact endpoint. Otherwise, including when either side is a combo or +the conversation model is remembered as a combo target, OpenCodex runs the portable summarizer +instead, so the summary stays readable when the conversation resumes on its own model, and the +caller's credential does not cross to the other provider. The selected model must support the +input size and content. Restart the proxy after editing +`config.json` by hand. Dashboard saves apply immediately. + ## Shadow calls Codex uses small helper models for tasks such as titles and commit messages. Enable diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx new file mode 100644 index 0000000000..e228589144 --- /dev/null +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT, type TKey } from "../i18n/shared"; +import { IconAlert } from "../icons"; +import { Select } from "../ui"; +import { createBoundedFetch } from "../bounded-fetch"; +import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; +import { formatNamespacedModelId } from "../provider-icons"; + +type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null; +const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; +const TRIGGERS = ["manual", "auto"]; +/** + * One picker over the two trigger sets the config allows. "manual" is sent as an omitted + * `triggers`, so the default save keeps the exact payload the manual-only override used. + */ +const TRIGGER_CHOICES = ["manual", "manual+auto", "auto"] as const; +const TRIGGER_LABELS: Record = { + "manual": "compactionRouting.triggersManual", + "manual+auto": "compactionRouting.triggersBoth", + "auto": "compactionRouting.triggersAuto", +}; + +function triggersToChoice(value: string[] | undefined): string { + if (!value) return "manual"; + const auto = value.includes("auto"); + return value.includes("manual") ? (auto ? "manual+auto" : "manual") : (auto ? "auto" : "manual"); +} + +function choiceToTriggers(choice: string): string[] | undefined { + if (choice === "auto") return ["auto"]; + if (choice === "manual+auto") return ["manual", "auto"]; + return undefined; +} + +function readComboProviders(payload: unknown): Record { + const combos = (payload as { combos?: unknown })?.combos; + if (!Array.isArray(combos)) return {}; + const result: Record = {}; + for (const combo of combos) { + if (!combo || typeof combo !== "object" || typeof (combo as { id?: unknown }).id !== "string") continue; + const targets = (combo as { targets?: unknown }).targets; + const providers = Array.isArray(targets) + ? targets.map(target => (target as { provider?: unknown })?.provider).filter((value): value is string => typeof value === "string") + : []; + result[(combo as { id: string }).id] = [...new Set(providers)]; + } + return result; +} + +function readSetting(payload: { compactionRouting?: unknown }): Setting { + const value = payload.compactionRouting; + if (value == null) return null; + if (!value || typeof value !== "object" || !("model" in value) || typeof value.model !== "string" || !value.model.trim()) { + throw new Error("invalid settings"); + } + 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; + 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"); + return { + model: value.model, + ...(effort ? { reasoningEffort: effort as string } : {}), + ...(triggers ? { triggers: triggers as string[] } : {}), + }; +} + +export default function CompactionRoutingPanel(props: { apiBase: string; models: ModelInfo[] }) { + return ; +} + +function CompactionRoutingControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) { + const t = useT(); + const [saved, setSaved] = useState(undefined); + const [model, setModel] = useState(""); + const [effort, setEffort] = useState(""); + const [triggers, setTriggers] = useState("manual"); + const [busy, setBusy] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); + const [comboProviders, setComboProviders] = useState>({}); + const active = useRef(false); + const pending = useRef | null>(null); + + const accept = useCallback((value: Setting) => { + setSaved(value); + setModel(value?.model ?? ""); + setEffort(value?.reasoningEffort ?? ""); + setTriggers(triggersToChoice(value?.triggers)); + }, []); + + const load = useCallback(async () => { + if (pending.current) return; + const request = createBoundedFetch(15_000); + pending.current = request; + setLoadError(false); + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: request.signal }); + const value = readSetting(await requireJson(response)); + if (active.current && pending.current === request) accept(value); + const combos = await fetch(`${apiBase}/api/combos`, { signal: request.signal }).then(requireJson).then(readComboProviders).catch(() => ({})); + if (active.current && pending.current === request) setComboProviders(combos); + } catch { + if (active.current && pending.current === request) setLoadError(true); + } finally { + request.clear(); + if (pending.current === request) pending.current = null; + } + }, [apiBase, accept]); + + useEffect(() => { + active.current = true; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + window.clearTimeout(timer); + active.current = false; + pending.current?.controller.abort(); + pending.current?.clear(); + pending.current = null; + }; + }, [load]); + + const save = async () => { + if (pending.current || saved === undefined) return; + const request = createBoundedFetch(15_000); + pending.current = request; + setBusy(true); + setFeedback(null); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + compactionRouting: model + ? { + model, + ...(effort ? { reasoningEffort: effort } : {}), + ...(choiceToTriggers(triggers) ? { triggers: choiceToTriggers(triggers) } : {}), + } + : null, + }), + signal: request.signal, + }); + const value = readSetting(await requireJson(response)); + if (active.current && pending.current === request) { + accept(value); + setFeedback("saved"); + } + } catch { + if (active.current && pending.current === request) setFeedback("failed"); + } finally { + request.clear(); + if (active.current && pending.current === request) setBusy(false); + if (pending.current === request) pending.current = null; + } + }; + + const options = [{ value: "", label: t("compactionRouting.currentModel") }, + ...[...new Set([...models.map(item => item.namespaced), ...(model ? [model] : [])])] + .map(value => ({ value, label: formatNamespacedModelId(value, t) }))]; + const disabled = busy || saved === undefined || loadError; + const dirty = model !== (saved?.model ?? "") + || effort !== (saved?.reasoningEffort ?? "") + || triggers !== triggersToChoice(saved?.triggers); + const namespace = model.slice(0, Math.max(model.indexOf("/"), 0)); + const combo = namespace === "combo" ? model.slice(namespace.length + 1) : ""; + const provider = namespace && !combo ? namespace : model; + const providers = comboProviders[combo]?.join(", ") || t("compactionRouting.comboProvidersUnknown"); + const routesAutomatic = triggers !== "manual"; + + return ( +
+
+
+
{t("compactionRouting.title")}
+
{t("compactionRouting.description")}
+
{t("compactionRouting.dataNotice")}
+
{t("compactionRouting.effortHint")}
+
+
+ ({ value, label: t(TRIGGER_LABELS[value]!) }))} + onChange={value => { setTriggers(value); setFeedback(null); }} /> +