Skip to content
Closed
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
50 changes: 50 additions & 0 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ runs helper features around provider requests.
| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. |
| `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. |
| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefixes are `gpt-6-luna` and `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. |
| `memoryModels?` | `{ extract?: { model: string; reasoningEffort?: string }; consolidation?: { model: string; reasoningEffort?: string } }` | off | Route Codex's two memory phases to a chosen model, with an optional reasoning effort per phase. See [Memory routing](#memory-routing). |
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. |
| `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. |
| `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. |
Expand Down Expand Up @@ -658,6 +659,55 @@ caller's credential does not cross to the other provider. The selected model mus
input size and content. Restart the proxy after editing
`config.json` by hand. Dashboard saves apply immediately.

## Memory routing

In **Dashboard → Overview → Memory routing**, choose a model and an optional reasoning effort for
each of Codex's two memory phases, then click **Save**. Select **Off** and save to
remove the override. Changes apply to the next memory request without restarting the proxy.

Set `memoryModels` in OpenCodex `config.json` to route those requests. With the block omitted,
both phases keep their existing route. The phases are independent: configuring one leaves the
other alone.

```json
{
"memoryModels": {
"extract": { "model": "provider/model-id", "reasoningEffort": "low" },
"consolidation": { "model": "provider/model-id", "reasoningEffort": "medium" }
}
}
```

`extract` is the pass that summarizes one finished session into a raw memory; `consolidation` is the
single agent run that merges those raw memories into the files under `$CODEX_HOME/memories`.
`model` accepts native model IDs, provider-qualified model IDs, and configured combos.
`reasoningEffort` is optional; omit it to keep the effort Codex asked for. Supported declarations are
`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. Codex hard-codes `low` for
extract and `medium` for consolidation, so a configured effort replaces that value.

OpenCodex recognizes these requests from Codex's own turn metadata: `request_kind: "memory"` in
the `x-codex-turn-metadata` header marks an extract pass, and `thread_source:
"memory_consolidation"` marks the consolidation thread. On HTTP, a request whose
`x-openai-subagent` header names `memory_consolidation` counts as a consolidation pass only when
turn metadata is absent. Explicit non-memory metadata wins over that fallback. The model id is
deliberately not a signal: the extract pass runs on the same helper model
Codex uses for titles and commit messages, so a model-based rule would also capture ordinary
helper calls. Missing, malformed, or conflicting metadata does not activate the override; when
several copies of the metadata are supplied they must name the same phase. WebSocket requests use
each frame's metadata rather than the connection's earlier handshake metadata — the bridge
re-attaches the handshake's `x-openai-subagent` header to every frame, so that header names the
connection, not the current pass, and is not a websocket signal.

A configured phase wins when `shadowCallIntercept` would match the same request. A phase left off
keeps its current routing, including any existing shadow-call rule that matches its model. The
selected model's provider receives the session text Codex summarizes for memory, including
sessions that normally run on another provider; the dashboard panel states this next to the model
pickers. A phase whose target stopped resolving — the provider is
disabled or deleted, or its combo no longer exists — fails that memory call with `409` and error code
`memory_model_target_unavailable` instead of falling back to the default provider. The request log
names the phase (`memory-extract` or `memory-consolidation`) as the routing reason. 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
226 changes: 226 additions & 0 deletions gui/src/components/MemoryModelsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useT, type TKey } from "../i18n/shared";
import { IconAlert, IconInfo, IconX } from "../icons";
import { Select } from "../ui";
import { createBoundedFetch } from "../bounded-fetch";
import { requireJson, useModalDialog, type ModelInfo } from "../pages/dashboard-shared";
import { formatNamespacedModelId } from "../provider-icons";

type Phase = "extract" | "consolidation";
interface PhaseSetting { model?: string; reasoningEffort?: string }
type Settings = { extract?: PhaseSetting; consolidation?: PhaseSetting };

const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];

/**
* Read the persisted phases. A phase without a model is "Off", so it is dropped rather than kept
* as an empty row: that is also the shape the PUT sends back for it.
*/
function readSettings(payload: { memoryModels?: unknown }): Settings {
const value = payload.memoryModels;
if (value == null) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid settings");
const out: Settings = {};
for (const phase of ["extract", "consolidation"] as const) {
const raw = (value as Record<string, unknown>)[phase];
if (raw === undefined) continue;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid phase");
const model = "model" in raw && typeof raw.model === "string" ? raw.model.trim() : "";
if (!model) throw new Error("invalid model");
const effort = "reasoningEffort" in raw ? raw.reasoningEffort : undefined;
if (effort !== undefined && (typeof effort !== "string" || !EFFORTS.includes(effort))) throw new Error("invalid effort");
out[phase] = { model, ...(effort ? { reasoningEffort: effort } : {}) };
}
return out;
}

/**
* One phase's PUT payload. A phase with no model is "Off", which the route reads as an absent
* key, so it must stay out of the object rather than travel as an empty string.
*/
function phasePayload(model: string, effort: string): PhaseSetting | undefined {
return model ? { model, ...(effort ? { reasoningEffort: effort } : {}) } : undefined;
}

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

function MemoryModelsControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) {
const t = useT();
const [saved, setSaved] = useState<Settings | undefined>(undefined);
const [infoOpen, setInfoOpen] = useState(false);
const [extractModel, setExtractModel] = useState("");
const [extractEffort, setExtractEffort] = useState("");
const [consolidationModel, setConsolidationModel] = useState("");
const [consolidationEffort, setConsolidationEffort] = useState("");
const [busy, setBusy] = useState(false);
const [loadError, setLoadError] = useState(false);
const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null);
const active = useRef(false);
const pending = useRef<ReturnType<typeof createBoundedFetch> | null>(null);
const infoTriggerRef = useRef<HTMLButtonElement>(null);
const infoDialogRef = useModalDialog(infoOpen, infoTriggerRef);

const accept = useCallback((value: Settings) => {
setSaved(value);
setExtractModel(value.extract?.model ?? "");
setExtractEffort(value.extract?.reasoningEffort ?? "");
setConsolidationModel(value.consolidation?.model ?? "");
setConsolidationEffort(value.consolidation?.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 = readSettings(await requireJson(response));
if (active.current && pending.current === request) accept(value);
} 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);
const extract = phasePayload(extractModel, extractEffort);
const consolidation = phasePayload(consolidationModel, consolidationEffort);
try {
const response = await fetch(`${apiBase}/api/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// Null clears the whole block; a phase left at "Off" is simply absent.
memoryModels: extract || consolidation
? { ...(extract ? { extract } : {}), ...(consolidation ? { consolidation } : {}) }
: null,
}),
signal: request.signal,
});
const value = readSettings(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("memoryModels.off") },
...[...new Set([...models.map(item => item.namespaced),
...[extractModel, consolidationModel].filter(Boolean)])]
.map(value => ({ value, label: formatNamespacedModelId(value, t) }))];
const effortOptions = [{ value: "", label: t("memoryModels.defaultEffort") },
...EFFORTS.map(value => ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))];
const disabled = busy || saved === undefined || loadError;
const dirty = extractModel !== (saved?.extract?.model ?? "")
|| extractEffort !== (saved?.extract?.reasoningEffort ?? "")
|| consolidationModel !== (saved?.consolidation?.model ?? "")
|| consolidationEffort !== (saved?.consolidation?.reasoningEffort ?? "");
// The account notice is about the phase that stays on Codex's own model, so it is both
// true and useful only while exactly one of the two phases is routed.
const partiallyRouted = Boolean(extractModel) !== Boolean(consolidationModel);
const info = t("memoryModels.info");

const row = (phase: Phase, model: string, effort: string, setModel: (value: string) => void, setEffort: (value: string) => void) => (
<div className="spread memory-models-row" style={{ flexWrap: "wrap", gap: 8, marginTop: 12 }}>
<div style={{ flex: "1 1 18rem", minWidth: 0 }}>
<div className="font-semibold">{t(`memoryModels.${phase}` as TKey)}</div>
<div className="muted setting-hint">{t(`memoryModels.${phase}Hint` as TKey)}</div>
</div>
<div className="memory-models-controls">
<Select id={`memory-models-${phase}`} value={model} options={options} disabled={disabled}
label={t("memoryModels.model")}
onChange={value => { setModel(value); if (!value) setEffort(""); setFeedback(null); }} />
<Select id={`memory-models-${phase}-effort`} value={effort} options={effortOptions}
disabled={disabled || !model} align="right" label={t("memoryModels.effort")}
onChange={value => { setEffort(value); setFeedback(null); }} />
</div>
</div>
);

return (
<section className="panel" aria-labelledby="memory-models-title" aria-busy={busy || (saved === undefined && !loadError)}>
<div className="font-semibold" id="memory-models-title" style={{ display: "flex", alignItems: "center", gap: 6 }}>
{t("memoryModels.title")}
<button
ref={infoTriggerRef}
type="button"
className="btn btn-ghost btn-sm"
style={{ width: 22, height: 22, minWidth: 22, padding: 0, borderRadius: "var(--radius-pill)", color: "var(--muted)" }}
onClick={() => setInfoOpen(open => !open)}
aria-label={t("memoryModels.infoLabel")}
aria-expanded={infoOpen}
aria-haspopup="dialog"
aria-controls="memory-models-help-dialog"
>
<IconInfo width={13} height={13} aria-hidden="true" />
</button>
</div>
<div className="muted setting-hint">{t("memoryModels.description")}</div>
{row("extract", extractModel, extractEffort, setExtractModel, setExtractEffort)}
{row("consolidation", consolidationModel, consolidationEffort, setConsolidationModel, setConsolidationEffort)}
<div className="spread" style={{ alignItems: "center", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
<div className="muted setting-hint" style={{ flex: "1 1 18rem", minWidth: 0 }}>{t("memoryModels.dataNotice")}</div>
<button type="button" className="btn btn-primary btn-sm" disabled={disabled || !dirty} onClick={() => { void save(); }}>
{busy ? t("common.saving") : t("common.save")}
</button>
</div>
{partiallyRouted && <div className="notice-warn" role="note" style={{ marginTop: 12 }}>
<IconAlert width={14} /> {t("memoryModels.accountNotice")}
</div>}
{loadError && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("memoryModels.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("memoryModels.saveFailed")}</div>}
{feedback === "saved" && <div className="muted setting-hint" role="status">{t("memoryModels.saved")}</div>}
<dialog
ref={infoDialogRef}
id="memory-models-help-dialog"
className="modal-overlay"
style={{ display: infoOpen ? "flex" : "none", border: "none", margin: 0, maxWidth: "none", maxHeight: "none", width: "100%", height: "100%" }}
aria-labelledby="memory-models-help-title"
onCancel={event => { event.preventDefault(); setInfoOpen(false); }}
>
<button type="button" className="modal-backdrop-dismiss" aria-label={t("common.close")} tabIndex={-1} onClick={() => setInfoOpen(false)} />
<div className="modal-card" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<h3 id="memory-models-help-title">{t("memoryModels.title")}</h3>
<button type="button" className="btn btn-ghost btn-icon" onClick={() => setInfoOpen(false)} aria-label={t("common.close")}><IconX /></button>
</div>
<div className="modal-desc leading-relaxed" style={{ whiteSpace: "pre-line" }}>
{info}
</div>
<div className="modal-actions">
<button type="button" className="btn btn-primary" onClick={() => setInfoOpen(false)}>{t("common.ok")}</button>
</div>
</div>
</dialog>
</section>
);
}
17 changes: 17 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,23 @@ export const de: Record<TKey, string> = {
"compactionRouting.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.",
"compactionRouting.saved": "Komprimierungseinstellungen gespeichert.",
"compactionRouting.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen sind noch vorhanden; versuche es erneut.",
"memoryModels.title": "Memory-Routing",
"memoryModels.description": "Codex schreibt Memories nach einer Sitzung im Hintergrund. Wähle für jede Phase ein Modell oder behalte die bestehende Route bei.",
"memoryModels.infoLabel": "Was sind Extract und Consolidation?",
"memoryModels.info": "Codex macht Memory in zwei Schritten. Extract liest eine beendete Sitzung und notiert, was passiert ist: ein Notizzettel pro Sitzung, also viele kleine Aufrufe. Consolidation nimmt diese Zettel und schreibt sie in die Memory-Dateien, die Codex am Anfang deiner nächsten Sitzungen liest. Läuft selten, bearbeitet aber Dateien. Jeder Schritt fragt sein Modell selbst an, deshalb stehen sie hier getrennt.",
"memoryModels.extract": "Extraktion",
"memoryModels.extractHint": "Fasst jede beendete Sitzung zu einem Raw Memory zusammen. Läuft einmal pro Sitzung.",
"memoryModels.consolidation": "Konsolidierung",
"memoryModels.consolidationHint": "Führt die Raw Memories in die Memory-Dateien zusammen, die Codex später liest. Läuft selten und bearbeitet Dateien.",
"memoryModels.model": "Modell",
"memoryModels.effort": "Reasoning-Aufwand",
"memoryModels.off": "Aus",
"memoryModels.defaultEffort": "Codex-Standard",
"memoryModels.dataNotice": "Das gewählte Modell erhält die Eingabe seiner Phase: die beendete Sitzung bei Extract, die Raw Memories bei Consolidation.",
"memoryModels.accountNotice": "Nur eine Phase ist hier geroutet; die andere behält ihre bestehende Route. Der Shadow Call Intercept kann auch die Memory-Aufrufe dieser Phase an sein eingestelltes Modell schicken.",
"memoryModels.loadFailed": "Memory-Einstellungen konnten nicht geladen werden.",
"memoryModels.saved": "Memory-Einstellungen gespeichert.",
"memoryModels.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen stehen noch da; versuch 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
Loading
Loading