Skip to content
46 changes: 46 additions & 0 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,52 @@ 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).

## Manual compaction

In **Dashboard → Overview → Manual compaction**, choose a model and optional reasoning effort,
then click **Save**. Select **Use conversation model** and save to remove the override.
Changes apply to the next manual `/compact` request without restarting the proxy.

Set `manualCompaction` in OpenCodex `config.json` to override the model used by Codex's
manual `/compact` command. The setting is disabled when omitted.

```json
{
"manualCompaction": {
"model": "provider/model-id",
"reasoningEffort": "low"
}
}
```

`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.

OpenCodex changes only requests with explicit `request_kind: "compaction"` and
`compaction.trigger: "manual"` metadata, sent to `/v1/responses/compact` or to `/v1/responses`
with a `compaction_trigger` input item. Automatic compaction and 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. 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.
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. This setting does
not guarantee a cache hit for automatic compaction. 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
158 changes: 158 additions & 0 deletions gui/src/components/ManualCompactionPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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 } | null;
const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];

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: { manualCompaction?: unknown }): Setting {
const value = payload.manualCompaction;
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");
return { model: value.model, ...(effort ? { reasoningEffort: effort as string } : {}) };
}

export default function ManualCompactionPanel(props: { apiBase: string; models: ModelInfo[] }) {
return <ManualCompactionControls key={props.apiBase} {...props} />;
}

function ManualCompactionControls({ 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 [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 ?? "");
}, []);

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({ manualCompaction: model ? { model, ...(effort ? { reasoningEffort: effort } : {}) } : 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("manualCompact.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 ?? "");
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("manualCompact.comboProvidersUnknown");

return (
<section className="panel" aria-labelledby="manual-compaction-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="manual-compaction-title">{t("manualCompact.title")}</div>
<div className="muted setting-hint">{t("manualCompact.description")}</div>
<div className="muted setting-hint">{t("manualCompact.dataNotice")}</div>
<div className="muted setting-hint">{t("manualCompact.effortHint")}</div>
</div>
<div className="dash-delegation-controls" style={{ flex: "0 1 auto" }}>
<Select id="manual-compaction-model" value={model} options={options} disabled={disabled}
label={t("manualCompact.model")}
onChange={value => { setModel(value); if (!value) setEffort(""); setFeedback(null); }} />
<Select id="manual-compaction-effort" value={effort} disabled={disabled || !model} align="right"
label={t("manualCompact.effort")}
options={[{ value: "", label: t("manualCompact.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("manualCompact.comboWarning", { combo: model, providers })
: t("manualCompact.providerWarning", { provider })}</div>}
{loadError && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("manualCompact.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("manualCompact.saveFailed")}</div>}
{feedback === "saved" && <div className="muted setting-hint" role="status">{t("manualCompact.saved")}</div>}
</section>
);
}
15 changes: 15 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,20 @@ 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",
"manualCompact.title": "Manuelle Komprimierung",
"manualCompact.description": "Wähle ein Modell für manuelle /compact-Befehle. Automatische Komprimierung und spätere Nachrichten behalten die Gesprächseinstellungen.",
"manualCompact.model": "Komprimierungsmodell",
"manualCompact.effort": "Denkaufwand",
"manualCompact.currentModel": "Gesprächsmodell verwenden",
"manualCompact.currentEffort": "Anfrageaufwand beibehalten",
"manualCompact.effortHint": "Der Denkaufwand gilt, wenn der Komprimierungsendpunkt ihn unterstützt. Das Modell muss das gesamte Gespräch verarbeiten können.",
"manualCompact.dataNotice": "Manuelles /compact 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.",
"manualCompact.providerWarning": "Mit dieser Einstellung sendet jedes manuelle /compact den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}.",
"manualCompact.comboWarning": "Mit dieser Einstellung sendet jedes manuelle /compact den vollständigen Gesprächsinhalt zur Zusammenfassung an jedes Ziel der Combo {combo} ({providers}), einschließlich Failover-Zielen.",
"manualCompact.comboProvidersUnknown": "ihre konfigurierten Zielanbieter",
"manualCompact.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.",
"manualCompact.saved": "Komprimierungseinstellungen gespeichert.",
"manualCompact.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 @@ -663,6 +677,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
15 changes: 15 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,20 @@ export const en = {
"dash.visionTimeout": "Timeout",
"dash.visionTimeoutInvalid": "Enter an integer from {min} to {max} milliseconds.",
"dash.visionAdvancedPopover": "Advanced vision settings",
"manualCompact.title": "Manual compaction",
"manualCompact.description": "Choose a model for manual /compact commands. Automatic compaction and later messages keep the conversation settings.",
"manualCompact.model": "Compaction model",
"manualCompact.effort": "Reasoning effort",
"manualCompact.currentModel": "Use conversation model",
"manualCompact.currentEffort": "Keep request effort",
"manualCompact.effortHint": "Reasoning applies where supported by the compaction endpoint. The model must accept the full conversation.",
"manualCompact.dataNotice": "Manual /compact sends the entire conversation to the selected model's provider for summarization, even when the conversation runs on another provider.",
"manualCompact.providerWarning": "With this setting, every manual /compact sends the full conversation contents to {provider} for summarization.",
"manualCompact.comboWarning": "With this setting, every manual /compact sends the full conversation contents to every target of combo {combo} ({providers}), including failover targets, for summarization.",
"manualCompact.comboProvidersUnknown": "its configured target providers",
"manualCompact.loadFailed": "Could not load compaction settings.",
"manualCompact.saved": "Compaction settings saved.",
"manualCompact.saveFailed": "Could not save. Your changes are still here; try again.",
"dash.shadowCallIntercept": "Shadow Call Intercept",
"dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.",
"dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.",
Expand Down Expand Up @@ -692,6 +706,7 @@ export const en = {
"models.reasoningEffort.high": "High",
"models.reasoningEffort.xhigh": "Extra high",
"models.reasoningEffort.max": "Maximum",
"models.reasoningEffort.ultra": "Ultra",
"models.tipProvider": "Provider",
"models.tipContext": "Context",
"models.tipModalities": "Modalities",
Expand Down
15 changes: 15 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,20 @@ export const fr: Record<TKey, string> = {
"dash.visionTimeout": "Délai d’expiration",
"dash.visionTimeoutInvalid": "Saisissez un entier compris entre {min} et {max} millisecondes.",
"dash.visionAdvancedPopover": "Paramètres de vision avancés",
"manualCompact.title": "Compaction manuelle",
"manualCompact.description": "Choisissez un modèle pour les commandes /compact manuelles. La compaction automatique et les messages suivants conservent les paramètres de la conversation.",
"manualCompact.model": "Modèle de compaction",
"manualCompact.effort": "Effort de raisonnement",
"manualCompact.currentModel": "Utiliser le modèle de la conversation",
"manualCompact.currentEffort": "Conserver l’effort de la requête",
"manualCompact.effortHint": "Le raisonnement s’applique si le point de terminaison de compaction le prend en charge. Le modèle doit accepter toute la conversation.",
"manualCompact.dataNotice": "Un /compact manuel envoie toute la conversation au fournisseur du modèle choisi pour la résumer, même si la conversation s’exécute chez un autre fournisseur.",
"manualCompact.providerWarning": "Avec ce réglage, chaque /compact manuel envoie l’intégralité du contenu de la conversation à {provider} pour la résumer.",
"manualCompact.comboWarning": "Avec ce réglage, chaque /compact manuel envoie l’intégralité du contenu de la conversation à chaque cible du combo {combo} ({providers}), y compris les cibles de bascule, pour le résumer.",
"manualCompact.comboProvidersUnknown": "ses fournisseurs cibles configurés",
"manualCompact.loadFailed": "Impossible de charger les paramètres de compaction.",
"manualCompact.saved": "Paramètres de compaction enregistrés.",
"manualCompact.saveFailed": "Échec de l’enregistrement. Vos modifications sont conservées ; réessayez.",
"dash.shadowCallIntercept": "Interception des appels fantômes",
"dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.",
"dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.",
Expand Down Expand Up @@ -677,6 +691,7 @@ export const fr: Record<TKey, string> = {
"models.reasoningEffort.high": "Élevé",
"models.reasoningEffort.xhigh": "Très élevé",
"models.reasoningEffort.max": "Maximum",
"models.reasoningEffort.ultra": "Ultra",
"models.tipProvider": "Fournisseur",
"models.tipContext": "Contexte",
"models.tipModalities": "Modalités",
Expand Down
Loading
Loading