diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 3aaba75fc0..e3656cd9be 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -460,6 +460,52 @@ Auto auth selects subscription when stored Claude auth is found, proxy when none subscription with a warning when detection is inconclusive. See [Claude Code auth mode](/guides/claude-code/#auth-mode). +## Manual compaction + +In **Dashboard → Overview → Manual compaction**, choose a model and optional reasoning effort, +then click **Save**. Select **Use conversation model** and save to remove the override. +Changes apply to the next manual `/compact` request without restarting the proxy. + +Set `manualCompaction` in OpenCodex `config.json` to override the model used by Codex's +manual `/compact` command. The setting is disabled when omitted. + +```json +{ + "manualCompaction": { + "model": "provider/model-id", + "reasoningEffort": "low" + } +} +``` + +`model` accepts native model IDs, provider-qualified model IDs, and configured combos. +`reasoningEffort` is optional; omit it to preserve the incoming effort. Supported declarations +are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. +Existing provider effort rules still apply. The native `/responses/compact` endpoint keeps +its existing behavior and does not forward reasoning settings. + +OpenCodex changes only requests with explicit `request_kind: "compaction"` and +`compaction.trigger: "manual"` metadata, sent to `/v1/responses/compact` or to `/v1/responses` +with a `compaction_trigger` input item. Automatic compaction and later conversation turns +keep their original routing and settings. Missing, malformed, or conflicting metadata does +not activate the override, including on older clients without trigger metadata. WebSocket +requests use each frame's metadata rather than the connection's earlier handshake metadata. + +The selected model's provider receives the entire conversation for summarization, including +conversations that normally run on another provider. A combo selector sends it to every combo +target, including failover targets. The dashboard panel states this next to the model picker +and names the destination provider, or the combo's target providers, once a model is chosen. +The override reuses the existing compaction handlers and summary formats. When the selected +model shares the conversation model's provider and account-routing identity (provider name, +Codex account mode, and account namespace), the request keeps the caller's credential and may +use that backend's native compact endpoint. Otherwise, including when either side is a combo or +the conversation model is remembered as a combo target, OpenCodex runs the portable summarizer +instead, so the summary stays readable when the conversation resumes on its own model, and the +caller's credential does not cross to the other provider. The selected model must support the +input size and content. This setting does +not guarantee a cache hit for automatic compaction. Restart the proxy after editing +`config.json` by hand. Dashboard saves apply immediately. + ## Shadow calls Codex uses small helper models for tasks such as titles and commit messages. Enable diff --git a/gui/src/components/ManualCompactionPanel.tsx b/gui/src/components/ManualCompactionPanel.tsx new file mode 100644 index 0000000000..cfcb411a32 --- /dev/null +++ b/gui/src/components/ManualCompactionPanel.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT, type TKey } from "../i18n/shared"; +import { IconAlert } from "../icons"; +import { Select } from "../ui"; +import { createBoundedFetch } from "../bounded-fetch"; +import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; +import { formatNamespacedModelId } from "../provider-icons"; + +type Setting = { model: string; reasoningEffort?: string } | null; +const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]; + +function readComboProviders(payload: unknown): Record { + const combos = (payload as { combos?: unknown })?.combos; + if (!Array.isArray(combos)) return {}; + const result: Record = {}; + for (const combo of combos) { + if (!combo || typeof combo !== "object" || typeof (combo as { id?: unknown }).id !== "string") continue; + const targets = (combo as { targets?: unknown }).targets; + const providers = Array.isArray(targets) + ? targets.map(target => (target as { provider?: unknown })?.provider).filter((value): value is string => typeof value === "string") + : []; + result[(combo as { id: string }).id] = [...new Set(providers)]; + } + return result; +} + +function readSetting(payload: { manualCompaction?: unknown }): Setting { + const value = payload.manualCompaction; + if (value == null) return null; + if (!value || typeof value !== "object" || !("model" in value) || typeof value.model !== "string" || !value.model.trim()) { + throw new Error("invalid settings"); + } + const effort = "reasoningEffort" in value ? value.reasoningEffort : undefined; + if (effort !== undefined && (typeof effort !== "string" || !EFFORTS.includes(effort))) throw new Error("invalid effort"); + return { model: value.model, ...(effort ? { reasoningEffort: effort as string } : {}) }; +} + +export default function ManualCompactionPanel(props: { apiBase: string; models: ModelInfo[] }) { + return ; +} + +function ManualCompactionControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) { + const t = useT(); + const [saved, setSaved] = useState(undefined); + const [model, setModel] = useState(""); + const [effort, setEffort] = useState(""); + const [busy, setBusy] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); + const [comboProviders, setComboProviders] = useState>({}); + const active = useRef(false); + const pending = useRef | null>(null); + + const accept = useCallback((value: Setting) => { + setSaved(value); + setModel(value?.model ?? ""); + setEffort(value?.reasoningEffort ?? ""); + }, []); + + const load = useCallback(async () => { + if (pending.current) return; + const request = createBoundedFetch(15_000); + pending.current = request; + setLoadError(false); + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: request.signal }); + const value = readSetting(await requireJson(response)); + if (active.current && pending.current === request) accept(value); + const combos = await fetch(`${apiBase}/api/combos`, { signal: request.signal }).then(requireJson).then(readComboProviders).catch(() => ({})); + if (active.current && pending.current === request) setComboProviders(combos); + } catch { + if (active.current && pending.current === request) setLoadError(true); + } finally { + request.clear(); + if (pending.current === request) pending.current = null; + } + }, [apiBase, accept]); + + useEffect(() => { + active.current = true; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + window.clearTimeout(timer); + active.current = false; + pending.current?.controller.abort(); + pending.current?.clear(); + pending.current = null; + }; + }, [load]); + + const save = async () => { + if (pending.current || saved === undefined) return; + const request = createBoundedFetch(15_000); + pending.current = request; + setBusy(true); + setFeedback(null); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ manualCompaction: model ? { model, ...(effort ? { reasoningEffort: effort } : {}) } : null }), + signal: request.signal, + }); + const value = readSetting(await requireJson(response)); + if (active.current && pending.current === request) { + accept(value); + setFeedback("saved"); + } + } catch { + if (active.current && pending.current === request) setFeedback("failed"); + } finally { + request.clear(); + if (active.current && pending.current === request) setBusy(false); + if (pending.current === request) pending.current = null; + } + }; + + const options = [{ value: "", label: t("manualCompact.currentModel") }, + ...[...new Set([...models.map(item => item.namespaced), ...(model ? [model] : [])])] + .map(value => ({ value, label: formatNamespacedModelId(value, t) }))]; + const disabled = busy || saved === undefined || loadError; + const dirty = model !== (saved?.model ?? "") || effort !== (saved?.reasoningEffort ?? ""); + const namespace = model.slice(0, Math.max(model.indexOf("/"), 0)); + const combo = namespace === "combo" ? model.slice(namespace.length + 1) : ""; + const provider = namespace && !combo ? namespace : model; + const providers = comboProviders[combo]?.join(", ") || t("manualCompact.comboProvidersUnknown"); + + return ( +
+
+
+
{t("manualCompact.title")}
+
{t("manualCompact.description")}
+
{t("manualCompact.dataNotice")}
+
{t("manualCompact.effortHint")}
+
+
+ ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]} + onChange={value => { setEffort(value); setFeedback(null); }} /> + +
+
+ {provider &&
{combo + ? t("manualCompact.comboWarning", { combo: model, providers }) + : t("manualCompact.providerWarning", { provider })}
} + {loadError &&
{t("manualCompact.loadFailed")}
} + {feedback === "failed" &&
{t("manualCompact.saveFailed")}
} + {feedback === "saved" &&
{t("manualCompact.saved")}
} +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dd5ca2633c..0347032d7a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -345,6 +345,20 @@ export const de: Record = { "dash.visionSidecar": "Vision-Sidecar", "dash.visionSidecarHint": "Backend und Modell zur Bildbeschreibung für reine Textmodelle auswählen.", "dash.visionOff": "Aus", + "manualCompact.title": "Manuelle Komprimierung", + "manualCompact.description": "Wähle ein Modell für manuelle /compact-Befehle. Automatische Komprimierung und spätere Nachrichten behalten die Gesprächseinstellungen.", + "manualCompact.model": "Komprimierungsmodell", + "manualCompact.effort": "Denkaufwand", + "manualCompact.currentModel": "Gesprächsmodell verwenden", + "manualCompact.currentEffort": "Anfrageaufwand beibehalten", + "manualCompact.effortHint": "Der Denkaufwand gilt, wenn der Komprimierungsendpunkt ihn unterstützt. Das Modell muss das gesamte Gespräch verarbeiten können.", + "manualCompact.dataNotice": "Manuelles /compact sendet das gesamte Gespräch zur Zusammenfassung an den Anbieter des gewählten Modells, auch wenn das Gespräch bei einem anderen Anbieter läuft.", + "manualCompact.providerWarning": "Mit dieser Einstellung sendet jedes manuelle /compact den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}.", + "manualCompact.comboWarning": "Mit dieser Einstellung sendet jedes manuelle /compact den vollständigen Gesprächsinhalt zur Zusammenfassung an jedes Ziel der Combo {combo} ({providers}), einschließlich Failover-Zielen.", + "manualCompact.comboProvidersUnknown": "ihre konfigurierten Zielanbieter", + "manualCompact.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.", + "manualCompact.saved": "Komprimierungseinstellungen gespeichert.", + "manualCompact.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen sind noch vorhanden; versuche es erneut.", "dash.shadowCallIntercept": "Shadow-Call-Abfangen", "dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.", "dash.shadowCallWarning": "⚠ Bei Aktivierung werden ALLE Anfragen an {models} durch das gewählte Modell ersetzt.", @@ -663,6 +677,7 @@ export const de: Record = { "models.reasoningEffort.high": "Hoch", "models.reasoningEffort.xhigh": "Sehr hoch", "models.reasoningEffort.max": "Maximal", + "models.reasoningEffort.ultra": "Ultra", "models.tipProvider": "Anbieter", "models.tipContext": "Kontext", "models.tipModalities": "Modalitäten", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6723d74b81..8e230ab2d1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -363,6 +363,20 @@ export const en = { "dash.visionTimeout": "Timeout", "dash.visionTimeoutInvalid": "Enter an integer from {min} to {max} milliseconds.", "dash.visionAdvancedPopover": "Advanced vision settings", + "manualCompact.title": "Manual compaction", + "manualCompact.description": "Choose a model for manual /compact commands. Automatic compaction and later messages keep the conversation settings.", + "manualCompact.model": "Compaction model", + "manualCompact.effort": "Reasoning effort", + "manualCompact.currentModel": "Use conversation model", + "manualCompact.currentEffort": "Keep request effort", + "manualCompact.effortHint": "Reasoning applies where supported by the compaction endpoint. The model must accept the full conversation.", + "manualCompact.dataNotice": "Manual /compact sends the entire conversation to the selected model's provider for summarization, even when the conversation runs on another provider.", + "manualCompact.providerWarning": "With this setting, every manual /compact sends the full conversation contents to {provider} for summarization.", + "manualCompact.comboWarning": "With this setting, every manual /compact sends the full conversation contents to every target of combo {combo} ({providers}), including failover targets, for summarization.", + "manualCompact.comboProvidersUnknown": "its configured target providers", + "manualCompact.loadFailed": "Could not load compaction settings.", + "manualCompact.saved": "Compaction settings saved.", + "manualCompact.saveFailed": "Could not save. Your changes are still here; try again.", "dash.shadowCallIntercept": "Shadow Call Intercept", "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.", "dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.", @@ -692,6 +706,7 @@ export const en = { "models.reasoningEffort.high": "High", "models.reasoningEffort.xhigh": "Extra high", "models.reasoningEffort.max": "Maximum", + "models.reasoningEffort.ultra": "Ultra", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 43d956906f..b766afe15d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -353,6 +353,20 @@ export const fr: Record = { "dash.visionTimeout": "Délai d’expiration", "dash.visionTimeoutInvalid": "Saisissez un entier compris entre {min} et {max} millisecondes.", "dash.visionAdvancedPopover": "Paramètres de vision avancés", + "manualCompact.title": "Compaction manuelle", + "manualCompact.description": "Choisissez un modèle pour les commandes /compact manuelles. La compaction automatique et les messages suivants conservent les paramètres de la conversation.", + "manualCompact.model": "Modèle de compaction", + "manualCompact.effort": "Effort de raisonnement", + "manualCompact.currentModel": "Utiliser le modèle de la conversation", + "manualCompact.currentEffort": "Conserver l’effort de la requête", + "manualCompact.effortHint": "Le raisonnement s’applique si le point de terminaison de compaction le prend en charge. Le modèle doit accepter toute la conversation.", + "manualCompact.dataNotice": "Un /compact manuel envoie toute la conversation au fournisseur du modèle choisi pour la résumer, même si la conversation s’exécute chez un autre fournisseur.", + "manualCompact.providerWarning": "Avec ce réglage, chaque /compact manuel envoie l’intégralité du contenu de la conversation à {provider} pour la résumer.", + "manualCompact.comboWarning": "Avec ce réglage, chaque /compact manuel envoie l’intégralité du contenu de la conversation à chaque cible du combo {combo} ({providers}), y compris les cibles de bascule, pour le résumer.", + "manualCompact.comboProvidersUnknown": "ses fournisseurs cibles configurés", + "manualCompact.loadFailed": "Impossible de charger les paramètres de compaction.", + "manualCompact.saved": "Paramètres de compaction enregistrés.", + "manualCompact.saveFailed": "Échec de l’enregistrement. Vos modifications sont conservées ; réessayez.", "dash.shadowCallIntercept": "Interception des appels fantômes", "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.", "dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.", @@ -677,6 +691,7 @@ export const fr: Record = { "models.reasoningEffort.high": "Élevé", "models.reasoningEffort.xhigh": "Très élevé", "models.reasoningEffort.max": "Maximum", + "models.reasoningEffort.ultra": "Ultra", "models.tipProvider": "Fournisseur", "models.tipContext": "Contexte", "models.tipModalities": "Modalités", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d60148bb60..3b5cf7202e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -354,6 +354,20 @@ export const ja: Record = { "dash.visionSidecar": "ビジョンサイドカー", "dash.visionSidecarHint": "テキスト専用ルーティングモデルで画像を説明するために使うバックエンドとモデルを選択します。", "dash.visionOff": "オフ", + "manualCompact.title": "手動圧縮", + "manualCompact.description": "手動の /compact コマンドに使うモデルを選択します。自動圧縮と以降のメッセージは会話の設定を維持します。", + "manualCompact.model": "圧縮モデル", + "manualCompact.effort": "推論の強度", + "manualCompact.currentModel": "会話のモデルを使用", + "manualCompact.currentEffort": "リクエストの推論強度を維持", + "manualCompact.effortHint": "圧縮エンドポイントが対応している場合に推論設定が適用されます。モデルは会話全体を受け入れられる必要があります。", + "manualCompact.dataNotice": "手動の /compact は、会話が別のプロバイダーで動いていても、会話全体を選択したモデルのプロバイダーへ送信して要約します。", + "manualCompact.providerWarning": "この設定では、手動の /compact のたびに会話の全内容が要約のために {provider} へ送信されます。", + "manualCompact.comboWarning": "この設定では、手動の /compact のたびに会話の全内容が、フェイルオーバー先を含むコンボ {combo} のすべてのターゲット({providers})へ要約のために送信されます。", + "manualCompact.comboProvidersUnknown": "設定済みのターゲットプロバイダー", + "manualCompact.loadFailed": "圧縮設定を読み込めませんでした。", + "manualCompact.saved": "圧縮設定を保存しました。", + "manualCompact.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", "dash.shadowCallIntercept": "シャドウコール傍受", "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", "dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。", @@ -2531,6 +2545,7 @@ export const ja: Record = { "models.reasoningEffort.high": "高", "models.reasoningEffort.xhigh": "非常に高", "models.reasoningEffort.max": "最大", + "models.reasoningEffort.ultra": "ウルトラ", "models.tipProvider": "Provider", "models.tipContext": "Context", "models.tipModalities": "Modalities", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index be26daf40e..a8c90e504c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -349,6 +349,20 @@ export const ko: Record = { "dash.visionSidecar": "비전 사이드카", "dash.visionSidecarHint": "텍스트 전용 라우팅 모델이 이미지를 읽을 때 쓸 백엔드와 모델을 고릅니다.", "dash.visionOff": "끔", + "manualCompact.title": "수동 압축", + "manualCompact.description": "수동 /compact 명령에 사용할 모델을 선택하세요. 자동 압축과 이후 메시지는 대화 설정을 유지합니다.", + "manualCompact.model": "압축 모델", + "manualCompact.effort": "추론 수준", + "manualCompact.currentModel": "대화 모델 사용", + "manualCompact.currentEffort": "요청의 추론 수준 유지", + "manualCompact.effortHint": "압축 엔드포인트가 지원하는 경우 추론 설정이 적용됩니다. 모델은 전체 대화를 수용할 수 있어야 합니다.", + "manualCompact.dataNotice": "수동 /compact는 대화가 다른 프로바이더에서 실행 중이더라도 전체 대화를 선택한 모델의 프로바이더로 보내 요약합니다.", + "manualCompact.providerWarning": "이 설정을 사용하면 수동 /compact마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", + "manualCompact.comboWarning": "이 설정을 사용하면 수동 /compact마다 전체 대화 내용이 장애 조치 대상을 포함한 콤보 {combo}의 모든 대상({providers})으로 요약을 위해 전송됩니다.", + "manualCompact.comboProvidersUnknown": "구성된 대상 프로바이더", + "manualCompact.loadFailed": "압축 설정을 불러올 수 없습니다.", + "manualCompact.saved": "압축 설정을 저장했습니다.", + "manualCompact.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다.", "dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.", @@ -674,6 +688,7 @@ export const ko: Record = { "models.reasoningEffort.high": "높음", "models.reasoningEffort.xhigh": "매우 높음", "models.reasoningEffort.max": "최대", + "models.reasoningEffort.ultra": "울트라", "models.tipProvider": "프로바이더", "models.tipContext": "컨텍스트", "models.tipModalities": "모달리티", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9691f8a49f..729e67f308 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -354,6 +354,20 @@ export const ru: Record = { "dash.visionSidecar": "Сайдкар для изображений", "dash.visionSidecarHint": "Выберите бэкенд и модель, которые описывают изображения для маршрутизируемых моделей, работающих только с текстом.", "dash.visionOff": "Выкл", + "manualCompact.title": "Ручное сжатие", + "manualCompact.description": "Выберите модель для ручных команд /compact. Автоматическое сжатие и последующие сообщения сохраняют настройки разговора.", + "manualCompact.model": "Модель сжатия", + "manualCompact.effort": "Уровень рассуждений", + "manualCompact.currentModel": "Использовать модель разговора", + "manualCompact.currentEffort": "Сохранить уровень из запроса", + "manualCompact.effortHint": "Уровень рассуждений применяется, если его поддерживает конечная точка сжатия. Модель должна вмещать весь разговор.", + "manualCompact.dataNotice": "Ручной /compact отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", + "manualCompact.providerWarning": "С этой настройкой каждый ручной /compact отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", + "manualCompact.comboWarning": "С этой настройкой каждый ручной /compact отправляет полное содержимое разговора каждой цели комбо {combo} ({providers}), включая резервные цели, для составления сводки.", + "manualCompact.comboProvidersUnknown": "его настроенные целевые провайдеры", + "manualCompact.loadFailed": "Не удалось загрузить настройки сжатия.", + "manualCompact.saved": "Настройки сжатия сохранены.", + "manualCompact.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.", "dash.shadowCallIntercept": "Перехват теневых вызовов", "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель.", "dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.", @@ -676,6 +690,7 @@ export const ru: Record = { "models.reasoningEffort.high": "Высокий", "models.reasoningEffort.xhigh": "Очень высокий", "models.reasoningEffort.max": "Максимальный", + "models.reasoningEffort.ultra": "Ультра", "models.tipProvider": "Провайдер", "models.tipContext": "Контекст", "models.tipModalities": "Модальности", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 95336fa32f..dab3b8629d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -355,6 +355,20 @@ export const tr: Record = { "dash.visionSidecar": "Görsel yan aracı (sidecar)", "dash.visionSidecarHint": "Salt metin modeller için görselleri tanımlamakta kullanılan arka ucu ve modeli seçin.", "dash.visionOff": "Kapalı", + "manualCompact.title": "Manuel özetleme", + "manualCompact.description": "Manuel /compact komutları için bir model seçin. Otomatik özetleme ve sonraki mesajlar konuşma ayarlarını korur.", + "manualCompact.model": "Özetleme modeli", + "manualCompact.effort": "Akıl yürütme düzeyi", + "manualCompact.currentModel": "Konuşma modelini kullan", + "manualCompact.currentEffort": "İsteğin düzeyini koru", + "manualCompact.effortHint": "Akıl yürütme, özetleme uç noktası destekliyorsa uygulanır. Model tüm konuşmayı kabul edebilmelidir.", + "manualCompact.dataNotice": "Manuel /compact, konuşma başka bir sağlayıcıda yürütülse bile konuşmanın tamamını özetlenmek üzere seçilen modelin sağlayıcısına gönderir.", + "manualCompact.providerWarning": "Bu ayarla her manuel /compact, konuşmanın tüm içeriğini özetlenmek üzere {provider} sağlayıcısına gönderir.", + "manualCompact.comboWarning": "Bu ayarla her manuel /compact, konuşmanın tüm içeriğini yedek hedefler dahil {combo} kombosunun her hedefine ({providers}) özetlenmek üzere gönderir.", + "manualCompact.comboProvidersUnknown": "yapılandırılmış hedef sağlayıcıları", + "manualCompact.loadFailed": "Özetleme ayarları yüklenemedi.", + "manualCompact.saved": "Özetleme ayarları kaydedildi.", + "manualCompact.saveFailed": "Kaydedilemedi. Değişiklikleriniz korunuyor; tekrar deneyin.", "dash.shadowCallIntercept": "Gölge Çağrı Yakalama", "dash.shadowCallInterceptHint": "Codex App'in arka plan yardımcı çağrılarını ({models}) başlık oluşturma ve commit mesajları için yakalar ve seçtiğiniz modele yönlendirir.", "dash.shadowCallWarning": "⚠ Etkinleştirildiğinde, {models} için olan TÜM istekler seçilen modelle değiştirilecektir.", @@ -679,6 +693,7 @@ export const tr: Record = { "models.reasoningEffort.high": "Yüksek", "models.reasoningEffort.xhigh": "Çok yüksek", "models.reasoningEffort.max": "Maksimum", + "models.reasoningEffort.ultra": "Ultra", "models.tipProvider": "Sağlayıcı", "models.tipContext": "Bağlam", "models.tipModalities": "Girdi Türleri", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 19f1e50ce2..9dfa3bcfc9 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -244,6 +244,20 @@ export const zhTW: Record = { "dash.visionSidecar": "視覺附屬服務", "dash.visionSidecarHint": "選擇純文字路由模型描述圖像時使用的後端和模型。", "dash.visionOff": "關閉", + "manualCompact.title": "手動壓縮", + "manualCompact.description": "選擇手動 /compact 命令使用的模型。自動壓縮和後續訊息保留對話設定。", + "manualCompact.model": "壓縮模型", + "manualCompact.effort": "推理強度", + "manualCompact.currentModel": "使用對話模型", + "manualCompact.currentEffort": "保留請求的推理強度", + "manualCompact.effortHint": "推理設定僅在壓縮端點支援時生效。模型必須能容納完整對話。", + "manualCompact.dataNotice": "手動 /compact 會將整個對話傳送給所選模型的供應商進行摘要,即使對話正在其他供應商上執行。", + "manualCompact.providerWarning": "啟用此設定後,每次手動 /compact 都會將完整對話內容傳送給 {provider} 進行摘要。", + "manualCompact.comboWarning": "啟用此設定後,每次手動 /compact 都會將完整對話內容傳送給組合 {combo} 的每個目標({providers}),包括容錯移轉目標,以進行摘要。", + "manualCompact.comboProvidersUnknown": "其已設定的目標供應商", + "manualCompact.loadFailed": "無法載入壓縮設定。", + "manualCompact.saved": "壓縮設定已儲存。", + "manualCompact.saveFailed": "儲存失敗。變更仍然保留,請重試。", "dash.shadowCallIntercept": "影子呼叫攔截", "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", "dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。", @@ -540,6 +554,7 @@ export const zhTW: Record = { "models.reasoningEffort.high": "高", "models.reasoningEffort.xhigh": "極高", "models.reasoningEffort.max": "最高", + "models.reasoningEffort.ultra": "超高", "models.tipProvider": "供應商", "models.tipContext": "上下文", "models.tipModalities": "模態", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e081d027f3..2d11c7f545 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -349,6 +349,20 @@ export const zh: Record = { "dash.visionSidecar": "视觉附属服务", "dash.visionSidecarHint": "选择纯文本路由模型描述图像时使用的后端和模型。", "dash.visionOff": "关闭", + "manualCompact.title": "手动压缩", + "manualCompact.description": "选择手动 /compact 命令使用的模型。自动压缩和后续消息保留对话设置。", + "manualCompact.model": "压缩模型", + "manualCompact.effort": "推理强度", + "manualCompact.currentModel": "使用对话模型", + "manualCompact.currentEffort": "保留请求的推理强度", + "manualCompact.effortHint": "推理设置仅在压缩端点支持时生效。模型必须能容纳完整对话。", + "manualCompact.dataNotice": "手动 /compact 会将整个对话发送给所选模型的提供商进行摘要,即使对话正在其他提供商上运行。", + "manualCompact.providerWarning": "启用此设置后,每次手动 /compact 都会将完整对话内容发送给 {provider} 进行摘要。", + "manualCompact.comboWarning": "启用此设置后,每次手动 /compact 都会将完整对话内容发送给组合 {combo} 的每个目标({providers}),包括故障转移目标,以进行摘要。", + "manualCompact.comboProvidersUnknown": "其已配置的目标提供商", + "manualCompact.loadFailed": "无法加载压缩设置。", + "manualCompact.saved": "压缩设置已保存。", + "manualCompact.saveFailed": "保存失败。更改仍然保留,请重试。", "dash.shadowCallIntercept": "影子调用拦截", "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。", "dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。", @@ -671,6 +685,7 @@ export const zh: Record = { "models.reasoningEffort.high": "高", "models.reasoningEffort.xhigh": "极高", "models.reasoningEffort.max": "最高", + "models.reasoningEffort.ultra": "超高", "models.tipProvider": "提供方", "models.tipContext": "上下文", "models.tipModalities": "模态", diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 8009d04423..fa5daeada0 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -1,3 +1,4 @@ +import ManualCompactionPanel from "../components/ManualCompactionPanel"; import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; import type { useDashboardData } from "./use-dashboard-data"; import { @@ -18,6 +19,7 @@ export function DashboardOverviewPanels(props: Dash) { + ); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 689a1b8672..5c5d8aa73d 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -134,6 +134,7 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientCline", "models.reasoningEffort.minimal", "models.reasoningEffort.max", + "models.reasoningEffort.ultra", "pws.pacingRpmUnit", "claudeDesktop.family.opus", "claudeDesktop.family.fable", diff --git a/gui/tests/manual-compaction-panel.test.tsx b/gui/tests/manual-compaction-panel.test.tsx new file mode 100644 index 0000000000..b4e6646dd0 --- /dev/null +++ b/gui/tests/manual-compaction-panel.test.tsx @@ -0,0 +1,131 @@ +/** @jsxImportSource react */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, StrictMode } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ManualCompactionPanel from "../src/components/ManualCompactionPanel"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record; +let win: Window; +let root: Root | undefined; +let container: HTMLDivElement; +let setting: { model: string; reasoningEffort?: string } | null; +let failLoad: boolean; +let failSave: boolean; +let writes: unknown[]; +const models = [{ id: "cheap", provider: "gateway", namespaced: "gateway/cheap" }, { id: "compact", provider: "combo", namespaced: "combo/compact" }]; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + for (const key of ["document", "window", "navigator", "localStorage", "sessionStorage", "HTMLElement"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + win.localStorage.setItem("ocx-lang", "en"); + setting = null; failLoad = false; failSave = false; writes = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => { + if (String(_input).endsWith("/api/combos")) { + return Response.json({ combos: [{ id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }] }); + } + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + writes.push(body); + if (failSave) return Response.json({ error: "fixture failure" }, { status: 500 }); + setting = body.manualCompaction; + } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json({ manualCompaction: setting }); + } }); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + root = undefined; win.close(); + for (const key of globals) { + if (previous[key]) Object.defineProperty(globalThis, key, previous[key]!); + else delete (globalThis as Record)[key]; + } +}); + +async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); } +async function render(base = "") { + if (!root) { + container = win.document.createElement("div") as unknown as HTMLDivElement; + win.document.body.appendChild(container); + root = (await import("react-dom/client")).createRoot(container); + } + await act(async () => { root!.render(); }); + await flush(); +} +async function choose(id: string, label: string) { + await act(async () => { container.querySelector(`#manual-compaction-${id}`)!.click(); }); + const option = [...win.document.querySelectorAll('[role="option"]')].find(node => node.textContent === label); + expect(option).toBeDefined(); + await act(async () => { (option as unknown as HTMLButtonElement).click(); }); +} +function saveButton() { return [...container.querySelectorAll('button')].find(button => button.textContent === "Save")!; } +async function save() { await act(async () => { saveButton().click(); }); } + +test("saves model and optional effort, reloads, removes effort, and clears override", async () => { + await render(); + expect(saveButton().disabled).toBe(true); + await choose("model", "gateway/cheap"); + await choose("effort", "Low"); + await save(); + expect(writes).toEqual([{ manualCompaction: { model: "gateway/cheap", reasoningEffort: "low" } }]); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Compaction settings saved."); + await render("/reloaded"); + expect(container.querySelector('#manual-compaction-effort')?.textContent).toContain("Low"); + await choose("effort", "Keep request effort"); + await save(); + expect(writes.at(-1)).toEqual({ manualCompaction: { model: "gateway/cheap" } }); + await choose("model", "Use conversation model"); + await save(); + expect(writes.at(-1)).toEqual({ manualCompaction: null }); + expect(container.querySelector('#manual-compaction-effort')!.disabled).toBe(true); +}); + +test("failed save retains the draft and allows retry", async () => { + await render(); + await choose("model", "gateway/cheap"); + failSave = true; + await save(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save"); + expect(container.querySelector('#manual-compaction-model')?.textContent).toContain("gateway/cheap"); + expect(saveButton().disabled).toBe(false); + expect(setting).toBeNull(); + failSave = false; + await save(); + expect(setting).toEqual({ model: "gateway/cheap" }); +}); + +test("failed load disables editing and retry recovers", async () => { + failLoad = true; + await render(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load"); + expect(container.querySelector('#manual-compaction-model')!.disabled).toBe(true); + failLoad = false; + await act(async () => { [...container.querySelectorAll('button')].find(button => button.textContent === "Retry")!.click(); }); + expect(container.querySelector('#manual-compaction-model')!.disabled).toBe(false); +}); + +test("retains a saved model missing from the current catalog", async () => { + setting = { model: "gateway/retired", reasoningEffort: "high" }; + await render(); + expect(container.querySelector('#manual-compaction-model')?.textContent).toContain("gateway/retired"); + expect(saveButton().disabled).toBe(true); +}); + +test("discloses that the selected provider receives the full conversation", async () => { + await render(); + expect(container.textContent).toContain("sends the entire conversation to the selected model's provider"); + expect(container.querySelector('[role="note"]')).toBeNull(); + await choose("model", "gateway/cheap"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("sends the full conversation contents to gateway for summarization"); + await choose("model", "combo/compact"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("every target of combo combo/compact (gateway, openai-apikey), including failover targets"); + await choose("model", "Use conversation model"); + expect(container.querySelector('[role="note"]')).toBeNull(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 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/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/config.ts b/src/config.ts index 510501c507..d2f07741d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -170,7 +170,7 @@ import { sanitizeModelCostsForLoad, sanitizeCapabilityDeclarationsForLoad, warnInheritedFastWireConflicts, - warnDegradedStreamMode, + warnDegradedStreamMode, warnDegradedManualCompaction, warnDegradedHostname, warnDegradedListeners, warnDegradedApiKeys, @@ -227,7 +227,7 @@ export function loadConfig(): OcxConfig { if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); warnInheritedFastWireConflicts(configPath, config); - warnDegradedStreamMode(parsed, config); + warnDegradedStreamMode(parsed, config); warnDegradedManualCompaction(parsed, config); warnDegradedHostname(parsed, config); warnDegradedListeners(parsed, config); warnDegradedApiKeys(parsed, config); diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index 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/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/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 +574,9 @@ export async function handleResponsesCompact( if (!body || typeof body !== "object" || Array.isArray(body)) { return formatErrorResponse(400, "invalid_request_error", "Invalid compaction request body"); } + if (!options.manualCompactionOverride) { + options = { ...options, manualCompactionOverride: applyManualCompactionOverride(body, req.headers, config, { endpoint: "compact" }) }; + } const raw = body as { model?: unknown; input?: unknown }; if (typeof raw.model !== "string" || raw.model.length === 0) { return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model"); @@ -582,11 +591,12 @@ export async function handleResponsesCompact( // The client's own selector, kept for the request log: `raw.model` is rewritten to the // base id above, and logCtx.requestedModel is assigned from it further down, so without // this the log would lose which id the client actually asked for. - const compactRequestedModel = compactFastRow ? compactFastRow.baseId + "--fast" : raw.model; + const compactRequestedModel = options.manualCompactionOverride?.sourceModel + ?? (compactFastRow ? compactFastRow.baseId + "--fast" : raw.model); // Recall the last completed client-visible bare model after a combo switch (#3891). // Configured selectors take precedence over this implicit session hint. - if (typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow + if (!options.manualCompactionOverride && typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow && !resolveComboId(config, compactModel)) { const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel); if (recalledComboId) { @@ -698,7 +708,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"); } @@ -1254,9 +1269,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.manualCompactionOverride) forgetCompactHandoffRoute(req); rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); - } else if (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, { @@ -1319,7 +1334,7 @@ export async function handleResponsesCompact( // The routed compaction turn is a handoff inside the same logical request, so it draws the // REMAINDER. Minting here is what let a native attempt spend three sends and the routed // fallback spend four more. - const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, ...(admission ? { admission } : {}) }); + const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, sendBudget, manualCompactionOverride: options.manualCompactionOverride, ...(admission ? { admission } : {}) }); if (!response.ok) return response; let json: { output?: unknown[]; status?: unknown; error?: unknown }; if (response.headers.get("content-type")?.includes("text/event-stream")) { @@ -1389,7 +1404,7 @@ export async function handleResponsesCompact( const result = new Response(JSON.stringify({ output: compactionItems }), { headers: { "Content-Type": "application/json" }, }); - rememberCompactHandoffRoute(req, raw.model); + if (!options.manualCompactionOverride) rememberCompactHandoffRoute(req, raw.model); return result; } const encrypted = compactionItems[0]!.encrypted_content; @@ -1400,6 +1415,6 @@ export async function handleResponsesCompact( } const summary = decoded; const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary); - 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 8cb50db821..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 (Object.hasOwn(config.providers, completedTarget.provider) + if (!options.manualCompactionOverride && Object.hasOwn(config.providers, completedTarget.provider) && provider && provider.disabled !== true && currentCombo?.targets.some(target => targetKey(target) === targetKey(completedTarget))) { rememberComboForLane(sessionLaneIdFromRequest(req.headers), comboId, completedTarget, model, writerGeneration); diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 4c790fd498..01ae19f1f0 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -11,6 +11,7 @@ import type { NativeMainRefreshDependencies } from "../../codex/main-account"; import type { InboundWire } from "../../providers/registry"; import type { ExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar"; import type { CallerDirectAuth } from "../../providers/caller-authorization"; +import type { ManualCompactionOverride } from "./manual-compaction"; import type { TranslatorBudget } from "../../lib/translator-budget"; import type { TransientSendBudget } from "../../lib/upstream-retry"; import type { RequestLogContext } from "../request-log"; @@ -105,6 +106,7 @@ export interface HandleResponsesOptions { callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + manualCompactionOverride?: ManualCompactionOverride | null; /** Internal combo handoff for one parent-validated continuation snapshot. */ comboReplaySnapshot?: { sourceBody: unknown; diff --git a/src/server/responses/manual-compaction.ts b/src/server/responses/manual-compaction.ts new file mode 100644 index 0000000000..8ee4915292 --- /dev/null +++ b/src/server/responses/manual-compaction.ts @@ -0,0 +1,86 @@ +import type { OcxConfig } from "../../types"; +import { isDeclaredReasoningEffort } from "../../reasoning-effort"; +import { routeConcreteModel, type RouteResult } from "../../router"; +import { resolveComboId } from "../../combos/identifiers"; +import { recallComboForLane } from "./combo-session-recall"; +import { sessionLaneIdFromRequest } from "../request-log-conversation"; + +/** `sourceModel` is the conversation's own selector before the rewrite. */ +export interface ManualCompactionOverride { + sourceModel: string; + /** Combo the lane remembers for a bare `sourceModel` (#3891); the conversation resumes there, not on the bare route. */ + sourceCombo?: string; + /** Combo the configured override resolves to; its children route concretely but stay portable. */ + targetCombo?: string; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +export interface ManualCompactionOverrideOptions { + /** `responses` requires a `compaction_trigger` input item; the native compact endpoint carries none. */ + endpoint?: "responses" | "compact"; + transport?: "websocket"; +} + +export function applyManualCompactionOverride( + body: unknown, + headers: Headers, + config: OcxConfig, + options: ManualCompactionOverrideOptions = {}, +): ManualCompactionOverride | null { + const override = config.manualCompaction; + const raw = record(body); + if (!raw || typeof raw.model !== "string" || !raw.model.trim() + || typeof override?.model !== "string" || !override.model.trim()) return null; + if (override.reasoningEffort !== undefined + && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return null; + if (options.endpoint !== "compact" + && !(Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"))) return null; + + const metadata: unknown[] = []; + const header = headers.get("x-codex-turn-metadata"); + if (options.transport !== "websocket" && header !== null) metadata.push(header); + const client = record(raw.client_metadata); + if (client && Object.hasOwn(client, "x-codex-turn-metadata")) metadata.push(client["x-codex-turn-metadata"]); + if (metadata.length === 0) return null; + for (const value of metadata) { + if (typeof value !== "string") return null; + try { + const parsed = record(JSON.parse(value)); + if (parsed?.request_kind !== "compaction" || record(parsed.compaction)?.trigger !== "manual") return null; + } catch { + return null; + } + } + + const sourceModel = raw.model; + const sourceCombo = recallComboForLane(config, sessionLaneIdFromRequest(headers), sourceModel); + const targetCombo = resolveComboId(config, override.model.trim()) ?? undefined; + raw.model = override.model.trim(); + if (override.reasoningEffort !== undefined) { + raw.reasoning = { ...record(raw.reasoning), effort: override.reasoningEffort }; + } + return { sourceModel, ...(sourceCombo ? { sourceCombo } : {}), ...(targetCombo ? { targetCombo } : {}) }; +} + +/** Same provider identity keeps caller auth and may use native compact; its ciphertext replays only there. */ +export function manualCompactionKeepsProviderIdentity( + config: OcxConfig, + override: ManualCompactionOverride, + route: RouteResult, +): boolean { + if (route.combo || override.sourceCombo || override.targetCombo || resolveComboId(config, override.sourceModel)) return false; + let source: RouteResult; + try { + source = routeConcreteModel(config, override.sourceModel); + } catch { + return false; + } + return source.providerName === route.providerName + && source.codexAccountMode === route.codexAccountMode + && source.codexAccountNamespace === route.codexAccountNamespace; +} diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 382ad7faa8..bcb8a5bbe1 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, manualCompactionKeepsProviderIdentity } from "./manual-compaction"; import { codexAuthContextLogLabel } from "../../codex/account-label"; import { conversationStateBindingFromAuth, @@ -147,6 +148,12 @@ export async function prepareResponsesRequest( } return decodeRequestErrorResponse(err, "responses"); } + if (!options.comboAttempt && !options.manualCompactionOverride && inboundWire === "responses") { + options.manualCompactionOverride = applyManualCompactionOverride(body, req.headers, config, { + endpoint: "responses", + transport: options.inboundTransport, + }); + } // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model. const comboRows = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body) @@ -178,7 +185,7 @@ export async function prepareResponsesRequest( } // Compaction may send the last client-visible bare model after a combo switch. // Configured selectors take precedence; otherwise recall before combo dispatch (#3891). - if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { + if (!options.comboAttempt && !options.manualCompactionOverride && body && typeof body === "object" && !Array.isArray(body)) { const rawModel = (body as { model?: unknown }).model; const rawInput = (body as { input?: unknown }).input; const isCompactionTrigger = Array.isArray(rawInput) @@ -200,7 +207,7 @@ export async function prepareResponsesRequest( // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG // LOOKUP so the check can never observe a one-candidate collapse. - if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { + if (!options.comboAttempt && !options.manualCompactionOverride && body && typeof body === "object" && !Array.isArray(body)) { const shadowIntercept = config.shadowCallIntercept; const rawShadowModel = (body as { model?: unknown }).model; if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string" @@ -371,7 +378,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 @@ -401,7 +408,7 @@ export async function prepareResponsesRequest( : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)); const _sci = config.shadowCallIntercept; let shadowRoute: RouteResult | undefined; - if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { + if (!options.manualCompactionOverride && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) { const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!; let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix }; try { @@ -429,8 +436,12 @@ export async function prepareResponsesRequest( shadowRoute = targetRoute; } } - if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true; + if (parsed._compactionRequest === true || options.manualCompactionOverride) parsed._cursorIsolateConversation = true; route = shadowRoute ?? resolveRoute(parsed.modelId); + if (options.manualCompactionOverride && !manualCompactionKeepsProviderIdentity(config, options.manualCompactionOverride, route)) { + credentialDomainWasRewritten = true; + if (parsed._compactionRequest === true) parsed._portableCompaction = true; + } logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index 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/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/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/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..2185c7ad95 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 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/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..39315461d9 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -662,3 +662,12 @@ Shared response-log retention and native SSE inspection pacing follow the [bound Native steering retains fixed phase deadlines and reconciled replay output; see the [steering stability contract](transports/streaming-health.md#steering-deadlines-and-replay-completeness). Native steering generation overrides, explicit public-API eligibility and the consent-gated wire probe follow the [shared control contract](transports/streaming-health.md#steering-settings-public-api-and-diagnostic-probe); this owner does not change routing or execute diagnostic tools. + +`manualCompaction` is a persisted configuration setting. Its model and optional effort follow the +[Responses trigger contract](transports/responses.md#manual-compaction-overrides). Dashboard Overview +provides model and effort selectors with an explicit Save action, a standing note that the selected +model's provider receives the entire conversation, and a warning naming that provider once a model +is chosen; for a combo selector the warning lists the combo's target providers from `GET /api/combos` +and states that failover targets receive the conversation too. `GET /api/settings` returns +the override or null; `PUT /api/settings` accepts a complete validated object or null to clear it. +Save failure restores live settings and deletion provenance; the dashboard retains the draft for retry. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 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..b331658f03 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -159,3 +159,7 @@ 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 a same-provider +[manual compaction model override](../transports/responses.md#manual-compaction-overrides); a +cross-provider override runs the portable summarizer on the selected provider instead. diff --git a/structure/runtime.md b/structure/runtime.md index 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..4182921521 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-read request body, before parsing, 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..d470c3d132 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -802,6 +802,41 @@ The [explicit model-capability contract](../config.md#explicit-per-model-capabil Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. +## Manual compaction overrides + +`src/server/responses/manual-compaction.ts` applies `manualCompaction` before model routing in +both `request-prepare.ts` and `compact.ts`. It requires explicit `request_kind: "compaction"` +and `compaction.trigger: "manual"` in `x-codex-turn-metadata`, supplied as a header or embedded +in Responses `client_metadata`, and on `/v1/responses` a `compaction_trigger` input item as +well, so metadata alone cannot move an ordinary turn. Every supplied metadata copy must agree; +malformed, absent, automatic, and ordinary-turn metadata leave the request unchanged. WebSocket requests use +only per-frame metadata; handshake headers can describe an earlier request. + +The override changes only the model and optional reasoning effort. Existing native forwarding, +routed summaries, capability handling, and retry budgets remain authoritative; native compact +still removes reasoning before sending. Internal handoffs carry the override record (with the +conversation's source model) as a recursion guard so combo children and fallback attempts +retain their selected targets. Manual overrides bypass shadow interception and conversation +combo recall, and do not publish replacement combo/handoff recall. They never change the +conversation's configured model or later automatic-compaction requests. + +`manualCompactionKeepsProviderIdentity` compares the source model's concrete route with the +selected route (provider name, Codex account mode and namespace; combos on either side never +match, and a bare source model the lane remembers as a combo target counts as a combo source, +recorded as `sourceCombo` when the override is applied, and a configured combo target is recorded as +`targetCombo` so its concretely routed children stay portable too). A matching identity keeps the caller's credential and may use the native compact +endpoint. A mismatch marks the credential domain as rewritten, exactly like a shadow +intercept, and forces the portable summarizer even for a native-capable target: `compact.ts` +skips `/responses/compact`, and `request-prepare.ts` sets `parsed._portableCompaction`, which +`request-sidecar-auth.ts` (`routedCompaction`) and the passthrough adapter's compaction body +build both honor for canonical ChatGPT destinations. Native ciphertext is replayable only by the +backend that minted it; the conversation model would otherwise resume with an omission marker +in place of its history. + +`tests/responses/responses-manual-compaction.test.ts` covers trigger selection, config validation, +native and routed handlers, same-provider credential retention, cross-provider portable summaries +and their replay, combo failover, and subsequent conversation settings. + ## Core module ownership `src/server/responses/core.ts` is the public ingress and compatibility-export surface. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 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..13c9907c0e 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -874,3 +874,50 @@ describe("config.json schema resilience", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + +describe("manual compaction settings", () => { + test("saves, reloads, replaces effort, and clears without changing other settings", async () => { + const config = baseConfig(); + config.effortCap = "high"; + const originalProviders = structuredClone(config.providers); + expect((await (await getSettings(config))!.json()).manualCompaction).toBeNull(); + const setting = { model: "gateway/cheap", reasoningEffort: "low" }; + const response = await putSettings(config, { manualCompaction: setting }); + expect(response?.status).toBe(200); + expect((await response!.json()).manualCompaction).toEqual(setting); + expect(loadConfig().manualCompaction).toEqual(setting); + expect((await (await getSettings(config))!.json()).manualCompaction).toEqual(setting); + await putSettings(config, { manualCompaction: { model: "gateway/cheap" } }); + expect(loadConfig().manualCompaction).toEqual({ model: "gateway/cheap" }); + await putSettings(config, { manualCompaction: null }); + expect(config.manualCompaction).toBeUndefined(); + expect(loadConfig().manualCompaction).toBeUndefined(); + expect(config.effortCap).toBe("high"); + expect(config.providers).toEqual(originalProviders); + expect((await (await getSettings(config))!.json()).manualCompaction).toBeNull(); + }); + + test("rejects malformed settings before any mutation", async () => { + const config = baseConfig(); + config.manualCompaction = { model: "gateway/cheap", reasoningEffort: "low" }; + const before = structuredClone(config); + for (const value of [false, [], {}, { model: " " }, { model: 2 }, { model: "m", reasoningEffort: "invalid" }, { model: "m", enabled: true }]) { + const response = await putSettings(config, { manualCompaction: value, streamMode: "eager-relay" }); + expect(response?.status).toBe(400); + expect(config).toEqual(before); + } + }); + + test("failed persistence restores the override and its deletion intent", async () => { + const { projectConfigRebaseProvenance } = await import("../../src/config/rebase-provenance"); + const config = baseConfig(); + config.manualCompaction = { model: "gateway/cheap", reasoningEffort: "low" }; + const before = projectConfigRebaseProvenance(config); + const deps = { saveConfigPreservingClaudeCode() { throw new Error("fixture save failure"); } }; + for (const value of [null, { model: "gateway/other" }]) { + await expect(putSettings(config, { manualCompaction: value }, deps)).rejects.toThrow("fixture save failure"); + expect(projectConfigRebaseProvenance(config)).toEqual(before); + expect(config.manualCompaction).toEqual({ model: "gateway/cheap", reasoningEffort: "low" }); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 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..1c06ede7d2 --- /dev/null +++ b/tests/responses/responses-manual-compaction.test.ts @@ -0,0 +1,522 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { applyManualCompactionOverride } from "../../src/server/responses/manual-compaction"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { clearCompactHandoffRoutesForTests } from "../../src/server/responses/compact"; +import { decodeCompactionSummary, SUMMARY_PREFIX } from "../../src/responses/compaction"; +import { getDefaultConfig, validateConfigCandidate } from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { warnDegradedManualCompaction } from "../../src/config/load-degrade"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboRecallForTests, recallComboForLane, rememberComboForLane } from "../../src/server/responses/combo-session-recall"; +import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +const metadata = (trigger = "manual", request_kind = "compaction") => + JSON.stringify({ request_kind, compaction: { trigger } }); + +function config(): OcxConfig { + return { + ...getDefaultConfig(), + defaultProvider: "gateway", + providers: { + gateway: { + adapter: "openai-responses", authMode: "key", + baseUrl: "https://gateway.example/v1", apiKey: "fixture-key", + }, + }, + manualCompaction: { model: "gateway/cheap", reasoningEffort: "low" }, + }; +} + +function body(compact = true): Record { + return { + model: "gateway/normal", stream: false, + reasoning: { effort: "high", summary: "auto" }, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Keep the task state." }] }, + ...(compact ? [{ type: "compaction_trigger" }] : []), + ], + }; +} + +function request(value: unknown, trigger?: string, path = "responses"): Request { + return new Request(`http://localhost/v1/${path}`, { + method: "POST", + headers: { + "content-type": "application/json", session_id: "manual-compaction-fixture", + ...(trigger ? { "x-codex-turn-metadata": metadata(trigger) } : {}), + }, + body: JSON.stringify(value), + }); +} + +function completion(summary = "Retain progress and resume the task."): Record { + return { + id: "resp_manual_fixture", status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: summary }] }], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }; +} + +function upstreamCompletion(input: Record): Response { + const response = { ...completion(), model: input.model }; + return input.stream + ? new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + : Response.json(response); +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearComboRecallForTests(); + clearCompactHandoffRoutesForTests(); +}); + +describe("manual compaction request selection", () => { + test.each(["header", "body", "both"])("uses explicit manual metadata from %s", location => { + const input = body(); + const history = structuredClone(input.input); + const headers = new Headers(); + if (location !== "body") headers.set("x-codex-turn-metadata", metadata()); + if (location !== "header") input.client_metadata = { "x-codex-turn-metadata": metadata() }; + expect(applyManualCompactionOverride(input, headers, config())).toEqual({ sourceModel: "gateway/normal" }); + expect(input.model).toBe("gateway/cheap"); + expect(input.reasoning).toEqual({ effort: "low", summary: "auto" }); + expect(input.input).toEqual(history); + }); + + test.each([ + undefined, "{", "null", "[]", metadata("auto"), metadata("manual", "turn"), + JSON.stringify({ compaction: { trigger: "manual" } }), + JSON.stringify({ request_kind: "compaction" }), + ])("does not override absent, malformed, automatic or ordinary metadata: %s", value => { + const input = body(); + const before = structuredClone(input); + const headers = new Headers(value === undefined ? {} : { "x-codex-turn-metadata": value }); + expect(applyManualCompactionOverride(input, headers, config())).toBeNull(); + expect(input).toEqual(before); + }); + + test("conflicting metadata cannot override automatic compaction", () => { + for (const [header, embedded] of [[metadata(), metadata("auto")], [metadata("auto"), metadata()], [metadata(), "{"], ["{", metadata()]]) { + const input = { ...body(), client_metadata: { "x-codex-turn-metadata": embedded } }; + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": header! }), config())).toBeNull(); + expect(input.model).toBe("gateway/normal"); + } + }); + + test("WebSocket frames use their own trigger and never reuse handshake metadata", () => { + for (const [handshake, frame, expected] of [ + ["auto", "manual", true], ["manual", "auto", false], ["manual", undefined, false], + ] as const) { + const input = body(); + if (frame) input.client_metadata = { "x-codex-turn-metadata": metadata(frame) }; + const headers = new Headers({ "x-codex-turn-metadata": metadata(handshake) }); + expect(applyManualCompactionOverride(input, headers, config(), { transport: "websocket" })).toEqual(expected ? { sourceModel: "gateway/normal" } : null); + expect(input.model).toBe(expected ? "gateway/cheap" : "gateway/normal"); + } + }); + + test("model-only configuration preserves the caller's reasoning", () => { + const input = body(); + const settings = config(); + settings.manualCompaction = { model: "gateway/cheap" }; + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toEqual({ sourceModel: "gateway/normal" }); + expect(input.reasoning).toEqual({ effort: "high", summary: "auto" }); + expect(settings.manualCompaction).toEqual({ model: "gateway/cheap" }); + }); + + test("unset configuration preserves manual compaction", () => { + const input = body(); + const before = structuredClone(input); + const settings = config(); + delete settings.manualCompaction; + expect(applyManualCompactionOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toBeNull(); + expect(input).toEqual(before); + }); + + test("manual metadata on an ordinary turn never rewrites the request", () => { + const input = body(false); + const before = structuredClone(input); + const headers = new Headers({ "x-codex-turn-metadata": metadata() }); + expect(applyManualCompactionOverride(input, headers, config())).toBeNull(); + expect(applyManualCompactionOverride(input, headers, config(), { endpoint: "responses" })).toBeNull(); + expect(input).toEqual(before); + expect(applyManualCompactionOverride(input, headers, config(), { endpoint: "compact" })).toEqual({ sourceModel: "gateway/normal" }); + expect(input.model).toBe("gateway/cheap"); + }); +}); + + +describe("manual compaction config", () => { + test("validates optional settings without resetting providers on malformed hand edits", () => { + expect(validateConfigCandidate(config()).ok).toBe(true); + for (const value of [null, {}, [], "cheap", { model: " " }, { model: 42 }, + { model: "gateway/cheap", reasoningEffort: "invalid" }, { model: "gateway/cheap", typo: true }]) { + const raw = { ...config(), manualCompaction: value }; + expect(validateConfigCandidate(raw).ok).toBe(false); + const loaded = configSchema.parse(raw); + expect(loaded.manualCompaction).toBeUndefined(); + expect(loaded.providers).toEqual(config().providers); + } + }); + + test("a dropped hand-edited block warns at load; valid or absent blocks stay silent", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (message: unknown) => { warnings.push(String(message)); }; + try { + const invalid = { ...config(), manualCompaction: { model: "gateway/cheap", reasoningEffort: "Low" } }; + warnDegradedManualCompaction(invalid, configSchema.parse(invalid) as OcxConfig); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("manualCompaction is invalid"); + warnDegradedManualCompaction(config(), configSchema.parse(config()) as OcxConfig); + const absent = config(); + delete absent.manualCompaction; + warnDegradedManualCompaction(absent, configSchema.parse(absent) as OcxConfig); + expect(warnings).toHaveLength(1); + } finally { + console.warn = original; + } + }); +}); + +describe("manual compaction reuses existing handlers", () => { + test("an ordinary turn carrying manual metadata stays on the conversation model", async () => { + const settings = config(); + const calls: string[] = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push(input.model); + return upstreamCompletion(input); + }) as typeof fetch; + const response = await handleResponses(request(body(false), "manual"), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(calls).toEqual(["normal"]); + }); + + test.each(["v1", "v2", "v2-body", "v2-websocket"])("%s changes only the manual request and returns the existing summary format", async version => { + const settings = config(); + const saved = structuredClone(settings); + const calls: Array> = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json(completion()); + }) as typeof fetch; + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const input = body(version !== "v1"); + if (version === "v2-body" || version === "v2-websocket") input.client_metadata = { "x-codex-turn-metadata": metadata() }; + const manualRequest = request(input, version === "v2-body" ? undefined : version === "v2-websocket" ? "auto" : "manual"); + const logCtx = { model: "", provider: "" } as { model: string; provider: string; requestedModel?: string }; + const response = version === "v2-websocket" + ? await handleResponses(manualRequest, settings, logCtx, { inboundTransport: "websocket" }) + : await handler(manualRequest, settings, logCtx); + const result = await response.json() as { output: Array> }; + expect(response.status).toBe(200); + expect(logCtx.requestedModel).toBe("gateway/normal"); + expect(logCtx.model).toBe("cheap"); + expect(calls[0]!.model).toBe("cheap"); + expect(calls[0]!.reasoning.effort).toBe("low"); + if (version === "v1") expect(JSON.stringify(result.output)).toContain(SUMMARY_PREFIX); + else expect(decodeCompactionSummary(result.output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + + const automatic = await handler(request(body(version !== "v1"), "auto"), settings, { model: "", provider: "" }); + expect(automatic.status).toBe(200); + await automatic.text(); + const resumedBody = body(false); + resumedBody.input = [...result.output, ...resumedBody.input]; + const resumed = await handleResponses(request(resumedBody), settings, { model: "", provider: "" }); + expect(resumed.status).toBe(200); + await resumed.text(); + expect(calls.map(call => [call.model, call.reasoning.effort])).toEqual([ + ["cheap", "low"], ["normal", "high"], ["normal", "high"], + ]); + expect(JSON.stringify(calls[2]!.input)).toContain("Retain progress"); + expect(JSON.stringify(calls[2]!.input)).not.toContain("ocx1:"); + expect(settings).toEqual(saved); + expect(input.model).toBe("gateway/normal"); + }); + + test("native v2 keeps caller authentication and forwards the existing compaction request", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.manualCompaction = { model: "gpt-5.6-luna", reasoningEffort: "low" }; + const input = body(); + input.model = "gpt-6-astra"; + input.stream = true; + const req = request(input, "manual"); + const authorization = `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`; + req.headers.set("authorization", authorization); + req.headers.set("chatgpt-account-id", "fixture-account"); + const calls: Array<{ body: Record; authorization: string | null }> = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + calls.push({ body: JSON.parse(String(init?.body)), authorization: new Headers(init?.headers).get("authorization") }); + const response = { ...completion(), model: "gpt-5.6-luna", output: [{ type: "compaction", encrypted_content: "native-summary" }] }; + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("native-summary"); + expect(calls).toHaveLength(1); + expect(calls[0]!.authorization).toBe(authorization); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(calls[0]!.body.reasoning.effort).toBe("low"); + expect(calls[0]!.body.input).toContainEqual({ type: "compaction_trigger" }); + }); + + test("same-provider native compact retains its existing endpoint and reasoning behavior", async () => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna", reasoningEffort: "low" }; + const calls: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }); + return Response.json({ output: [{ type: "compaction", encrypted_content: "native-summary" }] }); + }) as typeof fetch; + const input = { ...body(false), model: "openai-apikey/gpt-6-astra" }; + const response = await handleResponsesCompact(request(input, "manual", "responses/compact"), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ output: [{ type: "compaction", encrypted_content: "native-summary" }] }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://api.openai.com/v1/responses/compact"); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(calls[0]!.body.reasoning).toBeUndefined(); + }); + + test.each(["v1", "v2"])("%s cross-provider override produces a summary the conversation model can replay", async version => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna", reasoningEffort: "low" }; + const calls: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), body: input }); + if (String(url).endsWith("/compact")) return Response.json({ output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }); + return upstreamCompletion(input); + }) as typeof fetch; + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const response = await handler(request(body(version !== "v1"), "manual"), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const result = await response.json() as { output: Array> }; + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://api.openai.com/v1/responses"); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(calls[0]!.body.reasoning.effort).toBe("low"); + expect(JSON.stringify(result.output)).not.toContain("native-ciphertext"); + if (version === "v1") expect(JSON.stringify(result.output)).toContain(SUMMARY_PREFIX); + else expect(decodeCompactionSummary(result.output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + + const resumedBody = body(false); + resumedBody.input = [...result.output, ...resumedBody.input]; + const resumed = await handleResponses(request(resumedBody), settings, { model: "", provider: "" }); + expect(resumed.status).toBe(200); + await resumed.text(); + expect(calls[1]!.url).toBe("https://gateway.example/v1/responses"); + expect(calls[1]!.body.model).toBe("normal"); + expect(JSON.stringify(calls[1]!.body.input)).toContain("Retain progress"); + expect(JSON.stringify(calls[1]!.body.input)).not.toContain("cannot read"); + }); + + test("same-provider override keeps a caller-supplied bearer; a cross-provider override drops it", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.manualCompaction = { model: "gpt-5.6-luna", reasoningEffort: "low" }; + const seen: Array = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("authorization")); + return upstreamCompletion(JSON.parse(String(init?.body))); + }) as typeof fetch; + for (const [sourceModel, expectedStatus] of [["gpt-6-astra", 200], ["gateway/normal", 401]] as const) { + const input = { ...body(), model: sourceModel, stream: true }; + const req = request(input, "manual"); + req.headers.set("authorization", "Bearer opaque-caller-token"); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(expectedStatus); + await response.text(); + } + expect(seen).toEqual(["Bearer opaque-caller-token"]); + }); + + test("a ChatGPT target for a routed conversation runs the portable summarizer instead of native compaction", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.manualCompaction = { model: "gpt-5.6-luna", reasoningEffort: "low" }; + const calls: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), body: input }); + if (String(url).endsWith("/compact") || JSON.stringify(input.input).includes("compaction_trigger")) { + const response = { ...completion(), model: input.model, output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }; + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return upstreamCompletion(input); + }) as typeof fetch; + const jwt = `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`; + for (const version of ["v1", "v2"] as const) { + calls.length = 0; + const req = request(body(version === "v2"), "manual", version === "v1" ? "responses/compact" : "responses"); + req.headers.set("authorization", jwt); + req.headers.set("chatgpt-account-id", "fixture-account"); + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const response = await handler(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("native-ciphertext"); + const output = (JSON.parse(text) as { output: Array> }).output; + if (version === "v1") expect(JSON.stringify(output)).toContain(SUMMARY_PREFIX); + else expect(decodeCompactionSummary(output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(calls[0]!.body.model).toBe("gpt-5.6-luna"); + expect(JSON.stringify(calls[0]!.body.input)).not.toContain("compaction_trigger"); + + const resumedBody = body(false); + resumedBody.input = [...output, ...resumedBody.input]; + const resumed = await handleResponses(request(resumedBody), settings, { model: "", provider: "" }); + expect(resumed.status).toBe(200); + await resumed.text(); + expect(calls[1]!.url).toBe("https://gateway.example/v1/responses"); + expect(JSON.stringify(calls[1]!.body.input)).toContain("Retain progress"); + } + }); + + test("manual quota failure cannot borrow the conversation's automatic handoff target", async () => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna" }; + const calls: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push(input.model); + return String(url).endsWith("/compact") + ? Response.json({ error: { message: "quota exceeded", code: "insufficient_quota" } }, { status: 429 }) + : upstreamCompletion(input); + }) as typeof fetch; + const seed = await handleResponsesCompact(request(body(false), "auto"), settings, { model: "", provider: "" }); + expect(seed.status).toBe(200); + await seed.text(); + const manual = await handleResponsesCompact(request({ ...body(false), model: "openai-apikey/gpt-6-astra" }, "manual"), settings, { model: "", provider: "" }); + expect(manual.status).toBe(429); + await manual.text(); + expect(calls).toEqual(["normal", "gpt-5.6-luna"]); + + const automatic = await handleResponsesCompact(request({ ...body(false), model: "openai-apikey/gpt-6-astra" }, "auto"), settings, { model: "", provider: "" }); + expect(automatic.status).toBe(200); + await automatic.text(); + expect(calls).toEqual(["normal", "gpt-5.6-luna", "gpt-6-astra", "normal"]); + }); + + test.each(["v1", "v2"])("%s combo override preserves failover and the conversation's remembered combo", async version => { + const settings = config(); + settings.combos = { + normal: { targets: [{ provider: "gateway", model: "normal" }] }, + compact: { strategy: "failover", targets: [{ provider: "gateway", model: "unavailable" }, { provider: "gateway", model: "cheap" }] }, + }; + settings.manualCompaction = { model: "combo/compact", reasoningEffort: "low" }; + const req = request(body(version !== "v1"), "manual"); + const lane = sessionLaneIdFromRequest(req.headers); + rememberComboForLane(lane, "normal", { provider: "gateway", model: "normal" }, "normal", captureConfigGeneration()); + const calls: string[] = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + const model = JSON.parse(String(init?.body)).model; + calls.push(model); + if (model === "unavailable") return Response.json({ error: { + type: "invalid_request_error", code: "unsupported_value", param: "reasoning.effort", + message: "Unsupported value: 'low' is not supported with this model. Supported values are: 'medium', 'high'.", + } }, { status: 400 }); + return upstreamCompletion(JSON.parse(String(init?.body))); + }) as typeof fetch; + const handler = version === "v1" ? handleResponsesCompact : handleResponses; + const response = await handler(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(calls).toEqual(["unavailable", "cheap"]); + expect(recallComboForLane(settings, lane, "normal")).toBe("normal"); + }); + + test("a bare source model remembered as a combo target takes the portable path even on a same-provider native override", async () => { + const settings = config(); + settings.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-key", + }; + settings.combos = { fast: { targets: [{ provider: "gateway", model: "normal" }] } }; + settings.defaultProvider = "openai-apikey"; + settings.manualCompaction = { model: "openai-apikey/gpt-5.6-luna", reasoningEffort: "low" }; + const req = request({ ...body(false), model: "normal" }, "manual", "responses/compact"); + const lane = sessionLaneIdFromRequest(req.headers); + rememberComboForLane(lane, "fast", { provider: "gateway", model: "normal" }, "normal", captureConfigGeneration()); + const calls: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + calls.push(String(url)); + if (String(url).endsWith("/compact")) return Response.json({ output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }); + return upstreamCompletion(JSON.parse(String(init?.body))); + }) as typeof fetch; + const response = await handleResponsesCompact(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("native-ciphertext"); + expect(text).toContain(SUMMARY_PREFIX); + expect(calls).toEqual(["https://api.openai.com/v1/responses"]); + expect(recallComboForLane(settings, lane, "normal")).toBe("fast"); + }); + + test("a combo override whose same-provider child is canonical ChatGPT still runs the portable summarizer", async () => { + const settings = config(); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + settings.combos = { compact: { targets: [{ provider: "openai", model: "gpt-5.6-luna" }] } }; + settings.manualCompaction = { model: "combo/compact", reasoningEffort: "low" }; + const calls: Array<{ url: string; input: string }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), input: JSON.stringify(input.input) }); + if (calls.at(-1)!.input.includes("compaction_trigger")) { + const response = { ...completion(), model: input.model, output: [{ type: "compaction", encrypted_content: "native-ciphertext" }] }; + return new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return upstreamCompletion(input); + }) as typeof fetch; + const req = request({ ...body(), model: "gpt-6-astra" }, "manual"); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + req.headers.set("chatgpt-account-id", "fixture-account"); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).not.toContain("native-ciphertext"); + const output = (JSON.parse(text) as { output: Array> }).output; + expect(decodeCompactionSummary(output.find(item => item.type === "compaction")!.encrypted_content)).toContain("Retain progress"); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(calls[0]!.input).not.toContain("compaction_trigger"); + }); +});