Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
207 changes: 207 additions & 0 deletions gui/src/components/CompactionRoutingPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, TKey> = {
"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<string, string[]> {
const combos = (payload as { combos?: unknown })?.combos;
if (!Array.isArray(combos)) return {};
const result: Record<string, string[]> = {};
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 <CompactionRoutingControls key={props.apiBase} {...props} />;
}

function CompactionRoutingControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) {
const t = useT();
const [saved, setSaved] = useState<Setting | undefined>(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<Record<string, string[]>>({});
const active = useRef(false);
const pending = useRef<ReturnType<typeof createBoundedFetch> | 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");
Comment on lines +166 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve combo aliases before naming the destination

When a combo has a configured public alias, the model picker returns that bare alias, but this prefix-only check classifies it as a provider and displays a notice claiming the full conversation is sent to a provider named after the alias. The runtime actually resolves the alias to a combo whose targets may span unrelated providers, so the privacy notice hides the real destinations precisely when the user is deciding whether to enable routing. Index /api/combos by its returned public model as well as by id, then use the resolved combo's target providers.

AGENTS.md reference: gui/AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

const routesAutomatic = triggers !== "manual";

return (
<section className="panel" aria-labelledby="compaction-routing-title" aria-busy={busy || (saved === undefined && !loadError)}>
<div className="spread" style={{ alignItems: "flex-start", flexWrap: "wrap" }}>
<div style={{ flex: "1 1 20rem", minWidth: 0 }}>
<div className="font-semibold" id="compaction-routing-title">{t("compactionRouting.title")}</div>
<div className="muted setting-hint">{t("compactionRouting.description")}</div>
<div className="muted setting-hint">{t("compactionRouting.dataNotice")}</div>
<div className="muted setting-hint">{t("compactionRouting.effortHint")}</div>
</div>
<div className="dash-delegation-controls" style={{ flex: "0 1 auto" }}>
<Select id="compaction-routing-model" value={model} options={options} disabled={disabled}
label={t("compactionRouting.model")}
onChange={value => { setModel(value); if (!value) { setEffort(""); setTriggers("manual"); } setFeedback(null); }} />
<Select id="compaction-routing-triggers" value={triggers} disabled={disabled || !model} align="right"
label={t("compactionRouting.triggers")}
options={TRIGGER_CHOICES.map(value => ({ value, label: t(TRIGGER_LABELS[value]!) }))}
onChange={value => { setTriggers(value); setFeedback(null); }} />
<Select id="compaction-routing-effort" value={effort} disabled={disabled || !model} align="right"
label={t("compactionRouting.effort")}
options={[{ value: "", label: t("compactionRouting.currentEffort") }, ...EFFORTS.map(value => ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]}
onChange={value => { setEffort(value); setFeedback(null); }} />
<button type="button" className="btn btn-primary btn-sm" disabled={disabled || !dirty} onClick={() => { void save(); }}>
{busy ? t("common.saving") : t("common.save")}
</button>
</div>
</div>
{provider && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {combo
? t("compactionRouting.comboWarning", { combo: model, providers })
: t("compactionRouting.providerWarning", { provider })}</div>}
{provider && routesAutomatic && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {t("compactionRouting.autoNotice")}</div>}
{loadError && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("compactionRouting.loadFailed")} <button type="button" className="btn btn-ghost btn-sm" onClick={() => { void load(); }}>{t("common.retry")}</button></div>}
{feedback === "failed" && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("compactionRouting.saveFailed")}</div>}
{feedback === "saved" && <div className="muted setting-hint" role="status">{t("compactionRouting.saved")}</div>}
</section>
);
}
20 changes: 20 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,25 @@ export const de: Record<TKey, string> = {
"dash.visionSidecar": "Vision-Sidecar",
"dash.visionSidecarHint": "Backend und Modell zur Bildbeschreibung für reine Textmodelle auswählen.",
"dash.visionOff": "Aus",
"compactionRouting.title": "Komprimierungs-Routing",
"compactionRouting.description": "Wähle ein Modell für Codex-Komprimierungsanfragen und welche Auslöser es abdeckt. Spätere Nachrichten behalten die Gesprächseinstellungen.",
"compactionRouting.model": "Komprimierungsmodell",
"compactionRouting.effort": "Denkaufwand",
"compactionRouting.triggers": "Gilt für",
"compactionRouting.triggersManual": "Nur manuelles /compact",
"compactionRouting.triggersBoth": "Manuell und automatisch",
"compactionRouting.triggersAuto": "Nur automatisch",
"compactionRouting.currentModel": "Gesprächsmodell verwenden",
"compactionRouting.currentEffort": "Anfrageaufwand beibehalten",
"compactionRouting.effortHint": "Der Denkaufwand gilt, wenn der Komprimierungsendpunkt ihn unterstützt. Das Modell muss das gesamte Gespräch verarbeiten können.",
"compactionRouting.dataNotice": "Eine abgedeckte Komprimierungsanfrage sendet das gesamte Gespräch zur Zusammenfassung an den Anbieter des gewählten Modells, auch wenn das Gespräch bei einem anderen Anbieter läuft.",
"compactionRouting.autoNotice": "Automatische Komprimierung läuft von selbst, daher kann ein langes Gespräch an diesen Anbieter gehen, ohne dass du es angefordert hast.",
"compactionRouting.providerWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}.",
"compactionRouting.comboWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an jedes Ziel der Combo {combo} ({providers}), einschließlich Failover-Zielen.",
"compactionRouting.comboProvidersUnknown": "ihre konfigurierten Zielanbieter",
"compactionRouting.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.",
"compactionRouting.saved": "Komprimierungseinstellungen gespeichert.",
"compactionRouting.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen sind noch vorhanden; versuche es erneut.",
"dash.shadowCallIntercept": "Shadow-Call-Abfangen",
"dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.",
"dash.shadowCallWarning": "⚠ Bei Aktivierung werden ALLE Anfragen an {models} durch das gewählte Modell ersetzt.",
Expand Down Expand Up @@ -678,6 +697,7 @@ export const de: Record<TKey, string> = {
"models.reasoningEffort.high": "Hoch",
"models.reasoningEffort.xhigh": "Sehr hoch",
"models.reasoningEffort.max": "Maximal",
"models.reasoningEffort.ultra": "Ultra",
"models.tipProvider": "Anbieter",
"models.tipContext": "Kontext",
"models.tipModalities": "Modalitäten",
Expand Down
Loading
Loading