From de5df9bd06b0e967e4b7c752958a69902bea28cc Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 20 Sep 2026 02:30:23 +0900 Subject: [PATCH 1/6] feat(responses): route manual /compact to a configurable model and effort Adds an optional `manualCompaction` setting (`{ model, reasoningEffort? }`) that sends Codex's manual `/compact` request to a different model while every other request stays on the conversation's model. Without the setting nothing changes. A long conversation on an expensive model that sits idle past the provider's prompt-cache window re-reads its whole context at the uncached input price on the next request. Running `/compact` on a cheap model pays that one full uncached read at cheap-model rates, and the expensive model then resumes on the compacted context. Codex offers no per-command model selection, and OpenCodex previously routed the compaction request exactly like an ordinary turn. The override fires only for requests whose `x-codex-turn-metadata` carries `request_kind: "compaction"` and `compaction.trigger: "manual"`, and on `/v1/responses` only when the input also carries a `compaction_trigger` item. Automatic compaction, ordinary turns, and malformed or absent metadata are untouched. When the selected model leaves the conversation's provider identity the caller credential is treated as rewritten and the portable summarizer runs, because native `/responses/compact` ciphertext replays only on the backend that minted it. Carried from #4872 and rebased onto the current dev tip: `compactHandoffRoute`, `rememberCompactHandoffRoute` and `forgetCompactHandoffRoute` now take an `admission` argument, `config-routes.ts` gained `fastRows`, and `diagnostics.ts` gained `spendSchema`. Thirteen `structure/` owners took an append-at-end resolution keeping both sides. Co-authored-by: nahuelb --- .../docs/reference/configuration/server.md | 46 ++ gui/src/components/ManualCompactionPanel.tsx | 158 ++++++ gui/src/i18n/de.ts | 15 + gui/src/i18n/en.ts | 15 + gui/src/i18n/fr.ts | 15 + gui/src/i18n/ja.ts | 15 + gui/src/i18n/ko.ts | 15 + gui/src/i18n/ru.ts | 15 + gui/src/i18n/tr.ts | 15 + gui/src/i18n/zh-TW.ts | 15 + gui/src/i18n/zh.ts | 15 + gui/src/pages/dashboard-overview-panels.tsx | 2 + gui/tests/fr-localization.test.ts | 1 + gui/tests/manual-compaction-panel.test.tsx | 131 +++++ scripts/test-layout/layout.json | 1 + src/adapters/openai-responses/passthrough.ts | 2 +- src/config.ts | 4 +- src/config/diagnostics.ts | 5 + src/config/load-degrade.ts | 8 + src/config/schema/config-schema.ts | 2 + src/config/schema/leaf-validators.ts | 5 + src/server/management/config-routes.ts | 20 +- src/server/responses/compact.ts | 31 +- src/server/responses/core-combo.ts | 2 +- src/server/responses/core-options.ts | 2 + src/server/responses/manual-compaction.ts | 86 +++ src/server/responses/request-prepare.ts | 21 +- src/server/responses/request-sidecar-auth.ts | 2 +- src/types/config.ts | 4 + src/types/request.ts | 2 + structure/adapters/registry.md | 2 + structure/catalog.md | 3 + structure/clients/claude-desktop.md | 2 + structure/config.md | 1 + structure/data-planes/images.md | 3 + structure/data-planes/inbound-compat.md | 2 + structure/gui-and-management-api.md | 9 + structure/ops/docs-and-release.md | 3 + structure/ops/service-and-sidecars.md | 3 + structure/overview.md | 3 + structure/providers/xai-grok.md | 4 + structure/runtime.md | 3 + structure/subagents.md | 2 + structure/transports/byte-accounting.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 35 ++ structure/transports/streaming-health.md | 2 + tests/config/settings-stream-mode.test.ts | 47 ++ tests/fixtures/test-layout-expected.json | 1 + tests/helpers/responses-core-source.ts | 1 + .../responses-manual-compaction.test.ts | 522 ++++++++++++++++++ 51 files changed, 1302 insertions(+), 20 deletions(-) create mode 100644 gui/src/components/ManualCompactionPanel.tsx create mode 100644 gui/tests/manual-compaction-panel.test.tsx create mode 100644 src/server/responses/manual-compaction.ts create mode 100644 tests/responses/responses-manual-compaction.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 8a596e2a96..e733f07b7b 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -494,6 +494,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 diff --git a/gui/src/components/ManualCompactionPanel.tsx b/gui/src/components/ManualCompactionPanel.tsx new file mode 100644 index 0000000000..cfcb411a32 --- /dev/null +++ b/gui/src/components/ManualCompactionPanel.tsx @@ -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 { + 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: { 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 ; +} + +function ManualCompactionControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) { + const t = useT(); + const [saved, setSaved] = useState(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>({}); + const active = useRef(false); + const pending = useRef | 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 ( +
+
+
+
{t("manualCompact.title")}
+
{t("manualCompact.description")}
+
{t("manualCompact.dataNotice")}
+
{t("manualCompact.effortHint")}
+
+
+ ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]} + onChange={value => { setEffort(value); setFeedback(null); }} /> + +
+
+ {provider &&
{combo + ? t("manualCompact.comboWarning", { combo: model, providers }) + : t("manualCompact.providerWarning", { provider })}
} + {loadError &&
{t("manualCompact.loadFailed")}
} + {feedback === "failed" &&
{t("manualCompact.saveFailed")}
} + {feedback === "saved" &&
{t("manualCompact.saved")}
} +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3c12b24a2d..c77c51d7e2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -352,6 +352,20 @@ export const de: Record = { "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.", @@ -678,6 +692,7 @@ export const de: Record = { "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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c66a5e993b..bb800248fb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -370,6 +370,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.", @@ -707,6 +721,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", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 6af22a0e9d..5c455704b3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -360,6 +360,20 @@ export const fr: Record = { "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é.", @@ -692,6 +706,7 @@ export const fr: Record = { "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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 19ee0ddc00..56d0f795f4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -361,6 +361,20 @@ export const ja: Record = { "dash.visionSidecar": "ビジョンサイドカー", "dash.visionSidecarHint": "テキスト専用ルーティングモデルで画像を説明するために使うバックエンドとモデルを選択します。", "dash.visionOff": "オフ", + "manualCompact.title": "手動圧縮", + "manualCompact.description": "手動の /compact コマンドに使うモデルを選択します。自動圧縮と以降のメッセージは会話の設定を維持します。", + "manualCompact.model": "圧縮モデル", + "manualCompact.effort": "推論の強度", + "manualCompact.currentModel": "会話のモデルを使用", + "manualCompact.currentEffort": "リクエストの推論強度を維持", + "manualCompact.effortHint": "圧縮エンドポイントが対応している場合に推論設定が適用されます。モデルは会話全体を受け入れられる必要があります。", + "manualCompact.dataNotice": "手動の /compact は、会話が別のプロバイダーで動いていても、会話全体を選択したモデルのプロバイダーへ送信して要約します。", + "manualCompact.providerWarning": "この設定では、手動の /compact のたびに会話の全内容が要約のために {provider} へ送信されます。", + "manualCompact.comboWarning": "この設定では、手動の /compact のたびに会話の全内容が、フェイルオーバー先を含むコンボ {combo} のすべてのターゲット({providers})へ要約のために送信されます。", + "manualCompact.comboProvidersUnknown": "設定済みのターゲットプロバイダー", + "manualCompact.loadFailed": "圧縮設定を読み込めませんでした。", + "manualCompact.saved": "圧縮設定を保存しました。", + "manualCompact.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", "dash.shadowCallIntercept": "シャドウコール傍受", "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", "dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。", @@ -2556,6 +2570,7 @@ export const ja: Record = { "models.reasoningEffort.high": "高", "models.reasoningEffort.xhigh": "非常に高", "models.reasoningEffort.max": "最大", + "models.reasoningEffort.ultra": "ウルトラ", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f71338a352..bff1c3d896 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -356,6 +356,20 @@ export const ko: Record = { "dash.visionSidecar": "비전 사이드카", "dash.visionSidecarHint": "텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다.", "dash.visionOff": "끔", + "manualCompact.title": "수동 압축", + "manualCompact.description": "수동 /compact 명령에 사용할 모델을 선택하세요. 자동 압축과 이후 메시지는 대화 설정을 유지합니다.", + "manualCompact.model": "압축 모델", + "manualCompact.effort": "추론 수준", + "manualCompact.currentModel": "대화 모델 사용", + "manualCompact.currentEffort": "요청의 추론 수준 유지", + "manualCompact.effortHint": "압축 엔드포인트가 지원하는 경우 추론 설정이 적용됩니다. 모델은 전체 대화를 수용할 수 있어야 합니다.", + "manualCompact.dataNotice": "수동 /compact는 대화가 다른 프로바이더에서 실행 중이더라도 전체 대화를 선택한 모델의 프로바이더로 보내 요약합니다.", + "manualCompact.providerWarning": "이 설정을 사용하면 수동 /compact마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", + "manualCompact.comboWarning": "이 설정을 사용하면 수동 /compact마다 전체 대화 내용이 장애 조치 대상을 포함한 콤보 {combo}의 모든 대상({providers})으로 요약을 위해 전송됩니다.", + "manualCompact.comboProvidersUnknown": "구성된 대상 프로바이더", + "manualCompact.loadFailed": "압축 설정을 불러올 수 없습니다.", + "manualCompact.saved": "압축 설정을 저장했습니다.", + "manualCompact.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다.", "dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.", @@ -689,6 +703,7 @@ export const ko: Record = { "models.reasoningEffort.high": "높음", "models.reasoningEffort.xhigh": "매우 높음", "models.reasoningEffort.max": "최대", + "models.reasoningEffort.ultra": "울트라", "models.tipProvider": "프로바이더", "models.tipContext": "컨텍스트", "models.tipModalities": "모달리티", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a7e402cdf0..86d3353a03 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -361,6 +361,20 @@ export const ru: Record = { "dash.visionSidecar": "Сайдкар для изображений", "dash.visionSidecarHint": "Выберите бэкенд и модель, которые описывают изображения для маршрутизируемых моделей, работающих только с текстом.", "dash.visionOff": "Выкл", + "manualCompact.title": "Ручное сжатие", + "manualCompact.description": "Выберите модель для ручных команд /compact. Автоматическое сжатие и последующие сообщения сохраняют настройки разговора.", + "manualCompact.model": "Модель сжатия", + "manualCompact.effort": "Уровень рассуждений", + "manualCompact.currentModel": "Использовать модель разговора", + "manualCompact.currentEffort": "Сохранить уровень из запроса", + "manualCompact.effortHint": "Уровень рассуждений применяется, если его поддерживает конечная точка сжатия. Модель должна вмещать весь разговор.", + "manualCompact.dataNotice": "Ручной /compact отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", + "manualCompact.providerWarning": "С этой настройкой каждый ручной /compact отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", + "manualCompact.comboWarning": "С этой настройкой каждый ручной /compact отправляет полное содержимое разговора каждой цели комбо {combo} ({providers}), включая резервные цели, для составления сводки.", + "manualCompact.comboProvidersUnknown": "его настроенные целевые провайдеры", + "manualCompact.loadFailed": "Не удалось загрузить настройки сжатия.", + "manualCompact.saved": "Настройки сжатия сохранены.", + "manualCompact.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.", "dash.shadowCallIntercept": "Перехват теневых вызовов", "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель.", "dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.", @@ -691,6 +705,7 @@ export const ru: Record = { "models.reasoningEffort.high": "Высокий", "models.reasoningEffort.xhigh": "Очень высокий", "models.reasoningEffort.max": "Максимальный", + "models.reasoningEffort.ultra": "Ультра", "models.tipProvider": "Провайдер", "models.tipContext": "Контекст", "models.tipModalities": "Модальности", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f491c7f527..2e81d73c5b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -362,6 +362,20 @@ export const tr: Record = { "dash.visionSidecar": "Görsel yan aracı (sidecar)", "dash.visionSidecarHint": "Salt metin modeller için görselleri tanımlamakta kullanılan arka ucu ve modeli seçin.", "dash.visionOff": "Kapalı", + "manualCompact.title": "Manuel özetleme", + "manualCompact.description": "Manuel /compact komutları için bir model seçin. Otomatik özetleme ve sonraki mesajlar konuşma ayarlarını korur.", + "manualCompact.model": "Özetleme modeli", + "manualCompact.effort": "Akıl yürütme düzeyi", + "manualCompact.currentModel": "Konuşma modelini kullan", + "manualCompact.currentEffort": "İsteğin düzeyini koru", + "manualCompact.effortHint": "Akıl yürütme, özetleme uç noktası destekliyorsa uygulanır. Model tüm konuşmayı kabul edebilmelidir.", + "manualCompact.dataNotice": "Manuel /compact, konuşma başka bir sağlayıcıda yürütülse bile konuşmanın tamamını özetlenmek üzere seçilen modelin sağlayıcısına gönderir.", + "manualCompact.providerWarning": "Bu ayarla her manuel /compact, konuşmanın tüm içeriğini özetlenmek üzere {provider} sağlayıcısına gönderir.", + "manualCompact.comboWarning": "Bu ayarla her manuel /compact, konuşmanın tüm içeriğini yedek hedefler dahil {combo} kombosunun her hedefine ({providers}) özetlenmek üzere gönderir.", + "manualCompact.comboProvidersUnknown": "yapılandırılmış hedef sağlayıcıları", + "manualCompact.loadFailed": "Özetleme ayarları yüklenemedi.", + "manualCompact.saved": "Özetleme ayarları kaydedildi.", + "manualCompact.saveFailed": "Kaydedilemedi. Değişiklikleriniz korunuyor; tekrar deneyin.", "dash.shadowCallIntercept": "Gölge Çağrı Yakalama", "dash.shadowCallInterceptHint": "Codex App'in arka plan yardımcı çağrılarını ({models}) başlık oluşturma ve commit mesajları için yakalar ve seçtiğiniz modele yönlendirir.", "dash.shadowCallWarning": "⚠ Etkinleştirildiğinde, {models} için olan TÜM istekler seçilen modelle değiştirilecektir.", @@ -694,6 +708,7 @@ export const tr: Record = { "models.reasoningEffort.high": "Yüksek", "models.reasoningEffort.xhigh": "Çok yüksek", "models.reasoningEffort.max": "Maksimum", + "models.reasoningEffort.ultra": "Ultra", "models.tipProvider": "Sağlayıcı", "models.tipContext": "Bağlam", "models.tipModalities": "Girdi Türleri", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f23791fee8..436b53c23d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -251,6 +251,20 @@ export const zhTW: Record = { "dash.visionSidecar": "視覺附屬服務", "dash.visionSidecarHint": "選擇純文字路由模型描述圖像時使用的後端和模型。", "dash.visionOff": "關閉", + "manualCompact.title": "手動壓縮", + "manualCompact.description": "選擇手動 /compact 命令使用的模型。自動壓縮和後續訊息保留對話設定。", + "manualCompact.model": "壓縮模型", + "manualCompact.effort": "推理強度", + "manualCompact.currentModel": "使用對話模型", + "manualCompact.currentEffort": "保留請求的推理強度", + "manualCompact.effortHint": "推理設定僅在壓縮端點支援時生效。模型必須能容納完整對話。", + "manualCompact.dataNotice": "手動 /compact 會將整個對話傳送給所選模型的供應商進行摘要,即使對話正在其他供應商上執行。", + "manualCompact.providerWarning": "啟用此設定後,每次手動 /compact 都會將完整對話內容傳送給 {provider} 進行摘要。", + "manualCompact.comboWarning": "啟用此設定後,每次手動 /compact 都會將完整對話內容傳送給組合 {combo} 的每個目標({providers}),包括容錯移轉目標,以進行摘要。", + "manualCompact.comboProvidersUnknown": "其已設定的目標供應商", + "manualCompact.loadFailed": "無法載入壓縮設定。", + "manualCompact.saved": "壓縮設定已儲存。", + "manualCompact.saveFailed": "儲存失敗。變更仍然保留,請重試。", "dash.shadowCallIntercept": "影子呼叫攔截", "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", "dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。", @@ -555,6 +569,7 @@ export const zhTW: Record = { "models.reasoningEffort.high": "高", "models.reasoningEffort.xhigh": "極高", "models.reasoningEffort.max": "最高", + "models.reasoningEffort.ultra": "超高", "models.tipProvider": "供應商", "models.tipContext": "上下文", "models.tipModalities": "模態", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4a8dcbf727..0eab6efe2f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -356,6 +356,20 @@ export const zh: Record = { "dash.visionSidecar": "视觉附属服务", "dash.visionSidecarHint": "选择纯文本路由模型描述图像时使用的后端和模型。", "dash.visionOff": "关闭", + "manualCompact.title": "手动压缩", + "manualCompact.description": "选择手动 /compact 命令使用的模型。自动压缩和后续消息保留对话设置。", + "manualCompact.model": "压缩模型", + "manualCompact.effort": "推理强度", + "manualCompact.currentModel": "使用对话模型", + "manualCompact.currentEffort": "保留请求的推理强度", + "manualCompact.effortHint": "推理设置仅在压缩端点支持时生效。模型必须能容纳完整对话。", + "manualCompact.dataNotice": "手动 /compact 会将整个对话发送给所选模型的提供商进行摘要,即使对话正在其他提供商上运行。", + "manualCompact.providerWarning": "启用此设置后,每次手动 /compact 都会将完整对话内容发送给 {provider} 进行摘要。", + "manualCompact.comboWarning": "启用此设置后,每次手动 /compact 都会将完整对话内容发送给组合 {combo} 的每个目标({providers}),包括故障转移目标,以进行摘要。", + "manualCompact.comboProvidersUnknown": "其已配置的目标提供商", + "manualCompact.loadFailed": "无法加载压缩设置。", + "manualCompact.saved": "压缩设置已保存。", + "manualCompact.saveFailed": "保存失败。更改仍然保留,请重试。", "dash.shadowCallIntercept": "影子调用拦截", "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。", "dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。", @@ -686,6 +700,7 @@ export const zh: Record = { "models.reasoningEffort.high": "高", "models.reasoningEffort.xhigh": "极高", "models.reasoningEffort.max": "最高", + "models.reasoningEffort.ultra": "超高", "models.tipProvider": "提供方", "models.tipContext": "上下文", "models.tipModalities": "模态", diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 8009d04423..fa5daeada0 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -1,3 +1,4 @@ +import ManualCompactionPanel from "../components/ManualCompactionPanel"; import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; import type { useDashboardData } from "./use-dashboard-data"; import { @@ -18,6 +19,7 @@ export function DashboardOverviewPanels(props: Dash) { + ); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 689a1b8672..5c5d8aa73d 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -134,6 +134,7 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientCline", "models.reasoningEffort.minimal", "models.reasoningEffort.max", + "models.reasoningEffort.ultra", "pws.pacingRpmUnit", "claudeDesktop.family.opus", "claudeDesktop.family.fable", diff --git a/gui/tests/manual-compaction-panel.test.tsx b/gui/tests/manual-compaction-panel.test.tsx new file mode 100644 index 0000000000..b4e6646dd0 --- /dev/null +++ b/gui/tests/manual-compaction-panel.test.tsx @@ -0,0 +1,131 @@ +/** @jsxImportSource react */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, StrictMode } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ManualCompactionPanel from "../src/components/ManualCompactionPanel"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record; +let win: Window; +let root: Root | undefined; +let container: HTMLDivElement; +let setting: { model: string; reasoningEffort?: string } | null; +let failLoad: boolean; +let failSave: boolean; +let writes: unknown[]; +const models = [{ id: "cheap", provider: "gateway", namespaced: "gateway/cheap" }, { id: "compact", provider: "combo", namespaced: "combo/compact" }]; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + for (const key of ["document", "window", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + win.localStorage.setItem("ocx-lang", "en"); + setting = null; failLoad = false; failSave = false; writes = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => { + if (String(_input).endsWith("/api/combos")) { + return Response.json({ combos: [{ id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }] }); + } + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + writes.push(body); + if (failSave) return Response.json({ error: "fixture failure" }, { status: 500 }); + setting = body.manualCompaction; + } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json({ manualCompaction: setting }); + } }); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; win.close(); + for (const key of globals) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]!); + else delete (globalThis as Record)[key]; + } +}); + +async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); } +async function render(base = "") { + if (!root) { + container = win.document.createElement("div") as unknown as HTMLDivElement; + win.document.body.appendChild(container); + root = (await import("react-dom/client")).createRoot(container); + } + await act(async () => { root!.render(); }); + await flush(); +} +async function choose(id: string, label: string) { + await act(async () => { container.querySelector(`#manual-compaction-${id}`)!.click(); }); + const option = [...win.document.querySelectorAll('[role="option"]')].find(node => node.textContent === label); + expect(option).toBeDefined(); + await act(async () => { (option as unknown as HTMLButtonElement).click(); }); +} +function saveButton() { return [...container.querySelectorAll('button')].find(button => button.textContent === "Save")!; } +async function save() { await act(async () => { saveButton().click(); }); } + +test("saves model and optional effort, reloads, removes effort, and clears override", async () => { + await render(); + expect(saveButton().disabled).toBe(true); + await choose("model", "gateway/cheap"); + await choose("effort", "Low"); + await save(); + expect(writes).toEqual([{ manualCompaction: { model: "gateway/cheap", reasoningEffort: "low" } }]); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Compaction settings saved."); + await render("/reloaded"); + expect(container.querySelector('#manual-compaction-effort')?.textContent).toContain("Low"); + await choose("effort", "Keep request effort"); + await save(); + expect(writes.at(-1)).toEqual({ manualCompaction: { model: "gateway/cheap" } }); + await choose("model", "Use conversation model"); + await save(); + expect(writes.at(-1)).toEqual({ manualCompaction: null }); + expect(container.querySelector('#manual-compaction-effort')!.disabled).toBe(true); +}); + +test("failed save retains the draft and allows retry", async () => { + await render(); + await choose("model", "gateway/cheap"); + failSave = true; + await save(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save"); + expect(container.querySelector('#manual-compaction-model')?.textContent).toContain("gateway/cheap"); + expect(saveButton().disabled).toBe(false); + expect(setting).toBeNull(); + failSave = false; + await save(); + expect(setting).toEqual({ model: "gateway/cheap" }); +}); + +test("failed load disables editing and retry recovers", async () => { + failLoad = true; + await render(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load"); + expect(container.querySelector('#manual-compaction-model')!.disabled).toBe(true); + failLoad = false; + await act(async () => { [...container.querySelectorAll('button')].find(button => button.textContent === "Retry")!.click(); }); + expect(container.querySelector('#manual-compaction-model')!.disabled).toBe(false); +}); + +test("retains a saved model missing from the current catalog", async () => { + setting = { model: "gateway/retired", reasoningEffort: "high" }; + await render(); + expect(container.querySelector('#manual-compaction-model')?.textContent).toContain("gateway/retired"); + expect(saveButton().disabled).toBe(true); +}); + +test("discloses that the selected provider receives the full conversation", async () => { + await render(); + expect(container.textContent).toContain("sends the entire conversation to the selected model's provider"); + expect(container.querySelector('[role="note"]')).toBeNull(); + await choose("model", "gateway/cheap"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("sends the full conversation contents to gateway for summarization"); + await choose("model", "combo/compact"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("every target of combo combo/compact (gateway, openai-apikey), including failover targets"); + await choose("model", "Use conversation model"); + expect(container.querySelector('[role="note"]')).toBeNull(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ac1a90a8c1..e4f6396d53 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1212,6 +1212,7 @@ "responses-canonical-only-top-level-fields.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", + "responses-manual-compaction.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 31130b5b78..d242f3728b 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -411,7 +411,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // therefore be the last routed transform that may depend on those declarations. Structural // sanitizers below can still run after it. outBody = normalizeResponsesCodeMode(outBody, parsed, provider); - if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { + if (parsed._compactionRequest === true && (!isCanonicalOpenAiForwardProvider(provider) || parsed._portableCompaction === true)) { outBody = buildRoutedCompactionBody(outBody); } // Run after routed compaction so nested input_image parts are replaced before a malformed diff --git a/src/config.ts b/src/config.ts index 510501c507..d2f07741d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -170,7 +170,7 @@ import { sanitizeModelCostsForLoad, sanitizeCapabilityDeclarationsForLoad, warnInheritedFastWireConflicts, - warnDegradedStreamMode, + warnDegradedStreamMode, warnDegradedManualCompaction, warnDegradedHostname, warnDegradedListeners, warnDegradedApiKeys, @@ -227,7 +227,7 @@ export function loadConfig(): OcxConfig { if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); warnInheritedFastWireConflicts(configPath, config); - warnDegradedStreamMode(parsed, config); + warnDegradedStreamMode(parsed, config); warnDegradedManualCompaction(parsed, config); warnDegradedHostname(parsed, config); warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index b3904dfb0a..177bae738e 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -60,6 +60,7 @@ import { remoteGuiConfigSchema, runtimeRoleSchema, spendSchema, + manualCompactionSchema, } from "./schema/leaf-validators"; export type ConfigDiagnostics = { @@ -562,6 +563,10 @@ function managementIngressConfigError(value: unknown): string | null { } export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { + const manualCompaction = rawConfigRecord(value)?.manualCompaction; + if (manualCompaction !== undefined && !manualCompactionSchema.safeParse(manualCompaction).success) { + return { ok: false, error: "schema_invalid: manualCompaction: requires a nonblank model and an optional valid reasoningEffort" }; + } const boundaryError = configReasoningPinsConfigError(value) ?? blankHostnameError(value) ?? claudeSubagentEffortError(value) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 0f482cf8df..b882aa5cd7 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -101,6 +101,14 @@ export function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig) } } +export function warnDegradedManualCompaction(rawParsed: unknown, validated: OcxConfig): void { + if (!rawParsed || typeof rawParsed !== "object") return; + const raw = (rawParsed as Record).manualCompaction; + if (raw !== undefined && validated.manualCompaction === undefined) { + console.warn("⚠️ config.json manualCompaction is invalid (expected { model, reasoningEffort? } with a nonblank model and a declared effort) — manual /compact keeps the conversation model"); + } +} + /** * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 94844bf4ec..3a23236775 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -21,6 +21,7 @@ import { CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, codexAccountNamespacesSchema, modelPinnedEffortsSchema, + manualCompactionSchema, modelPreferHostedToolsConfigError, providerModelCostsConfigError, providerRelativeSendPathConfigError, @@ -125,6 +126,7 @@ export const configSchema = z.object({ ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), modelPinnedEfforts: modelPinnedEffortsSchema.optional(), + manualCompaction: manualCompactionSchema.optional().catch(undefined), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), // Malformed hand edits disable this opt-in projection without rejecting providers. diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 3f5988f542..1012950644 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -44,6 +44,11 @@ export function isUsableApiKeySecret(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value === value.trim(); } +export const manualCompactionSchema = z.object({ + model: z.string().trim().min(1), + reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(), +}).strict(); + /** * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth * shared by the config schema, the load-time sanitizer, and the management write diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 52363af836..4ef8d2fc73 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,3 +1,5 @@ +import { manualCompactionSchema } from "../../config/schema/leaf-validators"; +import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; import type { IntegrationClientId } from "../../integrations/registry"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -342,6 +344,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise void; @@ -604,6 +610,9 @@ export async function handleResponsesCompact( if (!body || typeof body !== "object" || Array.isArray(body)) { return formatErrorResponse(400, "invalid_request_error", "Invalid compaction request body"); } + if (!options.manualCompactionOverride) { + options = { ...options, manualCompactionOverride: applyManualCompactionOverride(body, req.headers, config, { endpoint: "compact" }) }; + } const raw = body as { model?: unknown; input?: unknown }; if (typeof raw.model !== "string" || raw.model.length === 0) { return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model"); @@ -618,11 +627,12 @@ export async function handleResponsesCompact( // The client's own selector, kept for the request log: `raw.model` is rewritten to the // base id above, and logCtx.requestedModel is assigned from it further down, so without // this the log would lose which id the client actually asked for. - const compactRequestedModel = compactFastRow ? compactFastRow.baseId + "--fast" : raw.model; + const compactRequestedModel = options.manualCompactionOverride?.sourceModel + ?? (compactFastRow ? compactFastRow.baseId + "--fast" : raw.model); // Recall the last completed client-visible bare model after a combo switch (#3891). // Configured selectors take precedence over this implicit session hint. - if (typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow + if (!options.manualCompactionOverride && typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow && !resolveComboId(config, compactModel)) { const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel); if (recalledComboId) { @@ -748,7 +758,12 @@ export async function handleResponsesCompact( // no budget at all, so `handleResponsesInner` minted a fresh four after the native attempt // had already spent some of the first one. const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget(); - if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo) { + // A manual override onto another backend must not mint ciphertext the conversation model cannot replay. + const manualOverrideCrossesProvider = options.manualCompactionOverride + ? !manualCompactionKeepsProviderIdentity(config, options.manualCompactionOverride, route) + : false; + if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel && !route.combo + && !manualOverrideCrossesProvider) { if (req.signal.aborted) { return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } @@ -1309,9 +1324,9 @@ export async function handleResponsesCompact( // synthetic buffer errors are not upstream bodies and stay uninspected. if (buffered.ok) { inspectResponseLogJson(logCtx, await buffered.clone().text()); - forgetCompactHandoffRoute(req, admission); + if (!options.manualCompactionOverride) forgetCompactHandoffRoute(req, admission); rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); - } else if (quotaFailure && !storedPool401ReplayAttempted) { + } else if (!options.manualCompactionOverride && quotaFailure && !storedPool401ReplayAttempted) { const fallbackModel = compactHandoffRoute(req, admission, raw.model); if (fallbackModel && !req.signal.aborted) { const fallbackReq = new Request(req.url, { @@ -1374,7 +1389,7 @@ export async function handleResponsesCompact( // The routed compaction turn is a handoff inside the same logical request, so it draws the // REMAINDER. Minting here is what let a native attempt spend three sends and the routed // fallback spend four more. - const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, ...(admission ? { admission } : {}) }); + const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, manualCompactionOverride: options.manualCompactionOverride, ...(admission ? { admission } : {}) }); if (!response.ok) return response; let json: { output?: unknown[]; status?: unknown; error?: unknown }; if (response.headers.get("content-type")?.includes("text/event-stream")) { @@ -1444,7 +1459,7 @@ export async function handleResponsesCompact( const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, }); - rememberCompactHandoffRoute(req, admission, raw.model); + if (!options.manualCompactionOverride) rememberCompactHandoffRoute(req, admission, raw.model); return result; } const encrypted = compactionItems[0]!.encrypted_content; @@ -1455,6 +1470,6 @@ export async function handleResponsesCompact( } const summary = decoded; const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary); - rememberCompactHandoffRoute(req, admission, raw.model); + if (!options.manualCompactionOverride) rememberCompactHandoffRoute(req, admission, raw.model); return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } }); } diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 81b7427499..571dcdb82a 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -521,7 +521,7 @@ export async function executeComboResponses( // The live config can change while the child is streaming. Never retain credentials. const currentCombo = getCombo(config, comboId); const provider = config.providers[completedTarget.provider]; - if (Object.hasOwn(config.providers, completedTarget.provider) + if (!options.manualCompactionOverride && Object.hasOwn(config.providers, completedTarget.provider) && provider && provider.disabled !== true && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) { rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration); diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 4c790fd498..01ae19f1f0 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -11,6 +11,7 @@ import type { NativeMainRefreshDependencies } from "../../codex/main-account"; import type { InboundWire } from "../../providers/registry"; import type { ExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar"; import type { CallerDirectAuth } from "../../providers/caller-authorization"; +import type { ManualCompactionOverride } from "./manual-compaction"; import type { TranslatorBudget } from "../../lib/translator-budget"; import type { TransientSendBudget } from "../../lib/upstream-retry"; import type { RequestLogContext } from "../request-log"; @@ -105,6 +106,7 @@ export interface HandleResponsesOptions { callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + manualCompactionOverride?: ManualCompactionOverride | null; /** Internal combo handoff for one parent-validated continuation snapshot. */ comboReplaySnapshot?: { sourceBody: unknown; diff --git a/src/server/responses/manual-compaction.ts b/src/server/responses/manual-compaction.ts new file mode 100644 index 0000000000..8ee4915292 --- /dev/null +++ b/src/server/responses/manual-compaction.ts @@ -0,0 +1,86 @@ +import type { OcxConfig } from "../../types"; +import { isDeclaredReasoningEffort } from "../../reasoning-effort"; +import { routeConcreteModel, type RouteResult } from "../../router"; +import { resolveComboId } from "../../combos/identifiers"; +import { recallComboForLane } from "./combo-session-recall"; +import { sessionLaneIdFromRequest } from "../request-log-conversation"; + +/** `sourceModel` is the conversation's own selector before the rewrite. */ +export interface ManualCompactionOverride { + sourceModel: string; + /** Combo the lane remembers for a bare `sourceModel` (#3891); the conversation resumes there, not on the bare route. */ + sourceCombo?: string; + /** Combo the configured override resolves to; its children route concretely but stay portable. */ + targetCombo?: string; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +export interface ManualCompactionOverrideOptions { + /** `responses` requires a `compaction_trigger` input item; the native compact endpoint carries none. */ + endpoint?: "responses" | "compact"; + transport?: "websocket"; +} + +export function applyManualCompactionOverride( + body: unknown, + headers: Headers, + config: OcxConfig, + options: ManualCompactionOverrideOptions = {}, +): ManualCompactionOverride | null { + const override = config.manualCompaction; + const raw = record(body); + if (!raw || typeof raw.model !== "string" || !raw.model.trim() + || typeof override?.model !== "string" || !override.model.trim()) return null; + if (override.reasoningEffort !== undefined + && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return null; + if (options.endpoint !== "compact" + && !(Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"))) return null; + + const metadata: unknown[] = []; + const header = headers.get("x-codex-turn-metadata"); + if (options.transport !== "websocket" && header !== null) metadata.push(header); + const client = record(raw.client_metadata); + if (client && Object.hasOwn(client, "x-codex-turn-metadata")) metadata.push(client["x-codex-turn-metadata"]); + if (metadata.length === 0) return null; + for (const value of metadata) { + if (typeof value !== "string") return null; + try { + const parsed = record(JSON.parse(value)); + if (parsed?.request_kind !== "compaction" || record(parsed.compaction)?.trigger !== "manual") return null; + } catch { + return null; + } + } + + const sourceModel = raw.model; + const sourceCombo = recallComboForLane(config, sessionLaneIdFromRequest(headers), sourceModel); + const targetCombo = resolveComboId(config, override.model.trim()) ?? undefined; + raw.model = override.model.trim(); + if (override.reasoningEffort !== undefined) { + raw.reasoning = { ...record(raw.reasoning), effort: override.reasoningEffort }; + } + return { sourceModel, ...(sourceCombo ? { sourceCombo } : {}), ...(targetCombo ? { targetCombo } : {}) }; +} + +/** Same provider identity keeps caller auth and may use native compact; its ciphertext replays only there. */ +export function manualCompactionKeepsProviderIdentity( + config: OcxConfig, + override: ManualCompactionOverride, + route: RouteResult, +): boolean { + if (route.combo || override.sourceCombo || override.targetCombo || resolveComboId(config, override.sourceModel)) return false; + let source: RouteResult; + try { + source = routeConcreteModel(config, override.sourceModel); + } catch { + return false; + } + return source.providerName === route.providerName + && source.codexAccountMode === route.codexAccountMode + && source.codexAccountNamespace === route.codexAccountNamespace; +} diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index bee8357350..5c9280eebf 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -118,6 +118,7 @@ import { codexLogAccountId, } from "./core-codex-account"; import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; +import { applyManualCompactionOverride, manualCompactionKeepsProviderIdentity } from "./manual-compaction"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -148,6 +149,12 @@ export async function prepareResponsesRequest( } return decodeRequestErrorResponse(err, "responses"); } + if (!options.comboAttempt && !options.manualCompactionOverride && inboundWire === "responses") { + options.manualCompactionOverride = applyManualCompactionOverride(body, req.headers, config, { + endpoint: "responses", + transport: options.inboundTransport, + }); + } // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model. const comboRows = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body) @@ -179,7 +186,7 @@ export async function prepareResponsesRequest( } // Compaction may send the last client-visible bare model after a combo switch. // Configured selectors take precedence; otherwise recall before combo dispatch (#3891). - if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { + if (!options.comboAttempt && !options.manualCompactionOverride && body && typeof body === "object" && !Array.isArray(body)) { const rawModel = (body as { model?: unknown }).model; const rawInput = (body as { input?: unknown }).input; const isCompactionTrigger = Array.isArray(rawInput) @@ -201,7 +208,7 @@ export async function prepareResponsesRequest( // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG // LOOKUP so the check can never observe a one-candidate collapse. - if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { + if (!options.comboAttempt && !options.manualCompactionOverride && body && typeof body === "object" && !Array.isArray(body)) { const shadowIntercept = config.shadowCallIntercept; const rawShadowModel = (body as { model?: unknown }).model; if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string" @@ -380,7 +387,7 @@ export async function prepareResponsesRequest( if (!logCtx.conversationId) { logCtx.conversationId = resolvedConversationId; } - logCtx.requestedModel = parsed.modelId; + logCtx.requestedModel = options.manualCompactionOverride?.sourceModel ?? parsed.modelId; logCtx.requestedEffort = parsed.options.reasoning; // What this request may spend beyond its input, for the durable spend reservation (#4707). // Read from the caller rather than from the adapter's serialized body, because the @@ -410,7 +417,7 @@ export async function prepareResponsesRequest( : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)); const _sci = config.shadowCallIntercept; let shadowRoute: RouteResult | undefined; - if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { + if (!options.manualCompactionOverride && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!; let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix }; try { @@ -438,8 +445,12 @@ export async function prepareResponsesRequest( shadowRoute = targetRoute; } } - if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true; + if (parsed._compactionRequest === true || options.manualCompactionOverride) parsed._cursorIsolateConversation = true; route = shadowRoute ?? resolveRoute(parsed.modelId); + if (options.manualCompactionOverride && !manualCompactionKeepsProviderIdentity(config, options.manualCompactionOverride, route)) { + credentialDomainWasRewritten = true; + if (parsed._compactionRequest === true) parsed._portableCompaction = true; + } logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index 5903cb235e..f2f8154b55 100644 --- a/src/server/responses/request-sidecar-auth.ts +++ b/src/server/responses/request-sidecar-auth.ts @@ -50,7 +50,7 @@ export async function prepareResponsesSidecarAuth( let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined; const visionDescribeTerminal = options.visionDescribeTerminal === true; const routedCompaction = parsed._compactionRequest === true - && !isCanonicalOpenAiForwardProvider(route.provider); + && (!isCanonicalOpenAiForwardProvider(route.provider) || parsed._portableCompaction === true); const needsOpenAiVision = !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); const needsOpenAiSearch = !routedCompaction && !transportState.adapter.runTurn diff --git a/src/types/config.ts b/src/types/config.ts index 2c38aef8e8..35ee3fdb31 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -616,6 +616,10 @@ export interface OcxConfig { subagentEffortCap?: string; /** Global model effort overrides, after provider model/wide pins; none means omission. */ modelPinnedEfforts?: Record; + manualCompaction?: { + model: string; + reasoningEffort?: string; + }; /** * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only diff --git a/src/types/request.ts b/src/types/request.ts index a7c319ec40..73bc671c8c 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -126,6 +126,8 @@ export interface OcxParsedRequest { * (see src/responses/compaction.ts). */ _compactionRequest?: boolean; + /** Manual compaction moved to another provider: summarize portably even on a canonical ChatGPT target. */ + _portableCompaction?: boolean; /** * True when the current request newly introduced a stored compaction summary/marker. Historical * markers restored by previous_response_id expansion were already acknowledged and do not reset diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index ff8218a30e..ea9a7a68ef 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -218,3 +218,5 @@ Dashboard Fast-row persistence and client refresh follow the [Fast selector rows ## Devin image boundary The registered Devin implementation in `src/adapters/devin.ts` maps data URLs to its native image field. Its textual fallback accepts only bounded HTTPS references and emits a fixed-size omission marker for unsupported or oversized values. + +A [manual compaction override](../transports/responses.md#manual-compaction-overrides) selects its target before adapter resolution and uses the existing registry factory. diff --git a/structure/catalog.md b/structure/catalog.md index 73d05b1662..0bf4823698 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -508,3 +508,6 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). + +Manual compaction selects its configured model at Responses ingress under the +[manual compaction contract](transports/responses.md#manual-compaction-overrides). Catalog selection remains conversation-owned. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e7edb67892..2a2bad3449 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -190,3 +190,5 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +The [manual compaction override](../transports/responses.md#manual-compaction-overrides) is scoped to Codex Responses metadata and original Responses ingress; Claude Messages replay retains its own routing. diff --git a/structure/config.md b/structure/config.md index a6eb45aff9..13b94459aa 100644 --- a/structure/config.md +++ b/structure/config.md @@ -84,6 +84,7 @@ matters for maintainers is which groups exist and who resolves them: | --- | --- | --- | | Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | | Routing | `defaultProvider`, `providers`, per-provider `selectedModels`, `combos` | Explicit `provider/model` wins over `defaultProvider`; combo dispatch uses the selected target's existing capability ladder and does not create a second catalog authority. | +| Manual compaction | `manualCompaction.model`, optional `manualCompaction.reasoningEffort` | Explicit manual Codex compaction metadata activates a request-local override; see [Responses compaction](transports/responses.md#manual-compaction-overrides). Invalid hand edits disable the block with a load warning without discarding providers; candidate writes reject invalid blocks. | | Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Spend | `spend.root`, `spend.identity`, `spend.pool`, `spend.retentionDays` | Durable token ceilings for the spend-reservation ledger. Absent is the default and means observe-only accounting: spend is still journaled and nothing is refused, so observe-only and enforced servers take the same state-directory writer lease. One live process may write one directory; explicit sibling instances need separate `OPENCODEX_HOME` directories. There is no default figure for any scope — the ledger is on by default, so a shipped ceiling would refuse real traffic on upgrade against a number nobody chose. Strictly validated and positive-integer only, because 0 would read as a budget and refuse everything; a malformed section degrades to no ceiling, which is why the write path rejects it and load diagnostics report it. Resolution and application live in `src/lib/spend-reservation-ledger.ts`; see [`transports/responses.md`](transports/responses.md). | diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index b01790ff75..e6fa3fd0a7 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -133,3 +133,6 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +Image-bearing Codex history follows the selected model's existing compaction handling after a +[manual compaction override](../transports/responses.md#manual-compaction-overrides). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index e3b754f91a..7db8cd4a23 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -350,3 +350,5 @@ Native steering generation overrides, explicit public-API eligibility and the co Unicode pattern normalization uses [copy-on-write traversal](../transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +The [manual compaction override](../transports/responses.md#manual-compaction-overrides) requires original Responses ingress; translated Chat and Messages calls retain their own routing. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index e6226c5889..4f13ad3c8e 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -719,3 +719,12 @@ Shared response-log retention and native SSE inspection pacing follow the [bound Native steering retains fixed phase deadlines and reconciled replay output; see the [steering stability contract](transports/streaming-health.md#steering-deadlines-and-replay-completeness). Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +`manualCompaction` is a persisted configuration setting. Its model and optional effort follow the +[Responses trigger contract](transports/responses.md#manual-compaction-overrides). Dashboard Overview +provides model and effort selectors with an explicit Save action, a standing note that the selected +model's provider receives the entire conversation, and a warning naming that provider once a model +is chosen; for a combo selector the warning lists the combo's target providers from `GET /api/combos` +and states that failover targets receive the conversation too. `GET /api/settings` returns +the override or null; `PUT /api/settings` accepts a complete validated object or null to clear it. +Save failure restores live settings and deletion provenance; the dashboard retains the draft for retry. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 967b81ecf0..493e6f25e0 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -459,3 +459,6 @@ Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#r Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](../transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged. Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +The public server configuration reference documents the optional +[manual compaction override](../transports/responses.md#manual-compaction-overrides). Its regression file is registered in both test-layout inventories. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e2957c8473..652672959a 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -194,3 +194,6 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](../transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +The service loads the optional `manualCompaction` block from persisted configuration. +[Responses ingress](../transports/responses.md#manual-compaction-overrides) applies it to individual manual requests. diff --git a/structure/overview.md b/structure/overview.md index fb5b287d5e..5dd3cf9c7c 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -194,3 +194,6 @@ Shared response-log retention and native SSE inspection pacing follow the [bound Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). + +Manual Codex compaction can select a request-local model through the +[existing Responses handlers](transports/responses.md#manual-compaction-overrides), while subsequent turns keep their conversation settings. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 123d81a04f..d8a954b6d4 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -187,3 +187,7 @@ Native steering generation overrides, explicit public-API eligibility and the co Shared startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](../runtime.md). Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +Routed Grok compaction uses the existing adapter and summary contract after a same-provider +[manual compaction model override](../transports/responses.md#manual-compaction-overrides); a +cross-provider override runs the portable summarizer on the selected provider instead. diff --git a/structure/runtime.md b/structure/runtime.md index c34925f78a..4be3b7c430 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -472,3 +472,6 @@ Native steering retains fixed phase deadlines and reconciled replay output; see Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. Unicode pattern normalization uses [copy-on-write traversal](transports/byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. + +Manual Codex compaction uses a request-local model override when configured; the +[Responses compaction contract](transports/responses.md#manual-compaction-overrides) owns its trigger and replay boundaries. diff --git a/structure/subagents.md b/structure/subagents.md index fe034027c1..ef599a16e6 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -410,3 +410,5 @@ Native steering generation overrides, explicit public-API eligibility and the co Startup provider-id migration preserves the account binding between configuration and OAuth credentials; see the [runtime contract](runtime.md). Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](gui-and-management-api.md#fast-selector-rows-setting). + +The [manual compaction override](transports/responses.md#manual-compaction-overrides) uses explicit request-kind and trigger metadata, independently of spawned-child markers. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 60cfd444bd..218efb82fa 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -167,3 +167,5 @@ Schema size still determines traversal work and the cost of copying a changed br `tests/responses/openai-responses-passthrough.test.ts` covers the existing wire contract. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +The [manual compaction override](responses.md#manual-compaction-overrides) changes model and effort scalars on the already-read request body, before parsing, within the existing body-reader budget. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 942fa3e680..a756c46a49 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -295,3 +295,5 @@ is left to the HTTP agent, which may pool or destroy it. `tests/lib/pinned-http-content-coding.test.ts` covers both routes on the same payload. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +The [manual compaction override](responses.md#manual-compaction-overrides) selects a target before the existing native compact or routed Responses transport is resolved. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 57206ad0f2..b874f3b77c 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -903,6 +903,41 @@ The [explicit model-capability contract](../config.md#explicit-per-model-capabil Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. Unicode pattern normalization uses [copy-on-write traversal](byte-accounting.md#unicode-pattern-normalization) while preserving the existing schema and wire semantics. +## Manual compaction overrides + +`src/server/responses/manual-compaction.ts` applies `manualCompaction` before model routing in +both `request-prepare.ts` and `compact.ts`. It requires explicit `request_kind: "compaction"` +and `compaction.trigger: "manual"` in `x-codex-turn-metadata`, supplied as a header or embedded +in Responses `client_metadata`, and on `/v1/responses` a `compaction_trigger` input item as +well, so metadata alone cannot move an ordinary turn. Every supplied metadata copy must agree; +malformed, absent, automatic, and ordinary-turn metadata leave the request unchanged. WebSocket requests use +only per-frame metadata; handshake headers can describe an earlier request. + +The override changes only the model and optional reasoning effort. Existing native forwarding, +routed summaries, capability handling, and retry budgets remain authoritative; native compact +still removes reasoning before sending. Internal handoffs carry the override record (with the +conversation's source model) as a recursion guard so combo children and fallback attempts +retain their selected targets. Manual overrides bypass shadow interception and conversation +combo recall, and do not publish replacement combo/handoff recall. They never change the +conversation's configured model or later automatic-compaction requests. + +`manualCompactionKeepsProviderIdentity` compares the source model's concrete route with the +selected route (provider name, Codex account mode and namespace; combos on either side never +match, and a bare source model the lane remembers as a combo target counts as a combo source, +recorded as `sourceCombo` when the override is applied, and a configured combo target is recorded as +`targetCombo` so its concretely routed children stay portable too). A matching identity keeps the caller's credential and may use the native compact +endpoint. A mismatch marks the credential domain as rewritten, exactly like a shadow +intercept, and forces the portable summarizer even for a native-capable target: `compact.ts` +skips `/responses/compact`, and `request-prepare.ts` sets `parsed._portableCompaction`, which +`request-sidecar-auth.ts` (`routedCompaction`) and the passthrough adapter's compaction body +build both honor for canonical ChatGPT destinations. Native ciphertext is replayable only by the +backend that minted it; the conversation model would otherwise resume with an omission marker +in place of its history. + +`tests/responses/responses-manual-compaction.test.ts` covers trigger selection, config validation, +native and routed handlers, same-provider credential retention, cross-provider portable summaries +and their replay, combo failover, and subsequent conversation settings. + ## Core module ownership `src/server/responses/core.ts` is the public ingress and compatibility-export surface. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ba387f403e..cc64a064f3 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -513,3 +513,5 @@ cover effective wire settings, immutable-route refusals, policy preservation, independent API credentials, unavailable-mode diagnostics and safe probe outcomes. Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). + +WebSocket [manual compaction selection](responses.md#manual-compaction-overrides) uses per-frame metadata; handshake metadata cannot supply a later frame's trigger. diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 06eb3c41fa..13c9907c0e 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -874,3 +874,50 @@ describe("config.json schema resilience", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + +describe("manual compaction settings", () => { + test("saves, reloads, replaces effort, and clears without changing other settings", async () => { + const config = baseConfig(); + config.effortCap = "high"; + const originalProviders = structuredClone(config.providers); + expect((await (await getSettings(config))!.json()).manualCompaction).toBeNull(); + const setting = { model: "gateway/cheap", reasoningEffort: "low" }; + const response = await putSettings(config, { manualCompaction: setting }); + expect(response?.status).toBe(200); + expect((await response!.json()).manualCompaction).toEqual(setting); + expect(loadConfig().manualCompaction).toEqual(setting); + expect((await (await getSettings(config))!.json()).manualCompaction).toEqual(setting); + await putSettings(config, { manualCompaction: { model: "gateway/cheap" } }); + expect(loadConfig().manualCompaction).toEqual({ model: "gateway/cheap" }); + await putSettings(config, { manualCompaction: null }); + expect(config.manualCompaction).toBeUndefined(); + expect(loadConfig().manualCompaction).toBeUndefined(); + expect(config.effortCap).toBe("high"); + expect(config.providers).toEqual(originalProviders); + expect((await (await getSettings(config))!.json()).manualCompaction).toBeNull(); + }); + + test("rejects malformed settings before any mutation", async () => { + const config = baseConfig(); + config.manualCompaction = { model: "gateway/cheap", reasoningEffort: "low" }; + const before = structuredClone(config); + for (const value of [false, [], {}, { model: " " }, { model: 2 }, { model: "m", reasoningEffort: "invalid" }, { model: "m", enabled: true }]) { + const response = await putSettings(config, { manualCompaction: value, streamMode: "eager-relay" }); + expect(response?.status).toBe(400); + expect(config).toEqual(before); + } + }); + + test("failed persistence restores the override and its deletion intent", async () => { + const { projectConfigRebaseProvenance } = await import("../../src/config/rebase-provenance"); + const config = baseConfig(); + config.manualCompaction = { model: "gateway/cheap", reasoningEffort: "low" }; + const before = projectConfigRebaseProvenance(config); + const deps = { saveConfigPreservingClaudeCode() { throw new Error("fixture save failure"); } }; + for (const value of [null, { model: "gateway/other" }]) { + await expect(putSettings(config, { manualCompaction: value }, deps)).rejects.toThrow("fixture save failure"); + expect(projectConfigRebaseProvenance(config)).toEqual(before); + expect(config.manualCompaction).toEqual({ model: "gateway/cheap", reasoningEffort: "low" }); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d677aea796..5127119ad8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1039,6 +1039,7 @@ "responses-canonical-only-top-level-fields.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", + "responses-manual-compaction.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index a4f9ae1a9a..22e9a5fa8e 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -29,6 +29,7 @@ export const RESPONSES_CORE_MODULES = [ "core-normalize.ts", "core-combo.ts", "request-prepare.ts", + "manual-compaction.ts", "request-transport.ts", "request-sidecar-auth.ts", "response-effects.ts", diff --git a/tests/responses/responses-manual-compaction.test.ts b/tests/responses/responses-manual-compaction.test.ts new file mode 100644 index 0000000000..1c06ede7d2 --- /dev/null +++ b/tests/responses/responses-manual-compaction.test.ts @@ -0,0 +1,522 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { applyManualCompactionOverride } from "../../src/server/responses/manual-compaction"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { clearCompactHandoffRoutesForTests } from "../../src/server/responses/compact"; +import { decodeCompactionSummary, SUMMARY_PREFIX } from "../../src/responses/compaction"; +import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { warnDegradedManualCompaction } from "../../src/config/load-degrade"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboRecallForTests, recallComboForLane, rememberComboForLane } from "../../src/server/responses/combo-session-recall"; +import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +const metadata = (trigger = "manual", request_kind = "compaction") => + JSON.stringify({ request_kind, compaction: { trigger } }); + +function config(): OcxConfig { + return { + ...getDefaultConfig(), + defaultProvider: "gateway", + providers: { + gateway: { + adapter: "openai-responses", authMode: "key", + baseUrl: "https://gateway.example/v1", apiKey: "fixture-key", + }, + }, + manualCompaction: { model: "gateway/cheap", reasoningEffort: "low" }, + }; +} + +function body(compact = true): Record { + return { + model: "gateway/normal", stream: false, + reasoning: { effort: "high", summary: "auto" }, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Keep the task state." }] }, + ...(compact ? [{ type: "compaction_trigger" }] : []), + ], + }; +} + +function request(value: unknown, trigger?: string, path = "responses"): Request { + return new Request(`http://localhost/v1/${path}`, { + method: "POST", + headers: { + "content-type": "application/json", session_id: "manual-compaction-fixture", + ...(trigger ? { "x-codex-turn-metadata": metadata(trigger) } : {}), + }, + body: JSON.stringify(value), + }); +} + +function completion(summary = "Retain progress and resume the task."): Record { + return { + id: "resp_manual_fixture", status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: summary }] }], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }; +} + +function upstreamCompletion(input: Record): Response { + const response = { ...completion(), model: input.model }; + return input.stream + ? new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + : Response.json(response); +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearComboRecallForTests(); + clearCompactHandoffRoutesForTests(); +}); + +describe("manual compaction request selection", () => { + test.each(["header", "body", "both"])("uses explicit manual metadata from %s", location => { + const input = body(); + const history = structuredClone(input.input); + const headers = new Headers(); + if (location !== "body") headers.set("x-codex-turn-metadata", metadata()); + if (location !== "header") input.client_metadata = { "x-codex-turn-metadata": metadata() }; + expect(applyManualCompactionOverride(input, headers, config())).toEqual({ sourceModel: "gateway/normal" }); + expect(input.model).toBe("gateway/cheap"); + expect(input.reasoning).toEqual({ effort: "low", summary: "auto" }); + expect(input.input).toEqual(history); + }); + + test.each([ + undefined, "{", "null", "[]", metadata("auto"), metadata("manual", "turn"), + JSON.stringify({ compaction: { trigger: "manual" } }), + JSON.stringify({ request_kind: "compaction" }), + ])("does not override absent, malformed, automatic or ordinary metadata: %s", value => { + const input = body(); + const before = structuredClone(input); + const headers = new Headers(value === undefined ? {} : { "x-codex-turn-metadata": value }); + expect(applyManualCompactionOverride(input, headers, config())).toBeNull(); + expect(input).toEqual(before); + }); + + test("conflicting metadata cannot override automatic compaction", () => { + for (const [header, embedded] of [[metadata(), metadata("auto")], [metadata("auto"), metadata()], [metadata(), "{"], ["{", metadata()]]) { + const input = { ...body(), client_metadata: { "x-codex-turn-metadata": embedded } }; + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": header! }), config())).toBeNull(); + expect(input.model).toBe("gateway/normal"); + } + }); + + test("WebSocket frames use their own trigger and never reuse handshake metadata", () => { + for (const [handshake, frame, expected] of [ + ["auto", "manual", true], ["manual", "auto", false], ["manual", undefined, false], + ] as const) { + const input = body(); + if (frame) input.client_metadata = { "x-codex-turn-metadata": metadata(frame) }; + const headers = new Headers({ "x-codex-turn-metadata": metadata(handshake) }); + expect(applyManualCompactionOverride(input, headers, config(), { transport: "websocket" })).toEqual(expected ? { sourceModel: "gateway/normal" } : null); + expect(input.model).toBe(expected ? "gateway/cheap" : "gateway/normal"); + } + }); + + test("model-only configuration preserves the caller's reasoning", () => { + const input = body(); + const settings = config(); + settings.manualCompaction = { model: "gateway/cheap" }; + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toEqual({ sourceModel: "gateway/normal" }); + expect(input.reasoning).toEqual({ effort: "high", summary: "auto" }); + expect(settings.manualCompaction).toEqual({ model: "gateway/cheap" }); + }); + + test("unset configuration preserves manual compaction", () => { + const input = body(); + const before = structuredClone(input); + const settings = config(); + delete settings.manualCompaction; + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toBeNull(); + expect(input).toEqual(before); + }); + + test("manual metadata on an ordinary turn never rewrites the request", () => { + const input = body(false); + const before = structuredClone(input); + const headers = new Headers({ "x-codex-turn-metadata": metadata() }); + expect(applyManualCompactionOverride(input, headers, config())).toBeNull(); + expect(applyManualCompactionOverride(input, headers, config(), { endpoint: "responses" })).toBeNull(); + expect(input).toEqual(before); + expect(applyManualCompactionOverride(input, headers, config(), { endpoint: "compact" })).toEqual({ sourceModel: "gateway/normal" }); + expect(input.model).toBe("gateway/cheap"); + }); +}); + + +describe("manual compaction config", () => { + test("validates optional settings without resetting providers on malformed hand edits", () => { + expect(validateConfigCandidate(config()).ok).toBe(true); + for (const value of [null, {}, [], "cheap", { model: " " }, { model: 42 }, + { model: "gateway/cheap", reasoningEffort: "invalid" }, { model: "gateway/cheap", typo: true }]) { + const raw = { ...config(), manualCompaction: value }; + expect(validateConfigCandidate(raw).ok).toBe(false); + const loaded = configSchema.parse(raw); + expect(loaded.manualCompaction).toBeUndefined(); + expect(loaded.providers).toEqual(config().providers); + } + }); + + test("a dropped hand-edited block warns at load; valid or absent blocks stay silent", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (message: unknown) => { warnings.push(String(message)); }; + try { + const invalid = { ...config(), manualCompaction: { model: "gateway/cheap", reasoningEffort: "Low" } }; + warnDegradedManualCompaction(invalid, configSchema.parse(invalid) as OcxConfig); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("manualCompaction is invalid"); + warnDegradedManualCompaction(config(), configSchema.parse(config()) as OcxConfig); + const absent = config(); + delete absent.manualCompaction; + warnDegradedManualCompaction(absent, configSchema.parse(absent) as OcxConfig); + expect(warnings).toHaveLength(1); + } finally { + console.warn = original; + } + }); +}); + +describe("manual compaction reuses existing handlers", () => { + test("an ordinary turn carrying manual metadata stays on the conversation model", async () => { + const settings = config(); + const calls: string[] = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push(input.model); + return upstreamCompletion(input); + }) as typeof fetch; + const response = await handleResponses(request(body(false), "manual"), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(calls).toEqual(["normal"]); + }); + + test.each(["v1", "v2", "v2-body", "v2-websocket"])("%s changes only the manual request and returns the existing summary format", async version => { + const settings = config(); + const saved = structuredClone(settings); + const calls: Array> = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json(completion()); + }) as typeof fetch; + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const input = body(version !== "v1"); + if (version === "v2-body" || version === "v2-websocket") input.client_metadata = { "x-codex-turn-metadata": metadata() }; + const manualRequest = request(input, version === "v2-body" ? undefined : version === "v2-websocket" ? "auto" : "manual"); + const logCtx = { model: "", provider: "" } as { model: string; provider: string; requestedModel?: string }; + const response = version === "v2-websocket" + ? await handleResponses(manualRequest, settings, logCtx, { inboundTransport: "websocket" }) + : await handler(manualRequest, settings, logCtx); + const result = await response.json() as { output: Array> }; + expect(response.status).toBe(200); + expect(logCtx.requestedModel).toBe("gateway/normal"); + expect(logCtx.model).toBe("cheap"); + expect(calls[0]!.model).toBe("cheap"); + expect(calls[0]!.reasoning.effort).toBe("low"); + if (version === "v1") expect(JSON.stringify(result.output)).toContain(SUMMARY_PREFIX); + else expect(decodeCompactionSummary(result.output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + + const automatic = await handler(request(body(version !== "v1"), "auto"), settings, { model: "", provider: "" }); + expect(automatic.status).toBe(200); + await automatic.text(); + const resumedBody = body(false); + resumedBody.input = [...result.output, ...resumedBody.input]; + const resumed = await handleResponses(request(resumedBody), settings, { model: "", provider: "" }); + expect(resumed.status).toBe(200); + await resumed.text(); + expect(calls.map(call => [call.model, call.reasoning.effort])).toEqual([ + ["cheap", "low"], ["normal", "high"], ["normal", "high"], + ]); + expect(JSON.stringify(calls[2]!.input)).toContain("Retain progress"); + expect(JSON.stringify(calls[2]!.input)).not.toContain("ocx1:"); + expect(settings).toEqual(saved); + expect(input.model).toBe("gateway/normal"); + }); + + test("native v2 keeps caller authentication and forwards the existing compaction request", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.manualCompaction = { model: "gpt-5.6-luna", reasoningEffort: "low" }; + const input = body(); + input.model = "gpt-6-astra"; + input.stream = true; + const req = request(input, "manual"); + const authorization = `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`; + req.headers.set("authorization", authorization); + req.headers.set("chatgpt-account-id", "fixture-account"); + const calls: Array<{ body: Record; authorization: string | null }> = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + calls.push({ body: JSON.parse(String(init?.body)), authorization: new Headers(init?.headers).get("authorization") }); + const response = { ...completion(), model: "gpt-5.6-luna", output: [{ type: "compaction", encrypted_content: "native-summary" }] }; + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("native-summary"); + expect(calls).toHaveLength(1); + expect(calls[0]!.authorization).toBe(authorization); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(calls[0]!.body.reasoning.effort).toBe("low"); + expect(calls[0]!.body.input).toContainEqual({ type: "compaction_trigger" }); + }); + + test("same-provider native compact retains its existing endpoint and reasoning behavior", async () => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna", reasoningEffort: "low" }; + const calls: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }); + return Response.json({ output: [{ type: "compaction", encrypted_content: "native-summary" }] }); + }) as typeof fetch; + const input = { ...body(false), model: "openai-apikey/gpt-6-astra" }; + const response = await handleResponsesCompact(request(input, "manual", "responses/compact"), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ output: [{ type: "compaction", encrypted_content: "native-summary" }] }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://api.openai.com/v1/responses/compact"); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(calls[0]!.body.reasoning).toBeUndefined(); + }); + + test.each(["v1", "v2"])("%s cross-provider override produces a summary the conversation model can replay", async version => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna", reasoningEffort: "low" }; + const calls: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), body: input }); + if (String(url).endsWith("/compact")) return Response.json({ output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }); + return upstreamCompletion(input); + }) as typeof fetch; + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const response = await handler(request(body(version !== "v1"), "manual"), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const result = await response.json() as { output: Array> }; + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://api.openai.com/v1/responses"); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(calls[0]!.body.reasoning.effort).toBe("low"); + expect(JSON.stringify(result.output)).not.toContain("native-ciphertext"); + if (version === "v1") expect(JSON.stringify(result.output)).toContain(SUMMARY_PREFIX); + else expect(decodeCompactionSummary(result.output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + + const resumedBody = body(false); + resumedBody.input = [...result.output, ...resumedBody.input]; + const resumed = await handleResponses(request(resumedBody), settings, { model: "", provider: "" }); + expect(resumed.status).toBe(200); + await resumed.text(); + expect(calls[1]!.url).toBe("https://gateway.example/v1/responses"); + expect(calls[1]!.body.model).toBe("normal"); + expect(JSON.stringify(calls[1]!.body.input)).toContain("Retain progress"); + expect(JSON.stringify(calls[1]!.body.input)).not.toContain("cannot read"); + }); + + test("same-provider override keeps a caller-supplied bearer; a cross-provider override drops it", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.manualCompaction = { model: "gpt-5.6-luna", reasoningEffort: "low" }; + const seen: Array = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("authorization")); + return upstreamCompletion(JSON.parse(String(init?.body))); + }) as typeof fetch; + for (const [sourceModel, expectedStatus] of [["gpt-6-astra", 200], ["gateway/normal", 401]] as const) { + const input = { ...body(), model: sourceModel, stream: true }; + const req = request(input, "manual"); + req.headers.set("authorization", "Bearer opaque-caller-token"); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(expectedStatus); + await response.text(); + } + expect(seen).toEqual(["Bearer opaque-caller-token"]); + }); + + test("a ChatGPT target for a routed conversation runs the portable summarizer instead of native compaction", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.manualCompaction = { model: "gpt-5.6-luna", reasoningEffort: "low" }; + const calls: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), body: input }); + if (String(url).endsWith("/compact") || JSON.stringify(input.input).includes("compaction_trigger")) { + const response = { ...completion(), model: input.model, output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }; + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return upstreamCompletion(input); + }) as typeof fetch; + const jwt = `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`; + for (const version of ["v1", "v2"] as const) { + calls.length = 0; + const req = request(body(version === "v2"), "manual", version === "v1" ? "responses/compact" : "responses"); + req.headers.set("authorization", jwt); + req.headers.set("chatgpt-account-id", "fixture-account"); + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const response = await handler(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("native-ciphertext"); + const output = (JSON.parse(text) as { output: Array> }).output; + if (version === "v1") expect(JSON.stringify(output)).toContain(SUMMARY_PREFIX); + else expect(decodeCompactionSummary(output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(JSON.stringify(calls[0]!.body.input)).not.toContain("compaction_trigger"); + + const resumedBody = body(false); + resumedBody.input = [...output, ...resumedBody.input]; + const resumed = await handleResponses(request(resumedBody), settings, { model: "", provider: "" }); + expect(resumed.status).toBe(200); + await resumed.text(); + expect(calls[1]!.url).toBe("https://gateway.example/v1/responses"); + expect(JSON.stringify(calls[1]!.body.input)).toContain("Retain progress"); + } + }); + + test("manual quota failure cannot borrow the conversation's automatic handoff target", async () => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna" }; + const calls: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push(input.model); + return String(url).endsWith("/compact") + ? Response.json({ error: { message: "quota exceeded", code: "insufficient_quota" } }, { status: 429 }) + : upstreamCompletion(input); + }) as typeof fetch; + const seed = await handleResponsesCompact(request(body(false), "auto"), settings, { model: "", provider: "" }); + expect(seed.status).toBe(200); + await seed.text(); + const manual = await handleResponsesCompact(request({ ...body(false), model: "openai-apikey/gpt-6-astra" }, "manual"), settings, { model: "", provider: "" }); + expect(manual.status).toBe(429); + await manual.text(); + expect(calls).toEqual(["normal", "gpt-5.6-luna"]); + + const automatic = await handleResponsesCompact(request({ ...body(false), model: "openai-apikey/gpt-6-astra" }, "auto"), settings, { model: "", provider: "" }); + expect(automatic.status).toBe(200); + await automatic.text(); + expect(calls).toEqual(["normal", "gpt-5.6-luna", "gpt-6-astra", "normal"]); + }); + + test.each(["v1", "v2"])("%s combo override preserves failover and the conversation's remembered combo", async version => { + const settings = config(); + settings.combos = { + normal: { targets: [{ provider: "gateway", model: "normal" }] }, + compact: { strategy: "failover", targets: [{ provider: "gateway", model: "unavailable" }, { provider: "gateway", model: "cheap" }] }, + }; + settings.manualCompaction = { model: "combo/compact", reasoningEffort: "low" }; + const req = request(body(version !== "v1"), "manual"); + const lane = sessionLaneIdFromRequest(req.headers); + rememberComboForLane(lane, "normal", { provider: "gateway", model: "normal" }, "normal", captureConfigGeneration()); + const calls: string[] = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const model = JSON.parse(String(init?.body)).model; + calls.push(model); + if (model === "unavailable") return Response.json({ error: { + type: "invalid_request_error", code: "unsupported_value", param: "reasoning.effort", + message: "Unsupported value: 'low' is not supported with this model. Supported values are: 'medium', 'high'.", + } }, { status: 400 }); + return upstreamCompletion(JSON.parse(String(init?.body))); + }) as typeof fetch; + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const response = await handler(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(calls).toEqual(["unavailable", "cheap"]); + expect(recallComboForLane(settings, lane, "normal")).toBe("normal"); + }); + + test("a bare source model remembered as a combo target takes the portable path even on a same-provider native override", async () => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.combos = { fast: { targets: [{ provider: "gateway", model: "normal" }] } }; + settings.defaultProvider = "openai-apikey"; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna", reasoningEffort: "low" }; + const req = request({ ...body(false), model: "normal" }, "manual", "responses/compact"); + const lane = sessionLaneIdFromRequest(req.headers); + rememberComboForLane(lane, "fast", { provider: "gateway", model: "normal" }, "normal", captureConfigGeneration()); + const calls: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + calls.push(String(url)); + if (String(url).endsWith("/compact")) return Response.json({ output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }); + return upstreamCompletion(JSON.parse(String(init?.body))); + }) as typeof fetch; + const response = await handleResponsesCompact(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("native-ciphertext"); + expect(text).toContain(SUMMARY_PREFIX); + expect(calls).toEqual(["https://api.openai.com/v1/responses"]); + expect(recallComboForLane(settings, lane, "normal")).toBe("fast"); + }); + + test("a combo override whose same-provider child is canonical ChatGPT still runs the portable summarizer", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.combos = { compact: { targets: [{ provider: "openai", model: "gpt-5.6-luna" }] } }; + settings.manualCompaction = { model: "combo/compact", reasoningEffort: "low" }; + const calls: Array<{ url: string; input: string }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), input: JSON.stringify(input.input) }); + if (calls.at(-1)!.input.includes("compaction_trigger")) { + const response = { ...completion(), model: input.model, output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }; + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return upstreamCompletion(input); + }) as typeof fetch; + const req = request({ ...body(), model: "gpt-6-astra" }, "manual"); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + req.headers.set("chatgpt-account-id", "fixture-account"); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("native-ciphertext"); + const output = (JSON.parse(text) as { output: Array> }).output; + expect(decodeCompactionSummary(output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(calls[0]!.input).not.toContain("compaction_trigger"); + }); +}); From c8de003b5365f835b76af7211055d4c89172d358 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Sun, 20 Sep 2026 02:44:26 +0900 Subject: [PATCH 2/6] feat(responses): cover automatic compaction with the same routing override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames `manualCompaction` to `compactionRouting` and adds `triggers`, the set of Codex `compaction.trigger` values the override covers. Omitting `triggers` means `["manual"]`, so a block written for the previous key behaves exactly as before and automatic compaction keeps routing where it routes today. #5012 asks for an explicitly routed compaction provider when the canonical OpenAI quota is exhausted. The reported failure is a thread resume that enters PreCompact, so the request the proxy rejects with 429 is an automatic compaction, not a manual `/compact`. codex-rs builds that turn in `compact_remote_v2::run_inline_remote_auto_compact_task` with `CompactionTrigger::Auto` and hands it to the same `run_remote_compact_task_inner` the manual `CompactTask` uses, so it reaches the identical surfaces — a `compaction_trigger` item on `/v1/responses`, or `/v1/responses/compact` — and differs only in the trigger string. The previous commit's gate required `"manual"` exactly, so it could never fire for the reported case. That makes one setting the right shape rather than two. `routeCompactionModel` reserves a bare native compaction model for an enabled canonical `openai` provider and releases it only when none is configured (#2901), never on quota exhaustion. Naming `"auto"` points the compaction at a provider-qualified model with its own credentials, which is the whole of the request; a second config block would have duplicated the model, effort, combo and portable-summary handling already here. Trigger metadata copies must now agree on which trigger they carry, not merely that the request is a compaction, so a caller cannot widen an override by disagreeing with itself. A `triggers` value the schema would reject disables the block instead of widening it, matching how a malformed `model` or `reasoningEffort` already behaves. `warnDegradedCompactionRouting` moves behind `warnDegradedTopLevelOptIns` so `loadConfig` gains no line: `src/config.ts` sits at its 460-line cap, and the previous commit stayed under it by folding two statements onto one line. Co-authored-by: nahuelb --- .../docs/reference/configuration/server.md | 48 ++++-- ...onPanel.tsx => CompactionRoutingPanel.tsx} | 103 +++++++++---- gui/src/i18n/de.ts | 33 ++-- gui/src/i18n/en.ts | 33 ++-- gui/src/i18n/fr.ts | 33 ++-- gui/src/i18n/ja.ts | 33 ++-- gui/src/i18n/ko.ts | 33 ++-- gui/src/i18n/ru.ts | 33 ++-- gui/src/i18n/tr.ts | 33 ++-- gui/src/i18n/zh-TW.ts | 33 ++-- gui/src/i18n/zh.ts | 33 ++-- gui/src/pages/dashboard-overview-panels.tsx | 4 +- ....tsx => compaction-routing-panel.test.tsx} | 53 +++++-- scripts/test-layout/layout.json | 2 +- src/config.ts | 4 +- src/config/diagnostics.ts | 8 +- src/config/load-degrade.ts | 18 ++- src/config/schema/config-schema.ts | 4 +- src/config/schema/leaf-validators.ts | 12 +- src/server/management/config-routes.ts | 30 ++-- src/server/responses/compact.ts | 32 ++-- ...al-compaction.ts => compaction-routing.ts} | 43 ++++-- src/server/responses/core-combo.ts | 2 +- src/server/responses/core-options.ts | 4 +- src/server/responses/request-prepare.ts | 18 +-- src/types/config.ts | 4 +- structure/adapters/registry.md | 2 +- structure/catalog.md | 4 +- structure/clients/claude-desktop.md | 2 +- structure/config.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 4 +- structure/ops/docs-and-release.md | 2 +- structure/ops/service-and-sidecars.md | 4 +- structure/overview.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 35 +++-- structure/transports/streaming-health.md | 2 +- tests/config/settings-stream-mode.test.ts | 38 ++--- tests/fixtures/test-layout-expected.json | 2 +- tests/helpers/responses-core-source.ts | 2 +- ... => responses-compaction-override.test.ts} | 142 ++++++++++++++---- 47 files changed, 607 insertions(+), 336 deletions(-) rename gui/src/components/{ManualCompactionPanel.tsx => CompactionRoutingPanel.tsx} (54%) rename gui/tests/{manual-compaction-panel.test.tsx => compaction-routing-panel.test.tsx} (68%) rename src/server/responses/{manual-compaction.ts => compaction-routing.ts} (64%) rename tests/responses/{responses-manual-compaction.test.ts => responses-compaction-override.test.ts} (77%) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e733f07b7b..b0793bba96 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -494,20 +494,21 @@ 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 +## Compaction routing -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. +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 `manualCompaction` in OpenCodex `config.json` to override the model used by Codex's -manual `/compact` command. The setting is disabled when omitted. +Set `compactionRouting` in OpenCodex `config.json` to override the model Codex's compaction +requests use. The setting is disabled when omitted. ```json { - "manualCompaction": { + "compactionRouting": { "model": "provider/model-id", - "reasoningEffort": "low" + "reasoningEffort": "low", + "triggers": ["manual"] } } ``` @@ -518,17 +519,33 @@ 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. +`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 @@ -536,8 +553,7 @@ use that backend's native compact endpoint. Otherwise, including when either sid 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 +input size and content. Restart the proxy after editing `config.json` by hand. Dashboard saves apply immediately. ## Shadow calls diff --git a/gui/src/components/ManualCompactionPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx similarity index 54% rename from gui/src/components/ManualCompactionPanel.tsx rename to gui/src/components/CompactionRoutingPanel.tsx index cfcb411a32..e228589144 100644 --- a/gui/src/components/ManualCompactionPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -6,8 +6,31 @@ 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; +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; @@ -24,26 +47,35 @@ function readComboProviders(payload: unknown): Record { return result; } -function readSetting(payload: { manualCompaction?: unknown }): Setting { - const value = payload.manualCompaction; +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"); - return { model: value.model, ...(effort ? { reasoningEffort: effort as string } : {}) }; + 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 ManualCompactionPanel(props: { apiBase: string; models: ModelInfo[] }) { - return ; +export default function CompactionRoutingPanel(props: { apiBase: string; models: ModelInfo[] }) { + return ; } -function ManualCompactionControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) { +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); @@ -55,6 +87,7 @@ function ManualCompactionControls({ apiBase, models }: { apiBase: string; models setSaved(value); setModel(value?.model ?? ""); setEffort(value?.reasoningEffort ?? ""); + setTriggers(triggersToChoice(value?.triggers)); }, []); const load = useCallback(async () => { @@ -98,7 +131,15 @@ function ManualCompactionControls({ apiBase, models }: { apiBase: string; models const response = await fetch(`${apiBase}/api/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ manualCompaction: model ? { model, ...(effort ? { reasoningEffort: effort } : {}) } : null }), + body: JSON.stringify({ + compactionRouting: model + ? { + model, + ...(effort ? { reasoningEffort: effort } : {}), + ...(choiceToTriggers(triggers) ? { triggers: choiceToTriggers(triggers) } : {}), + } + : null, + }), signal: request.signal, }); const value = readSetting(await requireJson(response)); @@ -115,32 +156,39 @@ function ManualCompactionControls({ apiBase, models }: { apiBase: string; models } }; - const options = [{ value: "", label: t("manualCompact.currentModel") }, + 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 ?? ""); + 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("manualCompact.comboProvidersUnknown"); + const providers = comboProviders[combo]?.join(", ") || t("compactionRouting.comboProvidersUnknown"); + const routesAutomatic = triggers !== "manual"; return ( -
+
-
{t("manualCompact.title")}
-
{t("manualCompact.description")}
-
{t("manualCompact.dataNotice")}
-
{t("manualCompact.effortHint")}
+
{t("compactionRouting.title")}
+
{t("compactionRouting.description")}
+
{t("compactionRouting.dataNotice")}
+
{t("compactionRouting.effortHint")}
- ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]} + ({ value, label: t(TRIGGER_LABELS[value]!) }))} + onChange={value => { setTriggers(value); setFeedback(null); }} /> +