From 0960b6e878942f22e2c73b36e9d5aa415db09a1c Mon Sep 17 00:00:00 2001 From: nahuelb Date: Wed, 16 Sep 2026 17:34:44 -0300 Subject: [PATCH 1/7] feat: configure manual compaction model and reasoning from dashboard --- .../docs/reference/configuration/server.md | 34 ++ gui/src/components/ManualCompactionPanel.tsx | 132 ++++++++ gui/src/i18n/de.ts | 10 + gui/src/i18n/en.ts | 10 + gui/src/i18n/fr.ts | 10 + gui/src/i18n/ja.ts | 10 + gui/src/i18n/ko.ts | 10 + gui/src/i18n/ru.ts | 10 + gui/src/i18n/tr.ts | 10 + gui/src/i18n/zh-TW.ts | 10 + gui/src/i18n/zh.ts | 10 + gui/src/pages/dashboard-overview-panels.tsx | 2 + gui/tests/manual-compaction-panel.test.tsx | 116 +++++++ scripts/test-layout/layout.json | 1 + src/config/diagnostics.ts | 5 + 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 | 17 +- src/server/responses/core-combo.ts | 2 +- src/server/responses/core-options.ts | 1 + src/server/responses/manual-compaction.ts | 39 +++ src/server/responses/request-prepare.ts | 14 +- src/types/config.ts | 4 + 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 | 6 + structure/ops/docs-and-release.md | 3 + structure/ops/service-and-sidecars.md | 3 + structure/overview.md | 3 + structure/providers/xai-grok.md | 3 + structure/runtime.md | 3 + structure/subagents.md | 2 + structure/transports/byte-accounting.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 19 ++ structure/transports/streaming-health.md | 2 + tests/config/settings-stream-mode.test.ts | 46 +++ tests/fixtures/test-layout-expected.json | 1 + tests/helpers/responses-core-source.ts | 1 + .../responses-manual-compaction.test.ts | 305 ++++++++++++++++++ 45 files changed, 884 insertions(+), 14 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 3aaba75fc0..113a7140a8 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -460,6 +460,40 @@ 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. 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 override reuses the existing compaction handlers and summary formats. 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..fce9d6ca37 --- /dev/null +++ b/gui/src/components/ManualCompactionPanel.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +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 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 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); + } 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 ?? ""); + + return ( +
+ {t("manualCompact.title")} +

{t("manualCompact.description")}

+
+
+ + ({ value, label: value }))]} + onChange={value => { setEffort(value); setFeedback(null); }} /> +
+ +
+

{t("manualCompact.effortHint")}

+ {loadError &&
{t("manualCompact.loadFailed")}
} + {feedback &&
{t(feedback === "saved" ? "manualCompact.saved" : "manualCompact.saveFailed")}
} +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dd5ca2633c..120e2ba49c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -345,6 +345,16 @@ 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.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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6723d74b81..7a706cd584 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -363,6 +363,16 @@ 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.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.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 43d956906f..229eb23fff 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -353,6 +353,16 @@ 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": "Compression manuelle", + "manualCompact.description": "Choisissez un modèle pour les commandes /compact manuelles. La compression automatique et les messages suivants conservent les paramètres de la conversation.", + "manualCompact.model": "Modèle de compression", + "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 compression le prend en charge. Le modèle doit accepter toute la conversation.", + "manualCompact.loadFailed": "Impossible de charger les paramètres de compression.", + "manualCompact.saved": "Paramètres de compression 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é.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d60148bb60..1434b85690 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -354,6 +354,16 @@ 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.loadFailed": "圧縮設定を読み込めませんでした。", + "manualCompact.saved": "圧縮設定を保存しました。", + "manualCompact.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", "dash.shadowCallIntercept": "シャドウコール傍受", "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", "dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index be26daf40e..b363094d61 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -349,6 +349,16 @@ 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.loadFailed": "압축 설정을 불러올 수 없습니다.", + "manualCompact.saved": "압축 설정을 저장했습니다.", + "manualCompact.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다.", "dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9691f8a49f..ce07b1bfc1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -354,6 +354,16 @@ 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.loadFailed": "Не удалось загрузить настройки сжатия.", + "manualCompact.saved": "Настройки сжатия сохранены.", + "manualCompact.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.", "dash.shadowCallIntercept": "Перехват теневых вызовов", "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель.", "dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 95336fa32f..87f69bf78a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -355,6 +355,16 @@ 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 sıkıştırma", + "manualCompact.description": "Manuel /compact komutları için bir model seçin. Otomatik sıkıştırma ve sonraki mesajlar konuşma ayarlarını korur.", + "manualCompact.model": "Sıkıştırma 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, sıkıştırma uç noktası destekliyorsa uygulanır. Model tüm konuşmayı kabul edebilmelidir.", + "manualCompact.loadFailed": "Sıkıştırma ayarları yüklenemedi.", + "manualCompact.saved": "Sıkıştırma 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.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 19f1e50ce2..e60b788abc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -244,6 +244,16 @@ 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.loadFailed": "無法載入壓縮設定。", + "manualCompact.saved": "壓縮設定已儲存。", + "manualCompact.saveFailed": "儲存失敗。變更仍然保留,請重試。", "dash.shadowCallIntercept": "影子呼叫攔截", "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", "dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e081d027f3..cb67eb6d8b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -349,6 +349,16 @@ 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.loadFailed": "无法加载压缩设置。", + "manualCompact.saved": "压缩设置已保存。", + "manualCompact.saveFailed": "保存失败。更改仍然保留,请重试。", "dash.shadowCallIntercept": "影子调用拦截", "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。", "dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。", 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/manual-compaction-panel.test.tsx b/gui/tests/manual-compaction-panel.test.tsx new file mode 100644 index 0000000000..136b279239 --- /dev/null +++ b/gui/tests/manual-compaction-panel.test.tsx @@ -0,0 +1,116 @@ +/** @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" }]; + +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 (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); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e96e059e31..7bdb6cff22 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1178,6 +1178,7 @@ "responses-account-label.test.ts": "responses", "responses-canonical-only-top-level-fields.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/config/diagnostics.ts b/src/config/diagnostics.ts index 28a645605d..81d1c5e844 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -58,6 +58,7 @@ import { quotaResetNotifySchema, remoteGuiConfigSchema, runtimeRoleSchema, + manualCompactionSchema, } from "./schema/leaf-validators"; export type ConfigDiagnostics = { @@ -542,6 +543,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/schema/config-schema.ts b/src/config/schema/config-schema.ts index ea8f7f72e8..5cd8311a06 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -20,6 +20,7 @@ import { CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, codexAccountNamespacesSchema, modelPinnedEffortsSchema, + manualCompactionSchema, modelPreferHostedToolsConfigError, providerModelCostsConfigError, providerRelativeSendPathConfigError, @@ -124,6 +125,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 b0ace501c5..af5e147242 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -40,6 +40,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 75a106cd84..af31a419f8 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"; @@ -338,6 +340,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise void; @@ -568,6 +570,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.manualCompactionApplied) { + options = { ...options, manualCompactionApplied: applyManualCompactionOverride(body, req.headers, config) }; + } 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"); @@ -586,7 +591,7 @@ export async function handleResponsesCompact( // 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.manualCompactionApplied && typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow && !resolveComboId(config, compactModel)) { const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel); if (recalledComboId) { @@ -1254,9 +1259,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); + if (!options.manualCompactionApplied) forgetCompactHandoffRoute(req); rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); - } else if (quotaFailure && !storedPool401ReplayAttempted) { + } else if (!options.manualCompactionApplied && quotaFailure && !storedPool401ReplayAttempted) { const fallbackModel = compactHandoffRoute(req, raw.model); if (fallbackModel && !req.signal.aborted) { const fallbackReq = new Request(req.url, { @@ -1319,7 +1324,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, manualCompactionApplied: options.manualCompactionApplied, ...(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")) { @@ -1389,7 +1394,7 @@ export async function handleResponsesCompact( const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, }); - rememberCompactHandoffRoute(req, raw.model); + if (!options.manualCompactionApplied) rememberCompactHandoffRoute(req, raw.model); return result; } const encrypted = compactionItems[0]!.encrypted_content; @@ -1400,6 +1405,6 @@ export async function handleResponsesCompact( } const summary = decoded; const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary); - rememberCompactHandoffRoute(req, raw.model); + if (!options.manualCompactionApplied) rememberCompactHandoffRoute(req, 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 8cb50db821..37f6a855fb 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.manualCompactionApplied && 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..83327832d5 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -105,6 +105,7 @@ export interface HandleResponsesOptions { callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + manualCompactionApplied?: boolean; /** 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..f5f5244e6d --- /dev/null +++ b/src/server/responses/manual-compaction.ts @@ -0,0 +1,39 @@ +import type { OcxConfig } from "../../types"; +import { isDeclaredReasoningEffort } from "../../reasoning-effort"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +export function applyManualCompactionOverride(body: unknown, headers: Headers, config: OcxConfig, transport?: "websocket"): boolean { + 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 false; + if (override.reasoningEffort !== undefined + && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return false; + + const metadata: unknown[] = []; + const header = headers.get("x-codex-turn-metadata"); + if (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 false; + for (const value of metadata) { + if (typeof value !== "string") return false; + try { + const parsed = record(JSON.parse(value)); + if (parsed?.request_kind !== "compaction" || record(parsed.compaction)?.trigger !== "manual") return false; + } catch { + return false; + } + } + + raw.model = override.model.trim(); + if (override.reasoningEffort !== undefined) { + raw.reasoning = { ...record(raw.reasoning), effort: override.reasoningEffort }; + } + return true; +} diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 382ad7faa8..cd61606f8b 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -117,6 +117,7 @@ import { codexLogAccountId, } from "./core-codex-account"; import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; +import { applyManualCompactionOverride } from "./manual-compaction"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -147,6 +148,9 @@ export async function prepareResponsesRequest( } return decodeRequestErrorResponse(err, "responses"); } + if (!options.comboAttempt && !options.manualCompactionApplied && inboundWire === "responses") { + options.manualCompactionApplied = applyManualCompactionOverride(body, req.headers, config, 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) @@ -178,7 +182,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.manualCompactionApplied && 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) @@ -200,7 +204,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.manualCompactionApplied && 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" @@ -388,7 +392,7 @@ export async function prepareResponsesRequest( logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier); let route: RouteResult; - let credentialDomainWasRewritten = false; + let credentialDomainWasRewritten = options.manualCompactionApplied === true; try { // A `compaction_trigger` turn may name a bare native model the operator has // no canonical OpenAI route for (#2901). Only the initial compaction route @@ -401,7 +405,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.manualCompactionApplied && _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 { @@ -429,7 +433,7 @@ export async function prepareResponsesRequest( shadowRoute = targetRoute; } } - if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true; + if (parsed._compactionRequest === true || options.manualCompactionApplied) parsed._cursorIsolateConversation = true; route = shadowRoute ?? resolveRoute(parsed.modelId); logCtx.routeDecision = route.routeDecision; } catch (err) { diff --git a/src/types/config.ts b/src/types/config.ts index 59cdc64d29..fbd781d783 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -591,6 +591,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/structure/adapters/registry.md b/structure/adapters/registry.md index 44ecf77b6f..bf6f005ba8 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -193,3 +193,5 @@ 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. + +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 4926f34917..9e8aa3b433 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -444,3 +444,6 @@ 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. + +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 a1cdfad714..7a40e2bbde 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -183,3 +183,5 @@ 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. + +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 7103bfe73b..89c0f73f20 100644 --- a/structure/config.md +++ b/structure/config.md @@ -72,6 +72,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 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. | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 58e6227af5..7434b1f7a8 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -131,3 +131,6 @@ 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. + +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 03e343c54f..722d012afc 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -341,3 +341,5 @@ 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. + +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 8d7f5edf98..413a8a1bcd 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -662,3 +662,9 @@ 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. `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 1aa51abe58..11e1da2924 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -430,3 +430,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 c6597b7a8e..03bee3fd3b 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -192,3 +192,6 @@ 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. + +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 695869ab63..84f4046eff 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -162,3 +162,6 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( 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. + +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 1043931311..f4bd6004b1 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -159,3 +159,6 @@ 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. + +Routed Grok compaction uses the existing adapter and summary contract after any +[manual compaction model override](../transports/responses.md#manual-compaction-overrides). diff --git a/structure/runtime.md b/structure/runtime.md index 00131f381c..f37810ac8c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -546,3 +546,6 @@ defines identity, unknown records, and aggregation boundaries. 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. + +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 3ee59b82c6..b34ebe8cf3 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -394,3 +394,5 @@ 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. + +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 fa7166b108..1a9d962d53 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -131,3 +131,5 @@ The same focused tests cover these lifecycle paths and Unicode code-unit limit b 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](streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +The [manual compaction override](responses.md#manual-compaction-overrides) changes model and effort scalars on the already parsed request, within the existing body-reader budget. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 10670c2eb1..6c4d0a4687 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -160,3 +160,5 @@ 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](streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +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 9963bc058e..50a2e6f419 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -802,6 +802,25 @@ 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. +## 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`. 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 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. + +`tests/responses/responses-manual-compaction.test.ts` covers trigger selection, config validation, +native and routed handlers, summary 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 c4e6e1f7a7..a626a0cce9 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -470,3 +470,5 @@ Codex App/CLI UI certification. The fixture suite also exercises real loopback s `tests/responses/ws-steering-completion.test.ts` and `ws-steering-smoke.test.ts` cover effective wire settings, immutable-route refusals, policy preservation, independent API credentials, unavailable-mode diagnostics and safe probe outcomes. + +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..eb442805f3 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -874,3 +874,49 @@ 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); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3f87179c7e..b15ac9280a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1005,6 +1005,7 @@ "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.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..eecb376c27 --- /dev/null +++ b/tests/responses/responses-manual-compaction.test.ts @@ -0,0 +1,305 @@ +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 { 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())).toBe(true); + 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())).toBe(false); + 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())).toBe(false); + 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(), "websocket")).toBe(expected); + 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)).toBe(true); + 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)).toBe(false); + expect(input).toEqual(before); + }); +}); + +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); + } + }); +}); + +describe("manual compaction reuses existing handlers", () => { + 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 response = version === "v2-websocket" + ? await handleResponses(manualRequest, settings, { model: "", provider: "" }, { inboundTransport: "websocket" }) + : await handler(manualRequest, settings, { model: "", provider: "" }); + const result = await response.json() as { output: Array> }; + expect(response.status).toBe(200); + 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("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 response = await handleResponsesCompact(request(body(false), "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("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), "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"); + }); +}); From b9c4ea9aac06f23d18b03a7ad7ceff95a7767ed8 Mon Sep 17 00:00:00 2001 From: nahuelb Date: Thu, 17 Sep 2026 04:09:40 -0300 Subject: [PATCH 2/7] fix(responses): keep manual compaction summaries replayable and caller auth inside one provider A manual override onto a different provider now runs the portable summarizer instead of the native compact endpoint, so the conversation model can replay the summary. Caller credentials are stripped only when the override crosses provider identity. --- .../docs/reference/configuration/server.md | 11 +- src/adapters/openai-responses/passthrough.ts | 2 +- src/server/responses/compact.ts | 31 +++-- src/server/responses/core-combo.ts | 2 +- src/server/responses/core-options.ts | 3 +- src/server/responses/manual-compaction.ts | 47 +++++-- src/server/responses/request-prepare.ts | 20 +-- src/server/responses/request-sidecar-auth.ts | 2 +- src/types/request.ts | 2 + structure/transports/responses.md | 23 +++- .../responses-manual-compaction.test.ts | 126 ++++++++++++++++-- 11 files changed, 221 insertions(+), 48 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 113a7140a8..bdffa5096c 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -490,9 +490,14 @@ keep their original routing and settings. Missing, malformed, or conflicting met 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 override reuses the existing compaction handlers and summary formats. 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. +The override reuses the existing compaction handlers and summary formats. When the selected +model lives on the same provider as the conversation model, the request keeps the caller's +credential and may use that backend's native compact endpoint. When it lives on a different +provider, 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 diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 1184ac6a1c..707da6dca8 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -404,7 +404,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/server/responses/compact.ts b/src/server/responses/compact.ts index 2ec14b1e25..547c7651a2 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -120,7 +120,11 @@ import { decideTier, tierValueAfterDecision } from "../../providers/fastwire"; import { fastPolicyForModel } from "../../providers/service-tier"; import { parseFastOnlyRowId } from "../fast-row"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; -import { applyManualCompactionOverride } from "./manual-compaction"; +import { + applyManualCompactionOverride, + manualCompactionKeepsProviderIdentity, + type ManualCompactionOverride, +} from "./manual-compaction"; import { isUsageDebugEnabled } from "../../usage/debug"; import { readJsonRequestBody, @@ -237,7 +241,7 @@ function compactHandoffRoute(req: Request, previousModel: string, now = Date.now } export interface HandleResponsesCompactOptions { - manualCompactionApplied?: boolean; + manualCompactionOverride?: ManualCompactionOverride | null; nativeMainRefreshDependencies?: NativeMainRefreshDependencies; /** Release the listener's idle guard only after the complete request body is accepted. */ onRequestBodyRead?: () => void; @@ -570,8 +574,8 @@ export async function handleResponsesCompact( if (!body || typeof body !== "object" || Array.isArray(body)) { return formatErrorResponse(400, "invalid_request_error", "Invalid compaction request body"); } - if (!options.manualCompactionApplied) { - options = { ...options, manualCompactionApplied: applyManualCompactionOverride(body, req.headers, config) }; + if (!options.manualCompactionOverride) { + options = { ...options, manualCompactionOverride: applyManualCompactionOverride(body, req.headers, config) }; } const raw = body as { model?: unknown; input?: unknown }; if (typeof raw.model !== "string" || raw.model.length === 0) { @@ -591,7 +595,7 @@ export async function handleResponsesCompact( // Recall the last completed client-visible bare model after a combo switch (#3891). // Configured selectors take precedence over this implicit session hint. - if (!options.manualCompactionApplied && 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) { @@ -703,7 +707,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"); } @@ -1259,9 +1268,9 @@ export async function handleResponsesCompact( // synthetic buffer errors are not upstream bodies and stay uninspected. if (buffered.ok) { inspectResponseLogJson(logCtx, await buffered.clone().text()); - if (!options.manualCompactionApplied) forgetCompactHandoffRoute(req); + if (!options.manualCompactionOverride) forgetCompactHandoffRoute(req); rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); - } else if (!options.manualCompactionApplied && quotaFailure && !storedPool401ReplayAttempted) { + } else if (!options.manualCompactionOverride && quotaFailure && !storedPool401ReplayAttempted) { const fallbackModel = compactHandoffRoute(req, raw.model); if (fallbackModel && !req.signal.aborted) { const fallbackReq = new Request(req.url, { @@ -1324,7 +1333,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, manualCompactionApplied: options.manualCompactionApplied, ...(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")) { @@ -1394,7 +1403,7 @@ export async function handleResponsesCompact( const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, }); - if (!options.manualCompactionApplied) rememberCompactHandoffRoute(req, raw.model); + if (!options.manualCompactionOverride) rememberCompactHandoffRoute(req, raw.model); return result; } const encrypted = compactionItems[0]!.encrypted_content; @@ -1405,6 +1414,6 @@ export async function handleResponsesCompact( } const summary = decoded; const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary); - if (!options.manualCompactionApplied) rememberCompactHandoffRoute(req, raw.model); + if (!options.manualCompactionOverride) rememberCompactHandoffRoute(req, 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 37f6a855fb..dc1fe1726a 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 (!options.manualCompactionApplied && 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 83327832d5..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,7 +106,7 @@ export interface HandleResponsesOptions { callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; - manualCompactionApplied?: 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 index f5f5244e6d..dcdaae0300 100644 --- a/src/server/responses/manual-compaction.ts +++ b/src/server/responses/manual-compaction.ts @@ -1,5 +1,12 @@ import type { OcxConfig } from "../../types"; import { isDeclaredReasoningEffort } from "../../reasoning-effort"; +import { routeConcreteModel, type RouteResult } from "../../router"; +import { resolveComboId } from "../../combos/identifiers"; + +/** `sourceModel` is the conversation's own selector before the rewrite. */ +export interface ManualCompactionOverride { + sourceModel: string; +} function record(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -7,33 +14,57 @@ function record(value: unknown): Record | undefined { : undefined; } -export function applyManualCompactionOverride(body: unknown, headers: Headers, config: OcxConfig, transport?: "websocket"): boolean { +export function applyManualCompactionOverride( + body: unknown, + headers: Headers, + config: OcxConfig, + transport?: "websocket", +): 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 false; + || typeof override?.model !== "string" || !override.model.trim()) return null; if (override.reasoningEffort !== undefined - && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return false; + && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return null; const metadata: unknown[] = []; const header = headers.get("x-codex-turn-metadata"); if (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 false; + if (metadata.length === 0) return null; for (const value of metadata) { - if (typeof value !== "string") return false; + if (typeof value !== "string") return null; try { const parsed = record(JSON.parse(value)); - if (parsed?.request_kind !== "compaction" || record(parsed.compaction)?.trigger !== "manual") return false; + if (parsed?.request_kind !== "compaction" || record(parsed.compaction)?.trigger !== "manual") return null; } catch { - return false; + return null; } } + const sourceModel = raw.model; raw.model = override.model.trim(); if (override.reasoningEffort !== undefined) { raw.reasoning = { ...record(raw.reasoning), effort: override.reasoningEffort }; } - return true; + return { sourceModel }; +} + +/** 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 || 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 cd61606f8b..d7d5ea067d 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -117,7 +117,7 @@ import { codexLogAccountId, } from "./core-codex-account"; import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health"; -import { applyManualCompactionOverride } from "./manual-compaction"; +import { applyManualCompactionOverride, manualCompactionKeepsProviderIdentity } from "./manual-compaction"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -148,8 +148,8 @@ export async function prepareResponsesRequest( } return decodeRequestErrorResponse(err, "responses"); } - if (!options.comboAttempt && !options.manualCompactionApplied && inboundWire === "responses") { - options.manualCompactionApplied = applyManualCompactionOverride(body, req.headers, config, options.inboundTransport); + if (!options.comboAttempt && !options.manualCompactionOverride && inboundWire === "responses") { + options.manualCompactionOverride = applyManualCompactionOverride(body, req.headers, config, 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. @@ -182,7 +182,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 && !options.manualCompactionApplied && 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) @@ -204,7 +204,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 && !options.manualCompactionApplied && 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" @@ -392,7 +392,7 @@ export async function prepareResponsesRequest( logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier); let route: RouteResult; - let credentialDomainWasRewritten = options.manualCompactionApplied === true; + let credentialDomainWasRewritten = false; try { // A `compaction_trigger` turn may name a bare native model the operator has // no canonical OpenAI route for (#2901). Only the initial compaction route @@ -405,7 +405,7 @@ export async function prepareResponsesRequest( : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)); const _sci = config.shadowCallIntercept; let shadowRoute: RouteResult | undefined; - if (!options.manualCompactionApplied && _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 { @@ -433,8 +433,12 @@ export async function prepareResponsesRequest( shadowRoute = targetRoute; } } - if (parsed._compactionRequest === true || options.manualCompactionApplied) 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 84989d46bb..0715beb0a4 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/request.ts b/src/types/request.ts index 3ac5e2cbde..8030866a77 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -120,6 +120,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/transports/responses.md b/structure/transports/responses.md index 50a2e6f419..321302ecf0 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -813,13 +813,26 @@ 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 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. +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). 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, summary replay, combo failover, and subsequent conversation settings. +native and routed handlers, same-provider credential retention, cross-provider portable summaries +and their replay, combo failover, and subsequent conversation settings. ## Core module ownership diff --git a/tests/responses/responses-manual-compaction.test.ts b/tests/responses/responses-manual-compaction.test.ts index eecb376c27..063698af06 100644 --- a/tests/responses/responses-manual-compaction.test.ts +++ b/tests/responses/responses-manual-compaction.test.ts @@ -84,7 +84,7 @@ describe("manual compaction request selection", () => { 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())).toBe(true); + 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); @@ -98,14 +98,14 @@ describe("manual compaction request selection", () => { const input = body(); const before = structuredClone(input); const headers = new Headers(value === undefined ? {} : { "x-codex-turn-metadata": value }); - expect(applyManualCompactionOverride(input, headers, config())).toBe(false); + 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())).toBe(false); + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": header! }), config())).toBeNull(); expect(input.model).toBe("gateway/normal"); } }); @@ -117,7 +117,7 @@ describe("manual compaction request selection", () => { 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(), "websocket")).toBe(expected); + expect(applyManualCompactionOverride(input, headers, config(), "websocket")).toEqual(expected ? { sourceModel: "gateway/normal" } : null); expect(input.model).toBe(expected ? "gateway/cheap" : "gateway/normal"); } }); @@ -126,7 +126,7 @@ describe("manual compaction request selection", () => { const input = body(); const settings = config(); settings.manualCompaction = { model: "gateway/cheap" }; - expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toBe(true); + 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" }); }); @@ -136,7 +136,7 @@ describe("manual compaction request selection", () => { const before = structuredClone(input); const settings = config(); delete settings.manualCompaction; - expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toBe(false); + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toBeNull(); expect(input).toEqual(before); }); }); @@ -227,7 +227,7 @@ describe("manual compaction reuses existing handlers", () => { expect(calls[0]!.body.input).toContainEqual({ type: "compaction_trigger" }); }); - test("native compact retains its existing endpoint and reasoning behavior", async () => { + 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", @@ -238,7 +238,8 @@ describe("manual compaction reuses existing handlers", () => { 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 response = await handleResponsesCompact(request(body(false), "manual", "responses/compact"), settings, { model: "", provider: "" }); + 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); @@ -247,6 +248,113 @@ describe("manual compaction reuses existing handlers", () => { 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"] = { @@ -264,7 +372,7 @@ describe("manual compaction reuses existing handlers", () => { 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), "manual"), settings, { model: "", provider: "" }); + 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"]); From 5fd3e22e7ef68ea05f540585df3a622773d3026c Mon Sep 17 00:00:00 2001 From: nahuelb Date: Thu, 17 Sep 2026 13:39:39 -0300 Subject: [PATCH 3/7] feat(gui): disclose the compaction destination, warn on dropped config, log the source model Review follow-ups: the dashboard panel states that the selected provider receives the entire conversation and names it once a model is chosen (with translations); an invalid hand-edited manualCompaction block now warns at load; request logs keep the conversation model as requestedModel. --- .../docs/reference/configuration/server.md | 4 ++- gui/src/components/ManualCompactionPanel.tsx | 5 ++++ gui/src/i18n/de.ts | 2 ++ gui/src/i18n/en.ts | 2 ++ gui/src/i18n/fr.ts | 2 ++ gui/src/i18n/ja.ts | 2 ++ gui/src/i18n/ko.ts | 2 ++ gui/src/i18n/ru.ts | 2 ++ gui/src/i18n/tr.ts | 2 ++ gui/src/i18n/zh-TW.ts | 2 ++ gui/src/i18n/zh.ts | 2 ++ gui/tests/manual-compaction-panel.test.tsx | 14 +++++++++- src/config.ts | 4 +-- src/config/load-degrade.ts | 8 ++++++ src/server/responses/compact.ts | 3 ++- src/server/responses/request-prepare.ts | 2 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 4 ++- .../responses-manual-compaction.test.ts | 27 +++++++++++++++++-- 19 files changed, 81 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index bdffa5096c..0bd2ac85ff 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -490,7 +490,9 @@ keep their original routing and settings. Missing, malformed, or conflicting met 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 override reuses the existing compaction handlers and summary formats. When the selected +The selected model's provider receives the entire conversation for summarization, including +conversations that normally run on another provider. The dashboard panel states this next to +the model picker. The override reuses the existing compaction handlers and summary formats. When the selected model lives on the same provider as the conversation model, the request keeps the caller's credential and may use that backend's native compact endpoint. When it lives on a different provider, OpenCodex runs the portable summarizer instead, so the summary stays readable when diff --git a/gui/src/components/ManualCompactionPanel.tsx b/gui/src/components/ManualCompactionPanel.tsx index fce9d6ca37..c392118788 100644 --- a/gui/src/components/ManualCompactionPanel.tsx +++ b/gui/src/components/ManualCompactionPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n/shared"; +import { IconAlert } from "../icons"; import { Select } from "../ui"; import { createBoundedFetch } from "../bounded-fetch"; import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; @@ -101,11 +102,14 @@ function ManualCompactionControls({ apiBase, models }: { apiBase: string; models .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 provider = namespace && namespace !== "combo" ? namespace : model; return (
{t("manualCompact.title")}

{t("manualCompact.description")}

+

{t("manualCompact.dataNotice")}

@@ -125,6 +129,7 @@ function ManualCompactionControls({ apiBase, models }: { apiBase: string; models

{t("manualCompact.effortHint")}

+ {provider &&
{t("manualCompact.providerWarning", { provider })}
} {loadError &&
{t("manualCompact.loadFailed")}
} {feedback &&
{t(feedback === "saved" ? "manualCompact.saved" : "manualCompact.saveFailed")}
}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 120e2ba49c..81b501e49d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -352,6 +352,8 @@ export const de: Record = { "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.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.", "manualCompact.saved": "Komprimierungseinstellungen gespeichert.", "manualCompact.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen sind noch vorhanden; versuche es erneut.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7a706cd584..048b7dbd97 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -370,6 +370,8 @@ export const en = { "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.loadFailed": "Could not load compaction settings.", "manualCompact.saved": "Compaction settings saved.", "manualCompact.saveFailed": "Could not save. Your changes are still here; try again.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 229eb23fff..10c520f541 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -360,6 +360,8 @@ export const fr: Record = { "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 compression 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.loadFailed": "Impossible de charger les paramètres de compression.", "manualCompact.saved": "Paramètres de compression enregistrés.", "manualCompact.saveFailed": "Échec de l’enregistrement. Vos modifications sont conservées ; réessayez.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1434b85690..8ab87fbd3a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -361,6 +361,8 @@ export const ja: Record = { "manualCompact.currentModel": "会話のモデルを使用", "manualCompact.currentEffort": "リクエストの推論強度を維持", "manualCompact.effortHint": "圧縮エンドポイントが対応している場合に推論設定が適用されます。モデルは会話全体を受け入れられる必要があります。", + "manualCompact.dataNotice": "手動の /compact は、会話が別のプロバイダーで動いていても、会話全体を選択したモデルのプロバイダーへ送信して要約します。", + "manualCompact.providerWarning": "この設定では、手動の /compact のたびに会話の全内容が要約のために {provider} へ送信されます。", "manualCompact.loadFailed": "圧縮設定を読み込めませんでした。", "manualCompact.saved": "圧縮設定を保存しました。", "manualCompact.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b363094d61..821f4eecb2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -356,6 +356,8 @@ export const ko: Record = { "manualCompact.currentModel": "대화 모델 사용", "manualCompact.currentEffort": "요청의 추론 수준 유지", "manualCompact.effortHint": "압축 엔드포인트가 지원하는 경우 추론 설정이 적용됩니다. 모델은 전체 대화를 수용할 수 있어야 합니다.", + "manualCompact.dataNotice": "수동 /compact는 대화가 다른 프로바이더에서 실행 중이더라도 전체 대화를 선택한 모델의 프로바이더로 보내 요약합니다.", + "manualCompact.providerWarning": "이 설정을 사용하면 수동 /compact마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", "manualCompact.loadFailed": "압축 설정을 불러올 수 없습니다.", "manualCompact.saved": "압축 설정을 저장했습니다.", "manualCompact.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ce07b1bfc1..4b58f63002 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -361,6 +361,8 @@ export const ru: Record = { "manualCompact.currentModel": "Использовать модель разговора", "manualCompact.currentEffort": "Сохранить уровень из запроса", "manualCompact.effortHint": "Уровень рассуждений применяется, если его поддерживает конечная точка сжатия. Модель должна вмещать весь разговор.", + "manualCompact.dataNotice": "Ручной /compact отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", + "manualCompact.providerWarning": "С этой настройкой каждый ручной /compact отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", "manualCompact.loadFailed": "Не удалось загрузить настройки сжатия.", "manualCompact.saved": "Настройки сжатия сохранены.", "manualCompact.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 87f69bf78a..a3cfe158b1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -362,6 +362,8 @@ export const tr: Record = { "manualCompact.currentModel": "Konuşma modelini kullan", "manualCompact.currentEffort": "İsteğin düzeyini koru", "manualCompact.effortHint": "Akıl yürütme, sıkıştırma 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.loadFailed": "Sıkıştırma ayarları yüklenemedi.", "manualCompact.saved": "Sıkıştırma ayarları kaydedildi.", "manualCompact.saveFailed": "Kaydedilemedi. Değişiklikleriniz korunuyor; tekrar deneyin.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e60b788abc..0af350072f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -251,6 +251,8 @@ export const zhTW: Record = { "manualCompact.currentModel": "使用對話模型", "manualCompact.currentEffort": "保留請求的推理強度", "manualCompact.effortHint": "推理設定僅在壓縮端點支援時生效。模型必須能容納完整對話。", + "manualCompact.dataNotice": "手動 /compact 會將整個對話傳送給所選模型的供應商進行摘要,即使對話正在其他供應商上執行。", + "manualCompact.providerWarning": "啟用此設定後,每次手動 /compact 都會將完整對話內容傳送給 {provider} 進行摘要。", "manualCompact.loadFailed": "無法載入壓縮設定。", "manualCompact.saved": "壓縮設定已儲存。", "manualCompact.saveFailed": "儲存失敗。變更仍然保留,請重試。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cb67eb6d8b..4d0bd6d8f3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -356,6 +356,8 @@ export const zh: Record = { "manualCompact.currentModel": "使用对话模型", "manualCompact.currentEffort": "保留请求的推理强度", "manualCompact.effortHint": "推理设置仅在压缩端点支持时生效。模型必须能容纳完整对话。", + "manualCompact.dataNotice": "手动 /compact 会将整个对话发送给所选模型的提供商进行摘要,即使对话正在其他提供商上运行。", + "manualCompact.providerWarning": "启用此设置后,每次手动 /compact 都会将完整对话内容发送给 {provider} 进行摘要。", "manualCompact.loadFailed": "无法加载压缩设置。", "manualCompact.saved": "压缩设置已保存。", "manualCompact.saveFailed": "保存失败。更改仍然保留,请重试。", diff --git a/gui/tests/manual-compaction-panel.test.tsx b/gui/tests/manual-compaction-panel.test.tsx index 136b279239..627d2fa8ef 100644 --- a/gui/tests/manual-compaction-panel.test.tsx +++ b/gui/tests/manual-compaction-panel.test.tsx @@ -15,7 +15,7 @@ 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" }]; +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)])); @@ -114,3 +114,15 @@ test("retains a saved model missing from the current catalog", async () => { 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("sends the full conversation contents to combo/compact for summarization"); + await choose("model", "Use conversation model"); + expect(container.querySelector('[role="note"]')).toBeNull(); +}); 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/load-degrade.ts b/src/config/load-degrade.ts index 84ca9fb35f..65674eeecc 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -100,6 +100,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/server/responses/compact.ts b/src/server/responses/compact.ts index 547c7651a2..8483aa8a93 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -591,7 +591,8 @@ 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. diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index d7d5ea067d..cdf7a48444 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -375,7 +375,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 diff --git a/structure/config.md b/structure/config.md index 89c0f73f20..2185c7ad95 100644 --- a/structure/config.md +++ b/structure/config.md @@ -72,7 +72,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 without discarding providers; candidate writes reject invalid blocks. | +| 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. | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 413a8a1bcd..de1ce7bc14 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -665,6 +665,8 @@ Native steering generation overrides, explicit public-API eligibility and the co `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. `GET /api/settings` returns +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. `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/tests/responses/responses-manual-compaction.test.ts b/tests/responses/responses-manual-compaction.test.ts index 063698af06..f242fee062 100644 --- a/tests/responses/responses-manual-compaction.test.ts +++ b/tests/responses/responses-manual-compaction.test.ts @@ -5,6 +5,7 @@ import { clearCompactHandoffRoutesForTests } from "../../src/server/responses/co 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"; @@ -153,6 +154,25 @@ describe("manual compaction config", () => { 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", () => { @@ -168,11 +188,14 @@ describe("manual compaction reuses existing handlers", () => { 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, { model: "", provider: "" }, { inboundTransport: "websocket" }) - : await handler(manualRequest, settings, { model: "", provider: "" }); + ? 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); From 6832f44446a80ac6d08e25580df19fb3f7713e42 Mon Sep 17 00:00:00 2001 From: nahuelb Date: Thu, 17 Sep 2026 14:52:36 -0300 Subject: [PATCH 4/7] fix(responses,gui): require a compaction_trigger for v2 overrides and localize effort labels CodeRabbit follow-ups: a manual override on /v1/responses now also requires a compaction_trigger input item so manual metadata alone cannot move an ordinary turn; the panel treats an absent manualCompaction key as unset, shows translated effort labels (adding the ultra key to every locale), and the byte-accounting note names the raw body. --- .../docs/reference/configuration/server.md | 3 +- gui/src/components/ManualCompactionPanel.tsx | 6 ++-- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/tests/fr-localization.test.ts | 1 + gui/tests/manual-compaction-panel.test.tsx | 4 +-- src/server/responses/compact.ts | 2 +- src/server/responses/manual-compaction.ts | 12 ++++++-- src/server/responses/request-prepare.ts | 5 +++- structure/transports/byte-accounting.md | 2 +- structure/transports/responses.md | 5 ++-- .../responses-manual-compaction.test.ts | 28 ++++++++++++++++++- 19 files changed, 63 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 0bd2ac85ff..9c8ebc4517 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -485,7 +485,8 @@ Existing provider effort rules still apply. The native `/responses/compact` endp its existing behavior and does not forward reasoning settings. OpenCodex changes only requests with explicit `request_kind: "compaction"` and -`compaction.trigger: "manual"` metadata. Automatic compaction and later conversation turns +`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. diff --git a/gui/src/components/ManualCompactionPanel.tsx b/gui/src/components/ManualCompactionPanel.tsx index c392118788..292d6c4af5 100644 --- a/gui/src/components/ManualCompactionPanel.tsx +++ b/gui/src/components/ManualCompactionPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useT } from "../i18n/shared"; +import { useT, type TKey } from "../i18n/shared"; import { IconAlert } from "../icons"; import { Select } from "../ui"; import { createBoundedFetch } from "../bounded-fetch"; @@ -11,7 +11,7 @@ const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ul function readSetting(payload: { manualCompaction?: unknown }): Setting { const value = payload.manualCompaction; - if (value === null) return null; + 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"); } @@ -121,7 +121,7 @@ function ManualCompactionControls({ apiBase, models }: { apiBase: string; models { setModel(value); if (!value) setEffort(""); setFeedback(null); }} /> - -
- -