From 5676ee69f9fd94aa2b3328970808d9ac2b76d31b Mon Sep 17 00:00:00 2001 From: yuanyuanlove Date: Thu, 8 Oct 2026 18:34:41 +0800 Subject: [PATCH 1/3] feat(responses): configure native reasoning retention across model and account switches --- .../src/content/docs/guides/web-dashboard.md | 1 + .../content/docs/reference/configuration.md | 4 + .../docs/reference/configuration/server.md | 22 +++ .../content/docs/reference/management-api.md | 1 + .../docs/zh-cn/guides/web-dashboard.md | 1 + .../docs/zh-cn/reference/configuration.md | 19 ++ .../docs/zh-cn/reference/management-api.md | 1 + .../NativeReasoningRetentionPanel.tsx | 102 +++++++++++ gui/src/i18n/de.ts | 9 + gui/src/i18n/en.ts | 9 + gui/src/i18n/fr.ts | 9 + gui/src/i18n/ja.ts | 9 + gui/src/i18n/ko.ts | 9 + gui/src/i18n/pt.ts | 9 + gui/src/i18n/ru.ts | 9 + gui/src/i18n/tr.ts | 9 + gui/src/i18n/vi.ts | 9 + gui/src/i18n/zh-TW.ts | 9 + gui/src/i18n/zh.ts | 9 + gui/src/pages/dashboard-overview-panels.tsx | 2 + .../native-reasoning-retention-panel.test.tsx | 172 ++++++++++++++++++ scripts/test-layout/layout.json | 6 +- src/adapters/openai-responses/passthrough.ts | 4 +- src/config/diagnostics.ts | 3 + src/config/load-degrade.ts | 2 + src/config/schema/config-schema.ts | 2 + .../schema/native-reasoning-retention.ts | 26 +++ src/responses/reasoning-replay-cache.ts | 26 ++- src/server/management-api.ts | 2 + .../native-reasoning-retention-routes.ts | 41 +++++ src/server/management/route-registry.ts | 2 + src/server/responses/account-change-state.ts | 10 +- src/server/responses/core-codex-account.ts | 2 + src/server/responses/core-opaque-recovery.ts | 1 + src/server/responses/core-replay.ts | 17 +- src/server/responses/request-prepare.ts | 6 + src/server/responses/run-turn-execution.ts | 2 + src/types/config.ts | 2 + src/types/request.ts | 4 + structure/INDEX.md | 13 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/manifest.json | 38 ++-- structure/providers/openai-accounts.md | 2 + .../transports/native-reasoning-retention.md | 19 ++ structure/transports/responses.md | 6 +- .../reasoning-replay-identity.test.ts | 17 ++ tests/cli/cli-headless-parity.test.ts | 1 + .../config-native-reasoning-retention.test.ts | 71 ++++++++ tests/fixtures/test-layout-expected.json | 5 +- .../responses-account-change-scrub.test.ts | 38 ++++ .../responses-opaque-blob-recovery.test.ts | 147 +++++++++++++-- .../server-native-reasoning-retention.test.ts | 127 +++++++++++++ 53 files changed, 1025 insertions(+), 45 deletions(-) create mode 100644 gui/src/components/NativeReasoningRetentionPanel.tsx create mode 100644 gui/tests/native-reasoning-retention-panel.test.tsx create mode 100644 src/config/schema/native-reasoning-retention.ts create mode 100644 src/server/management/native-reasoning-retention-routes.ts create mode 100644 structure/transports/native-reasoning-retention.md create mode 100644 tests/config/config-native-reasoning-retention.test.ts create mode 100644 tests/server/server-native-reasoning-retention.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index d25c15a07ef..2daaeaace41 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -111,6 +111,7 @@ is visible) and never forces an upstream refresh. | Area | What it does | | --- | --- | | **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, active providers, and available native/routed models. | +| **Native reasoning retention** | Independently opt into reasoning ciphertext forwarding on native model or account changes. Both default off; account retention is experimental. See [the configuration scope](/reference/configuration/server/#native-reasoning-retention). | | **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. In the desktop shell, its update entry opens the native app update page instead of running the package updater. | diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 06cf68b87c6..baba267dadf 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -48,6 +48,10 @@ Aliases are optional short request names. They never change the native model id Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. Codex model pickers show the qualified alias while preserving the canonical `provider/model` routing id. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern. +### Native reasoning retention + +Native reasoning ciphertext retention on model and account changes has two opt-in settings, both off by default. Configure them on the dashboard overview or see [Native reasoning retention](/reference/configuration/server/#native-reasoning-retention) for the exact scope and experimental account behavior. + ### Cursor effort rows `cursorEffortRows` is an optional boolean and defaults to `false`. When enabled, the raw OpenAI-style diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 50864587924..c912bbbc35d 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -23,6 +23,7 @@ runs helper features around provider requests. | `websockets?` | `boolean` | `false` | Advertise and admit the client-facing Responses WebSocket path. False keeps clients on HTTP/SSE; it does not disable an eligible canonical ChatGPT upstream WS optimization. Complete-input requests may reuse an upstream connection within the same selected credential, account, thread and turn; changed handshake policy or missing identity keeps requests on separate connections. This does not trim HTTP input or create previous-response IDs. | | `codexNativeSteering?` | `boolean` | `false` | Experimental, native-only mid-turn steering on the Responses WebSocket endpoint. Requires `websockets: true`, a compatible upstream/client, and a pinned account/model/tool surface. Validated generation settings can change in explicit saved-result continuations. Does not enable translated models or HTTP fallback. See [native steering](/guides/codex-integration/#experimental-native-mid-turn-steering). | | `codexNativeInjection?` | `boolean` | `false` | Experimental saved function-result injection on compatible native multi-agent WebSocket turns. Requires `websockets: true`, explicit `multi_agent.enabled`, and an eligible provider. Separate from steering; no automatic tool rerun or recovery create. See [native injection](/guides/codex-integration/#experimental-native-function-result-injection). | +| `nativeReasoningRetention?` | `{ modelSwitch?: boolean; accountSwitch?: boolean }` | both `false` | Permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT backend. Account retention is experimental. See [Native reasoning retention](#native-reasoning-retention). | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` data-plane admission credentials on non-loopback binds. They do not authorize management APIs; management access uses the separate credential documented in the [management reference](/reference/management-api/). Dashboard-managed. | | `storageCleanupPolicy?` | `StorageCleanupPolicy` | disabled | Opt-in archived-session cleanup policy. Never enabled implicitly. | @@ -48,6 +49,27 @@ runs helper features around provider requests. | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. | | `images?` | `OcxImagesConfig` | automatic OpenAI selection | Standalone Images relay options for Codex `image_gen`. | +## Native reasoning retention + +The dashboard overview exposes two independent opt-ins: retain reasoning when switching models, and retain it when switching accounts. Both default off. The same policy can be saved in `config.json`: + +```jsonc +{ + "nativeReasoningRetention": { + "modelSwitch": true, + "accountSwitch": false + } +} +``` + +This example retains reasoning `encrypted_content` on a known model switch within the same native ChatGPT destination and credential. Set `accountSwitch` to `true` only to opt into experimental cross-account forwarding. When both model and account change, both switches must be enabled. The policy applies to each actual native Combo target and pool retry; it does not grant retention to third-party or API-key destinations. + +Retention means that OpenCodex may forward the carried ciphertext. It cannot confirm that the upstream accepts or reuses that reasoning. A detected decrypt rejection still enters the existing bounded cleanup recovery, and the rejection memo takes precedence over either switch. Response and conversation references, foreign reasoning item ids, uploaded-file account restrictions and cache isolation continue using their own ownership rules. Native compaction blocks keep their existing cleanup behavior; direct native `/v1/responses/compact` sanitization is unchanged. + +Use `GET /api/native-reasoning-retention` to read the resolved booleans and `PUT` with a partial boolean object to update them. `PUT` with JSON `null` restores both defaults. Invalid fields or unknown keys are rejected; a malformed file block disables retention with a warning. Dashboard/API changes apply to subsequent requests. Local `ocx config set/unset` edits require a restart to converge the running proxy. + +## Upstream transport recovery + While the canonical ChatGPT upstream WebSocket waits for the first Responses event after sending the create frame, it watches for liveness rather than a fixed deadline. When the socket supports protocol pings, the proxy pings it every 15 seconds. Any inbound frame — quota, diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 5b7e28afe56..f7a82e6ba8d 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -264,6 +264,7 @@ See [Codex prompt layers](/guides/codex-prompt/) for the layer model and the key | `GET /api/config` | Return the redacted, management-safe configuration DTO | — | | `PUT /api/config` | Disabled full-config replacement guard | 405; use focused endpoints instead | | `GET, PUT /api/settings` | Read runtime/startup settings or update auto-start, stream mode, app-owned memory budget, and `codexAccountPickerEnabled` | 400 invalid, non-object, or empty update | +| `GET, PUT /api/native-reasoning-retention` | Read resolved model/account reasoning-retention booleans; partial PUT preserves omitted fields, JSON `null` restores defaults. See [native reasoning retention](/reference/configuration/server/#native-reasoning-retention). | 400 unknown field, invalid type or non-object/non-null body | | `GET /api/startup-health` | Read cached service/shim startup health | — | | `POST /api/startup-action` | Install or repair the service or Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Read Windows tray state or install/start/stop/uninstall it | 400 unsupported platform/action; 500 operation failure | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 0d8f285caa4..a05bff80d9b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -43,6 +43,7 @@ bun run dev:gui | 区域 | 作用 | | --- | --- | | **Dashboard 摘要** | 显示 multi-agent 模式、在线状态、版本、运行时间、provider 数量、30 天 token 总量、活动 provider 和可用的原生/路由模型。 | +| **原生推理密文保留** | 独立开启原生模型/账号切换时的推理密文转发;默认均关闭,跨账号保留属于实验性选项。见 [配置范围](/zh-cn/reference/configuration/#原生推理密文保留)。 | | **Sub-agent delegation** | 选择供 OpenCodex 委派指引与可选的 Codex 原生子代理默认值共用的原生/路由模型和可选 reasoning 强度。它不是逐次生成的路由器,详见下文。 | | **Sidecar** | 选择 web-search 模型及强度,以及图像描述模型;更改从下一次请求开始生效。 | | **Maintenance** | 重新同步 Codex 模型目录,查看项目级配置绕过警告,检查 latest/preview 版本,并可在更新后重启代理。 在桌面 shell 中,该更新入口会打开原生应用更新页面,而不是运行包更新器。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 66327ddc41c..c8538ee8168 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -34,6 +34,25 @@ opencodex 会把持久化配置存放在 `$OPENCODEX_HOME/config.json`,通常 路由有自己独立的顺序化解析规则;见 [Routing](/reference/configuration/routing/)。 +## 原生推理密文保留 + +仪表盘概览提供“切换模型时保留”和“切换账号时保留”两个独立开关,默认均关闭。也可以在 `config.json` 中配置: + +```jsonc +{ + "nativeReasoningRetention": { + "modelSwitch": true, + "accountSwitch": false + } +} +``` + +上面的例子允许在同一原生 ChatGPT 目标和凭据下,发生已知模型切换时转发推理 `encrypted_content`。跨账号保留属于实验性选项;模型和账号同时变化时必须同时开启两个开关。策略按实际选中的原生 Combo 成员或账号池重试目标判断,不适用于第三方或 API-key provider。 + +允许转发不代表上游一定接受或复用推理。已检测到的解密拒绝仍执行现有的有限清理恢复,拒绝缓存优先于开关。旧响应和会话引用、外部推理项 ID、上传文件的账号限制及缓存隔离保持各自的归属规则。原生压缩块继续沿用现有清理行为,原生 `/v1/responses/compact` 的直接转发清理也不改变。 + +仪表盘和 `PUT /api/native-reasoning-retention` 的修改对后续请求生效。PUT 接受部分布尔字段,JSON `null` 恢复默认值;未知字段或类型错误会被拒绝。使用本地 `ocx config set/unset` 修改后需重启代理。完整说明见 [Native reasoning retention](/reference/configuration/server/#native-reasoning-retention)。 + ## 配置域 - [Providers](/reference/configuration/providers/) — provider 条目、认证、端点、目录、allowlist、上下文限制、配额和 provider 特定选项。 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index a590806cd01..0ca3cce0642 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -162,6 +162,7 @@ Aside 配置档的变更在这种情况下仍会保存一件事:确认之后 | `GET /api/config` | 返回已脱敏、对管理安全的配置 DTO | — | | `PUT /api/config` | 禁用的完整配置替换保护 | 405;请改用聚焦端点 | | `GET, PUT /api/settings` | 读取运行时/启动设置,或更新自动启动、流模式、应用拥有的内存预算和 `codexAccountPickerEnabled` | 400 无效、非对象或空更新 | +| `GET, PUT /api/native-reasoning-retention` | 读取模型/账号推理保留开关;部分 PUT 保留未提交字段,JSON `null` 恢复默认值。见 [原生推理密文保留](/zh-cn/reference/configuration/#原生推理密文保留)。 | 400 未知字段、无效类型或非对象/非 null 输入 | | `GET /api/startup-health` | 读取缓存的服务/shim 启动健康状态 | — | | `POST /api/startup-action` | 安装或修复服务或 Codex shim | 400 无效动作;500 动作失败 | | `GET, POST /api/windows-tray` | 读取 Windows 托盘状态,或安装、启动、停止、卸载它 | 400 不支持的平台/动作;500 操作失败 | diff --git a/gui/src/components/NativeReasoningRetentionPanel.tsx b/gui/src/components/NativeReasoningRetentionPanel.tsx new file mode 100644 index 00000000000..7729c050394 --- /dev/null +++ b/gui/src/components/NativeReasoningRetentionPanel.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../i18n/shared"; +import { Switch } from "../ui"; +import { createBoundedFetch } from "../bounded-fetch"; +import { requireJson } from "../pages/dashboard-shared"; + +interface Settings { modelSwitch: boolean; accountSwitch: boolean } +function readSettings(payload: unknown): Settings { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid reasoning settings"); + const value = payload as Record; + if (typeof value.modelSwitch !== "boolean" || typeof value.accountSwitch !== "boolean") { + throw new Error("Missing reasoning settings"); + } + return { modelSwitch: value.modelSwitch, accountSwitch: value.accountSwitch }; +} + +export default function NativeReasoningRetentionPanel({ apiBase }: { apiBase: string }) { + return ; +} + +function NativeReasoningRetentionControls({ apiBase }: { apiBase: string }) { + const t = useT(); + const [saved, setSaved] = useState(); + const [modelSwitch, setModelSwitch] = useState(false); + const [accountSwitch, setAccountSwitch] = useState(false); + const [busy, setBusy] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); + const active = useRef(false); + const pending = useRef | null>(null); + const accept = useCallback((value: Settings) => { + setSaved(value); setModelSwitch(value.modelSwitch); setAccountSwitch(value.accountSwitch); + }, []); + 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/native-reasoning-retention`, { signal: request.signal }); + const value = readSettings(await requireJson(response)); + if (active.current && pending.current === request) accept(value); + } catch { + if (active.current && pending.current === request) setLoadError(true); + } finally { + request.clear(); + if (pending.current === request) pending.current = null; + } + }, [apiBase, accept]); + useEffect(() => { + active.current = true; + const timer = window.setTimeout(() => { void load(); }, 0); + return () => { + window.clearTimeout(timer); active.current = false; + pending.current?.controller.abort(); pending.current?.clear(); pending.current = null; + }; + }, [load]); + const save = async (reset = false) => { + 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/native-reasoning-retention`, { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: request.signal, + body: JSON.stringify(reset ? null : { modelSwitch, accountSwitch }), + }); + const value = readSettings(await requireJson(response)); + if (active.current && pending.current === request) { accept(value); setFeedback("saved"); } + } catch { + if (active.current && pending.current === request) setFeedback("failed"); + } finally { + request.clear(); + if (active.current && pending.current === request) setBusy(false); + if (pending.current === request) pending.current = null; + } + }; + const disabled = busy || saved === undefined || loadError; + const changed = modelSwitch !== saved?.modelSwitch || accountSwitch !== saved?.accountSwitch; + return
+
{t("nativeReasoningRetention.title")}
+

{t("nativeReasoningRetention.description")}

+
+
+ {t("nativeReasoningRetention.modelSwitch")} + { setModelSwitch(value => !value); setFeedback(null); }} /> +
+
+ {t("nativeReasoningRetention.accountSwitch")} + { setAccountSwitch(value => !value); setFeedback(null); }} /> +
+

{t("nativeReasoningRetention.accountHint")}

+
+ + +
+
+ {loadError &&
{t("nativeReasoningRetention.loadFailed")}
} + {feedback === "failed" &&
{t("nativeReasoningRetention.saveFailed")}
} + {feedback === "saved" &&
{t("nativeReasoningRetention.saved")}
} +
; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b451fd11332..875e7810dc7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,15 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "nativeReasoningRetention.title": "Native OpenAI-Denkdaten behalten", + "nativeReasoningRetention.description": "Nur für native Anfragen an das offizielle ChatGPT-Backend, auch bei tatsächlichen Zielwechseln in Combos und Kontopools. Alte verschlüsselte Denkdaten können erneut gesendet werden; die Wiederverwendung hängt vom Backend ab. Beide Optionen sind standardmäßig aus.", + "nativeReasoningRetention.modelSwitch": "Verschlüsselte Denkdaten bei Modellwechsel behalten", + "nativeReasoningRetention.accountSwitch": "Verschlüsselte Denkdaten bei Kontowechsel behalten (experimentell)", + "nativeReasoningRetention.accountHint": "Das Behalten der Daten gibt anderen Konten keinen Zugriff auf alte Antwort-IDs oder Dateien. Eine erfolgreiche Anfrage beweist keine Wiederverwendung der Denkdaten. Die native Kontextkomprimierung bleibt unverändert.", + "nativeReasoningRetention.reset": "Standard wiederherstellen", + "nativeReasoningRetention.loadFailed": "Native Denkdateneinstellungen konnten nicht geladen werden.", + "nativeReasoningRetention.saveFailed": "Native Denkdateneinstellungen konnten nicht gespeichert werden.", + "nativeReasoningRetention.saved": "Native Denkdateneinstellungen gespeichert.", "nav.claude": "Claude", "claude.pageSub": "OpenCodex-Routing und Einstellungen für Claude Code.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d272be88d0c..b7aea1a3261 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,15 @@ * `{var}` are plain interpolations. */ export const en = { + "nativeReasoningRetention.title": "Native OpenAI reasoning retention", + "nativeReasoningRetention.description": "Only for native requests to the official ChatGPT backend, including actual target changes in Combos and account pools. Allows old encrypted reasoning to be replayed; reuse depends on the backend. Both options are off by default.", + "nativeReasoningRetention.modelSwitch": "Keep encrypted reasoning when switching models", + "nativeReasoningRetention.accountSwitch": "Keep encrypted reasoning when switching accounts (experimental)", + "nativeReasoningRetention.accountHint": "Preserving ciphertext does not share stored response IDs or files across accounts. A successful request does not prove reasoning reuse. Native compaction handling is unchanged.", + "nativeReasoningRetention.reset": "Restore defaults", + "nativeReasoningRetention.loadFailed": "Could not load native reasoning settings.", + "nativeReasoningRetention.saveFailed": "Could not save native reasoning settings.", + "nativeReasoningRetention.saved": "Native reasoning settings saved.", "nav.claude": "Claude", "claude.pageSub": "OpenCodex routing and settings for Claude Code.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 274bf6b17af..4ab11f8bf79 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,15 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "nativeReasoningRetention.title": "Conservation du raisonnement natif OpenAI", + "nativeReasoningRetention.description": "Uniquement pour les requêtes natives vers le backend officiel ChatGPT, y compris les changements de cible dans les Combos et les pools de comptes. Permet de renvoyer le raisonnement chiffré antérieur ; sa réutilisation dépend du backend. Les deux options sont désactivées par défaut.", + "nativeReasoningRetention.modelSwitch": "Conserver le raisonnement chiffré lors du changement de modèle", + "nativeReasoningRetention.accountSwitch": "Conserver le raisonnement chiffré lors du changement de compte (expérimental)", + "nativeReasoningRetention.accountHint": "Conserver le texte chiffré ne donne pas aux autres comptes accès aux anciens identifiants de réponse ou fichiers. Une requête réussie ne prouve pas la réutilisation du raisonnement. Le traitement de la compression native reste inchangé.", + "nativeReasoningRetention.reset": "Rétablir les valeurs par défaut", + "nativeReasoningRetention.loadFailed": "Impossible de charger les paramètres du raisonnement natif.", + "nativeReasoningRetention.saveFailed": "Impossible de sauvegarder les paramètres du raisonnement natif.", + "nativeReasoningRetention.saved": "Paramètres du raisonnement natif sauvegardés.", "nav.claude": "Claude", "claude.pageSub": "Routage et paramètres OpenCodex pour Claude Code.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c6eb3da49af..feebe74ae25 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,15 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "nativeReasoningRetention.title": "OpenAI ネイティブ推論の保持", + "nativeReasoningRetention.description": "公式 ChatGPT バックエンドへのネイティブリクエストにのみ適用され、Combo やアカウントプール内の実際の宛先変更も含みます。以前の暗号化推論を再送できますが、再利用はバックエンドに依存します。両方とも既定ではオフです。", + "nativeReasoningRetention.modelSwitch": "モデル切り替え時に暗号化推論を保持", + "nativeReasoningRetention.accountSwitch": "アカウント切り替え時に暗号化推論を保持(実験的)", + "nativeReasoningRetention.accountHint": "暗号文を保持しても、別のアカウントから以前の応答 ID やファイルにアクセスできるわけではありません。リクエストの成功は推論の再利用を証明しません。 ネイティブ圧縮の処理は変わりません。", + "nativeReasoningRetention.reset": "既定値に戻す", + "nativeReasoningRetention.loadFailed": "ネイティブ推論の設定を読み込めませんでした。", + "nativeReasoningRetention.saveFailed": "ネイティブ推論の設定を保存できませんでした。", + "nativeReasoningRetention.saved": "ネイティブ推論の設定を保存しました。", "nav.claude": "Claude", "claude.pageSub": "Claude Code の OpenCodex ルーティングと設定を管理します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3e5bc27f2bd..4681071ac7c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,15 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "nativeReasoningRetention.title": "OpenAI 네이티브 추론 보존", + "nativeReasoningRetention.description": "공식 ChatGPT 백엔드로 보내는 네이티브 요청에만 적용되며 Combo 및 계정 풀의 실제 대상 변경도 포함합니다. 이전 암호화 추론을 다시 보낼 수 있지만 재사용 여부는 백엔드에 따라 달라집니다. 두 옵션은 기본적으로 꺼져 있습니다.", + "nativeReasoningRetention.modelSwitch": "모델 전환 시 암호화 추론 보존", + "nativeReasoningRetention.accountSwitch": "계정 전환 시 암호화 추론 보존 (실험적)", + "nativeReasoningRetention.accountHint": "암호문을 보존해도 다른 계정이 이전 응답 ID나 파일에 접근할 수 있는 것은 아닙니다. 요청 성공은 추론 재사용을 입증하지 않습니다. 네이티브 압축 처리는 변경되지 않습니다.", + "nativeReasoningRetention.reset": "기본값 복원", + "nativeReasoningRetention.loadFailed": "네이티브 추론 설정을 불러올 수 없습니다.", + "nativeReasoningRetention.saveFailed": "네이티브 추론 설정을 저장할 수 없습니다.", + "nativeReasoningRetention.saved": "네이티브 추론 설정을 저장했습니다.", "nav.claude": "Claude", "claude.pageSub": "Claude Code의 OpenCodex 라우팅과 설정을 관리합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index a41a90300b4..28f07178489 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -6,6 +6,15 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const pt: Record = { + "nativeReasoningRetention.title": "Retenção de raciocínio nativo OpenAI", + "nativeReasoningRetention.description": "Somente para solicitações nativas ao backend oficial do ChatGPT, incluindo mudanças do destino real em Combos e pools de contas. Permite reenviar o raciocínio criptografado anterior; a reutilização depende do backend. As duas opções ficam desativadas por padrão.", + "nativeReasoningRetention.modelSwitch": "Manter o raciocínio criptografado ao trocar de modelo", + "nativeReasoningRetention.accountSwitch": "Manter o raciocínio criptografado ao trocar de conta (experimental)", + "nativeReasoningRetention.accountHint": "Preservar o conteúdo criptografado não dá a outras contas acesso aos IDs de respostas ou arquivos anteriores. Uma solicitação bem-sucedida não comprova a reutilização do raciocínio. O tratamento da compactação nativa permanece inalterado.", + "nativeReasoningRetention.reset": "Restaurar padrões", + "nativeReasoningRetention.loadFailed": "Não foi possível carregar as configurações de raciocínio nativo.", + "nativeReasoningRetention.saveFailed": "Não foi possível salvar as configurações de raciocínio nativo.", + "nativeReasoningRetention.saved": "Configurações de raciocínio nativo salvas.", "compactionRouting.sources": "Origens", "compactionRouting.sourcesAll": "Todos os modelos de conversa", "compactionRouting.sourcesSelected": "Somente origens selecionadas", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 311b4f0865b..3e0e6270ed5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,15 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "nativeReasoningRetention.title": "Сохранение нативных рассуждений OpenAI", + "nativeReasoningRetention.description": "Только для нативных запросов к официальному бэкенду ChatGPT, включая смену фактической цели в Combo и пулах аккаунтов. Позволяет повторно передавать прежние зашифрованные рассуждения; их использование зависит от бэкенда. Оба параметра по умолчанию выключены.", + "nativeReasoningRetention.modelSwitch": "Сохранять зашифрованные рассуждения при смене модели", + "nativeReasoningRetention.accountSwitch": "Сохранять зашифрованные рассуждения при смене аккаунта (экспериментально)", + "nativeReasoningRetention.accountHint": "Сохранение шифротекста не даёт другим аккаунтам доступа к прежним ID ответов или файлам. Успешный запрос не доказывает повторное использование рассуждений. Обработка нативного сжатия остаётся прежней.", + "nativeReasoningRetention.reset": "Восстановить значения", + "nativeReasoningRetention.loadFailed": "Не удалось загрузить настройки нативных рассуждений.", + "nativeReasoningRetention.saveFailed": "Не удалось сохранить настройки нативных рассуждений.", + "nativeReasoningRetention.saved": "Настройки нативных рассуждений сохранены.", "nav.claude": "Claude", "claude.pageSub": "Маршрутизация и настройки OpenCodex для Claude Code.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1ee24d1cbe3..d3290a781cd 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,15 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "nativeReasoningRetention.title": "OpenAI yerel akıl yürütmesini koruma", + "nativeReasoningRetention.description": "Yalnızca resmi ChatGPT arka ucuna yapılan yerel isteklerde, Combo ve hesap havuzlarındaki gerçek hedef değişiklikleri dahil uygulanır. Önceki şifreli akıl yürütme yeniden gönderilebilir; yeniden kullanım arka uca bağlıdır. İki seçenek de varsayılan olarak kapalıdır.", + "nativeReasoningRetention.modelSwitch": "Model değiştirirken şifreli akıl yürütmeyi koru", + "nativeReasoningRetention.accountSwitch": "Hesap değiştirirken şifreli akıl yürütmeyi koru (deneysel)", + "nativeReasoningRetention.accountHint": "Şifreli içeriği korumak, başka hesaplara eski yanıt kimliklerine veya dosyalara erişim vermez. Başarılı bir istek, akıl yürütmenin yeniden kullanıldığını kanıtlamaz. Yerel bağlam sıkıştırma işlemi değişmez.", + "nativeReasoningRetention.reset": "Varsayılanları geri yükle", + "nativeReasoningRetention.loadFailed": "Yerel akıl yürütme ayarları yüklenemedi.", + "nativeReasoningRetention.saveFailed": "Yerel akıl yürütme ayarları kaydedilemedi.", + "nativeReasoningRetention.saved": "Yerel akıl yürütme ayarları kaydedildi.", "nav.claude": "Claude", "claude.pageSub": "Claude Code için OpenCodex yönlendirmesi ve ayarları.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 499f946728b..306746c0d9a 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -6,6 +6,15 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + "nativeReasoningRetention.title": "Giữ lại suy luận gốc OpenAI", + "nativeReasoningRetention.description": "Chỉ áp dụng cho yêu cầu gốc đến backend ChatGPT chính thức, bao gồm đổi đích thực tế trong Combo và nhóm tài khoản. Cho phép gửi lại suy luận đã mã hóa trước đó; việc tái sử dụng phụ thuộc vào backend. Cả hai tùy chọn mặc định đều tắt.", + "nativeReasoningRetention.modelSwitch": "Giữ suy luận đã mã hóa khi đổi mô hình", + "nativeReasoningRetention.accountSwitch": "Giữ suy luận đã mã hóa khi đổi tài khoản (thử nghiệm)", + "nativeReasoningRetention.accountHint": "Giữ bản mã không cho tài khoản khác quyền truy cập ID phản hồi hoặc tệp cũ. Yêu cầu thành công không chứng minh suy luận đã được tái sử dụng. Cách xử lý nén ngữ cảnh gốc không thay đổi.", + "nativeReasoningRetention.reset": "Khôi phục mặc định", + "nativeReasoningRetention.loadFailed": "Không thể tải cài đặt suy luận gốc.", + "nativeReasoningRetention.saveFailed": "Không thể lưu cài đặt suy luận gốc.", + "nativeReasoningRetention.saved": "Đã lưu cài đặt suy luận gốc.", "nav.claude": "Claude", "claude.pageSub": "Định tuyến và cài đặt OpenCodex cho Claude Code.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 070f08fcf26..bbc15303519 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,15 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "nativeReasoningRetention.title": "OpenAI 原生推理保留", + "nativeReasoningRetention.description": "僅適用於官方 ChatGPT 後端的原生請求,包括 Combo 與帳號池中的實際目標切換。允許傳送舊推理密文;能否重用由後端決定。兩個選項預設關閉。", + "nativeReasoningRetention.modelSwitch": "切換模型時保留推理密文", + "nativeReasoningRetention.accountSwitch": "切換帳號時保留推理密文(實驗性)", + "nativeReasoningRetention.accountHint": "保留密文不會讓其他帳號取得舊回應 ID 或檔案的存取權限。請求成功也不能證明推理已被重用。 原生壓縮的處理方式保持不變。", + "nativeReasoningRetention.reset": "還原預設", + "nativeReasoningRetention.loadFailed": "無法載入原生推理設定。", + "nativeReasoningRetention.saveFailed": "無法儲存原生推理設定。", + "nativeReasoningRetention.saved": "原生推理設定已儲存。", "nav.claude": "Claude", "claude.pageSub": "管理 Claude Code 的 OpenCodex 路由與設定。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8111262373e..d293550d075 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,15 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "nativeReasoningRetention.title": "OpenAI 原生推理保留", + "nativeReasoningRetention.description": "仅适用于官方 ChatGPT 后端的原生请求,包括 Combo 和账号池中的实际目标切换。允许透传旧推理密文;能否复用由后端决定。两个选项默认关闭。", + "nativeReasoningRetention.modelSwitch": "切换模型时保留推理密文", + "nativeReasoningRetention.accountSwitch": "切换账号时保留推理密文(实验性)", + "nativeReasoningRetention.accountHint": "保留密文不会让其他账号获得旧响应 ID 或文件的访问权限。请求成功也不能证明推理已被复用。 原生压缩的处理方式保持不变。", + "nativeReasoningRetention.reset": "恢复默认", + "nativeReasoningRetention.loadFailed": "无法加载原生推理设置。", + "nativeReasoningRetention.saveFailed": "无法保存原生推理设置。", + "nativeReasoningRetention.saved": "原生推理设置已保存。", "nav.claude": "Claude", "claude.pageSub": "管理 Claude Code 的 OpenCodex 路由和设置。", diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 5d7347aa52d..8e7d61b3fa6 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -1,6 +1,7 @@ import CompactionRoutingPanel from "../components/CompactionRoutingPanel"; import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; import MemoryModelsPanel from "../components/MemoryModelsPanel"; +import NativeReasoningRetentionPanel from "../components/NativeReasoningRetentionPanel"; import type { useDashboardData } from "./use-dashboard-data"; import { DashboardEffortCapPanel, @@ -21,6 +22,7 @@ export function DashboardOverviewPanels(props: Dash) { + diff --git a/gui/tests/native-reasoning-retention-panel.test.tsx b/gui/tests/native-reasoning-retention-panel.test.tsx new file mode 100644 index 00000000000..bb7e9498da2 --- /dev/null +++ b/gui/tests/native-reasoning-retention-panel.test.tsx @@ -0,0 +1,172 @@ +/** @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 NativeReasoningRetentionPanel from "../src/components/NativeReasoningRetentionPanel"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +const MODEL_LABEL = "Keep encrypted reasoning when switching models"; +const ACCOUNT_LABEL = "Keep encrypted reasoning when switching accounts (experimental)"; +let previous: Record; +let win: Window; +let root: Root | undefined; +let container: HTMLDivElement; +let setting: unknown; +let failLoad: boolean; +let failSave: boolean; +let writes: unknown[]; +let urls: string[]; + +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 = { modelSwitch: false, accountSwitch: false }; + failLoad = false; failSave = false; writes = []; urls = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (input: unknown, init?: RequestInit) => { + urls.push(String(input)); + 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 ?? { modelSwitch: false, accountSwitch: false }; + } else if (failLoad) return Response.json({ error: "unavailable" }, { status: 503 }); + return Response.json(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(); +} +function button(label: string) { + return [...container.querySelectorAll("button")] + .find(value => value.textContent === label || value.getAttribute("aria-label") === label)!; +} +async function click(label: string) { await act(async () => { button(label).click(); }); } +function enabled(label: string) { return button(label).getAttribute("aria-pressed") === "true"; } + +test("loads conservative defaults and saves model/account choices independently through the dedicated route", async () => { + await render("/proxy"); + expect(enabled(MODEL_LABEL)).toBe(false); + expect(enabled(ACCOUNT_LABEL)).toBe(false); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + expect(container.textContent).toContain("official ChatGPT backend"); + expect(container.textContent).toContain("does not prove reasoning reuse"); + await click(MODEL_LABEL); await click("Save"); + expect(writes).toEqual([{ modelSwitch: true, accountSwitch: false }]); + expect(button("Save").disabled).toBe(true); + await click(MODEL_LABEL); await click(ACCOUNT_LABEL); await click("Save"); + expect(writes.at(-1)).toEqual({ modelSwitch: false, accountSwitch: true }); + expect(container.querySelector('[role="status"]')?.textContent).toBe("Native reasoning settings saved."); + expect(urls.every(value => value === "/proxy/api/native-reasoning-retention")).toBe(true); +}); + +test("reloads saved choices and resetting removes the block with a null payload", async () => { + setting = { modelSwitch: true, accountSwitch: true }; + await render(); + expect(enabled(MODEL_LABEL)).toBe(true); + expect(enabled(ACCOUNT_LABEL)).toBe(true); + expect(button("Save").disabled).toBe(true); + await click("Restore defaults"); + expect(writes).toEqual([null]); + expect(enabled(MODEL_LABEL)).toBe(false); + expect(enabled(ACCOUNT_LABEL)).toBe(false); + expect(button("Restore defaults").disabled).toBe(true); +}); + +test("load errors disable writes and retry reloads authoritative settings", async () => { + failLoad = true; await render(); + expect(button(MODEL_LABEL).disabled).toBe(true); + expect(button(ACCOUNT_LABEL).disabled).toBe(true); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load"); + setting = { modelSwitch: false, accountSwitch: true }; failLoad = false; + await click("Retry"); + expect(enabled(ACCOUNT_LABEL)).toBe(true); + expect(button(MODEL_LABEL).disabled).toBe(false); + expect(writes).toEqual([]); +}); + +test.each([null, [], {}, { modelSwitch: true }, { modelSwitch: "true", accountSwitch: false }].map(value => [value]))( + "missing or malformed read settings never enable writes: %j", async value => { + setting = value; await render(); + expect(button(MODEL_LABEL).disabled).toBe(true); + expect(button(ACCOUNT_LABEL).disabled).toBe(true); + expect(button("Save").disabled).toBe(true); + expect(button("Restore defaults").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load"); + expect(writes).toEqual([]); + }, +); + +test("save failures retain the draft for retry and do not claim success", async () => { + await render(); await click(MODEL_LABEL); + failSave = true; await click("Save"); + expect(enabled(MODEL_LABEL)).toBe(true); + expect(button("Save").disabled).toBe(false); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save"); + expect(container.querySelector('[role="status"]')).toBeNull(); + failSave = false; await click("Save"); + expect(button("Save").disabled).toBe(true); + expect(container.querySelector('[role="alert"]')).toBeNull(); +}); + +test("a pending write disables all controls and duplicate clicks send only one update", async () => { + await render(); await click(ACCOUNT_LABEL); + const pending = Promise.withResolvers(); + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + writes.push(JSON.parse(String(init?.body))); return pending.promise; + }) as typeof fetch; + await act(async () => { button("Save").click(); button("Save").click(); }); + expect(writes).toEqual([{ modelSwitch: false, accountSwitch: true }]); + for (const label of [MODEL_LABEL, ACCOUNT_LABEL, "Save", "Restore defaults"]) expect(button(label).disabled).toBe(true); + await act(async () => { pending.resolve(Response.json({ modelSwitch: false, accountSwitch: true })); }); + expect(button(ACCOUNT_LABEL).disabled).toBe(false); + expect(button("Save").disabled).toBe(true); +}); + +test("a malformed successful save response keeps the draft and reports failure", async () => { + await render(); await click(MODEL_LABEL); + globalThis.fetch = (async () => Response.json({ modelSwitch: false })) as typeof fetch; + await click("Save"); + expect(enabled(MODEL_LABEL)).toBe(true); + expect(button("Save").disabled).toBe(false); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not save"); +}); + +test("a late response from the previous API origin cannot replace current choices", async () => { + const pending = Promise.withResolvers(); + globalThis.fetch = (async (input: unknown) => String(input).startsWith("/old/") + ? pending.promise : Response.json({ modelSwitch: true, accountSwitch: false })) as typeof fetch; + await render("/old"); + expect(button("Save").disabled).toBe(true); + await render("/new"); + expect(enabled(MODEL_LABEL)).toBe(true); + await act(async () => { pending.resolve(Response.json({ modelSwitch: false, accountSwitch: true })); }); + expect(enabled(MODEL_LABEL)).toBe(true); + expect(enabled(ACCOUNT_LABEL)).toBe(false); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8c9253b0c5..0c0cb226d5c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,11 @@ "version": 1, "root": "tests", "explicit": { - "claude-picker-upload.test.ts": "claude-integration", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", + "config-native-reasoning-retention.test.ts": "config", + "server-native-reasoning-retention.test.ts": "server", + "claude-picker-upload.test.ts": "claude-integration", + "messages-native-scope.test.ts": "claude-integration", + "catalog-auto-review-row.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli", "cli-observe-snapshot.test.ts": "cli", "cli-access-data-plane.test.ts": "cli", "cli-access-key-guard.test.ts": "cli", "cli-access-rename.test.ts": "cli", "cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 00bf30c3adb..cc514c2a8cf 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -480,6 +480,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; + const stripNativeCompaction = parsed._stripNativeCompactionEncryptedContent === true + || threadServingIdentityChanged; // Providers with the strict plaintext tool-continuation contract cannot consume any // encrypted reasoning blob, including one whose provenance is unknown. Combo routing // separately refuses a proven cross-route replay when no plaintext exists; this final @@ -492,7 +494,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): scrubOcxCompactionItems( outBody, destinationDecodesNativeCompactionBlob(provider), - threadServingIdentityChanged, + stripNativeCompaction, ), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index e086acce8ae..d1d6b592650 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -5,6 +5,7 @@ import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import * as z from "zod/v4"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; +import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import type { OcxConfig } from "../types"; import { parseAnthropicModelRoutes } from "../oauth/anthropic-model-routes"; @@ -117,6 +118,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); warnings.push(...degradedListenerWarnings(rawParsed, normalized)); if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets"); + if (nativeReasoningRetentionConfigError(rawParsed)) warnings.push("nativeReasoningRetention ignored: expected only optional boolean modelSwitch and accountSwitch fields; both remain off"); const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { @@ -669,6 +671,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx if (!parsed.ok) return { ok: false, error: `schema_invalid: anthropicAccountPool.routes: ${parsed.error}` }; } const boundaryError = blockedModelRedirectsError(value) + ?? nativeReasoningRetentionConfigError(value) ?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value) ?? blankHostnameError(value) ?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null) diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 0cca11dcd79..c5b20b7468b 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -2,6 +2,7 @@ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models"; import { chmodSync, existsSync } from "node:fs"; import { join } from "node:path"; import { compactionRecoveryConfigError } from "./schema/compaction-recovery"; +import { nativeReasoningRetentionConfigError } from "./schema/native-reasoning-retention"; import { blockedModelRedirectsError } from "./schema/blocked-model-redirects"; import { modelPinnedEffortsConfigError, @@ -123,6 +124,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx * the ratchet only ever moves down: a per-block call there costs a line the file does not have. */ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void { + if (nativeReasoningRetentionConfigError(rawParsed)) console.warn("⚠️ invalid nativeReasoningRetention disabled; model and account switch allowances remain off"); if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved"); if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available"); const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed); diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 89cfff25aca..5b4597f3ec7 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -1,6 +1,7 @@ import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types"; import * as z from "zod/v4"; import { compactionRecoverySchema } from "./compaction-recovery"; +import { nativeReasoningRetentionSchema } from "./native-reasoning-retention"; import { blockedModelRedirectsSchema } from "./blocked-model-redirects"; import { agentTaskRecoverySchema, @@ -187,6 +188,7 @@ export const configSchema = z.object({ modelPinnedEfforts: modelPinnedEffortsSchema.optional(), compactionRouting: compactionRoutingSchema.optional().catch(undefined), compactionRecovery: compactionRecoverySchema.optional().catch(undefined), + nativeReasoningRetention: nativeReasoningRetentionSchema.optional().catch(undefined), // A hand-edited malformed phase disables only that phase instead of rejecting // providers/apiKeys, matching the load-time degradation notice; the management write // boundary (validateConfigCandidate) still refuses the bad value through the shared, diff --git a/src/config/schema/native-reasoning-retention.ts b/src/config/schema/native-reasoning-retention.ts new file mode 100644 index 00000000000..b5c54033590 --- /dev/null +++ b/src/config/schema/native-reasoning-retention.ts @@ -0,0 +1,26 @@ +import * as z from "zod/v4"; +import type { OcxConfig } from "../../types"; + +/** Opt-in allowances for canonical ChatGPT reasoning replay; absence keeps existing cleanup. */ +export const nativeReasoningRetentionSchema = z.object({ + modelSwitch: z.boolean().optional(), + accountSwitch: z.boolean().optional(), +}).strict(); + +export function nativeReasoningRetentionConfigError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const retention = (value as Record).nativeReasoningRetention; + return retention === undefined || nativeReasoningRetentionSchema.safeParse(retention).success + ? null : "schema_invalid: nativeReasoningRetention: accepts only optional boolean modelSwitch and accountSwitch fields"; +} + +/** Resolve malformed synthetic inputs the same way as degraded disk config: both off. */ +export function resolveNativeReasoningRetention( + config: Pick, +): { modelSwitch: boolean; accountSwitch: boolean } { + const parsed = nativeReasoningRetentionSchema.safeParse(config.nativeReasoningRetention); + return { + modelSwitch: parsed.success && parsed.data.modelSwitch === true, + accountSwitch: parsed.success && parsed.data.accountSwitch === true, + }; +} diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 1084a47f745..9ed9bbdda0d 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -184,12 +184,34 @@ function sweepExpiredOpaqueBlobRejections(at: number): void { export function reasoningReplayServingIdentityChanged( scope: OcxReasoningReplayScopeRef | undefined, ): boolean { + return reasoningReplayServingIdentityChange(scope) !== undefined; +} + +/** Known durable route differences; an unknown issuer never establishes compatibility. */ +export function reasoningReplayServingIdentityChange( + scope: OcxReasoningReplayScopeRef | undefined, +): { + providerChanged: boolean; + destinationChanged: boolean; + adapterChanged: boolean; + modelChanged: boolean; + credentialChanged: boolean; +} | undefined { const current = servingIdentityFor(scope); - if (!current) return false; + if (!current) return undefined; const at = now(); sweepExpiredServingIdentities(at); const previous = servingIdentities.get(current.threadId); - return previous !== undefined && previous.identity !== current.identity; + if (previous === undefined || previous.identity === current.identity) return undefined; + const before = JSON.parse(previous.identity) as ReasoningReplayIdentityTuple; + const after = JSON.parse(current.identity) as ReasoningReplayIdentityTuple; + return { + providerChanged: before[0] !== after[0], + destinationChanged: before[1] !== after[1], + adapterChanged: before[2] !== after[2], + modelChanged: before[3] !== after[3], + credentialChanged: before[4] !== after[4], + }; } /** diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 1f6b28e427d..232b98de171 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -59,6 +59,7 @@ import { applySystemEnvToggle } from "./system-env"; import type { ManagementApiDeps } from "./management/context"; import { handleConfigRoutes } from "./management/config-routes"; +import { handleNativeReasoningRetentionRoutes } from "./management/native-reasoning-retention-routes"; import { handleLogsUsageRoutes } from "./management/logs-usage-routes"; import { handleStorageLogGuardRoutes } from "./management/storage-log-guard-routes"; import { handleRequestHistoryRoutes } from "./management/request-history-routes"; @@ -339,6 +340,7 @@ export async function handleManagementAPI( ?? (await handleLinkRoutesOnDemand(ctx)) ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx)) ?? (await handleConfigRoutes(ctx)) + ?? (await handleNativeReasoningRetentionRoutes(ctx)) ?? (await handleStorageLogGuardRoutes(ctx)) ?? (await handleLogsUsageRoutes(ctx)) ?? (await handleRequestHistoryRoutes(ctx)) diff --git a/src/server/management/native-reasoning-retention-routes.ts b/src/server/management/native-reasoning-retention-routes.ts new file mode 100644 index 00000000000..9291a74b093 --- /dev/null +++ b/src/server/management/native-reasoning-retention-routes.ts @@ -0,0 +1,41 @@ +import { + nativeReasoningRetentionSchema, + resolveNativeReasoningRetention, +} from "../../config/schema/native-reasoning-retention"; +import { captureConfigTopLevelRollback, deleteConfigTopLevelKey } from "../../config/rebase-provenance"; +import { ConfigWritePublishedError } from "../../config/persist-unlocked"; +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBodyOr } from "./body"; +import type { ManagementContext } from "./context"; + +const INVALID_BODY = Symbol("invalid-body"); + +/** Partial PUTs preserve omitted allowances; null resets the entire block to defaults. */ +export async function handleNativeReasoningRetentionRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + if (url.pathname === "/api/native-reasoning-retention" && req.method === "GET") { + return jsonResponse(resolveNativeReasoningRetention(config), 200, req, config); + } + if (url.pathname === "/api/native-reasoning-retention" && req.method === "PUT") { + const body = await readManagementJsonBodyOr(req, INVALID_BODY); + const parsed = body === null ? null : nativeReasoningRetentionSchema.safeParse(body); + if (parsed !== null && !parsed.success) { + return jsonResponse({ error: "body must be null or an object containing only boolean modelSwitch and accountSwitch fields" }, 400, req, config); + } + // Resolve the saver before changing the live object: no await may interleave mutation/save. + const persist = ctx.deps.saveConfigPreservingClaudeCode + ?? (await import("../../config")).saveConfigPreservingClaudeCode; + const restore = captureConfigTopLevelRollback(config, ["nativeReasoningRetention"]); + try { + if (parsed === null) deleteConfigTopLevelKey(config, "nativeReasoningRetention"); + else config.nativeReasoningRetention = { ...config.nativeReasoningRetention, ...parsed.data }; + persist(config); + } catch (error) { + // Published bytes remain authoritative even if later bookkeeping failed. + if (!(error instanceof ConfigWritePublishedError)) restore(); + throw error; + } + return jsonResponse(resolveNativeReasoningRetention(config), 200, req, config); + } + return null; +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 5853ef89385..2d72e4f806d 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -210,6 +210,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/config", module: "server/management/config-routes", mutates: false }, { method: "GET", path: "/api/diagnostics/project-config", module: "server/management/config-routes", mutates: false }, { method: "GET", path: "/api/settings", module: "server/management/config-routes", mutates: false }, + { method: "GET", path: "/api/native-reasoning-retention", module: "server/management/native-reasoning-retention-routes", mutates: false, exempt: { reason: "local-transport", why: "The CLI reads the same nativeReasoningRetention policy through ocx config get; no separate HTTP-specific settings verb is required." } }, + { method: "PUT", path: "/api/native-reasoning-retention", module: "server/management/native-reasoning-retention-routes", mutates: true, exempt: { reason: "deferred-verb", why: "A dedicated live CLI verb for this policy is deferred to native-reasoning-retention-live-cli; ocx config set/unset currently edits the same block on disk without live convergence.", owner: "native-reasoning-retention-live-cli", ownerDoc: "structure/transports/native-reasoning-retention.md" } }, { method: "GET", path: "/api/shadow-call-settings", module: "server/management/config-routes", mutates: false }, { method: "GET", path: "/api/sidecar-settings", module: "server/management/config-routes", mutates: false }, { method: "GET", path: "/api/startup-health", module: "server/management/config-routes", mutates: false }, diff --git a/src/server/responses/account-change-state.ts b/src/server/responses/account-change-state.ts index 71a24e26fe5..4d7aa756a64 100644 --- a/src/server/responses/account-change-state.ts +++ b/src/server/responses/account-change-state.ts @@ -234,7 +234,9 @@ export interface ApplyAccountChangeConversationStateScrubArgs { servingAccountId: string; /** Account this request body was prepared for, when this is an in-request move. */ priorAccountId?: string | null; - parsed?: Pick; + parsed?: Pick; + /** Trusted canonical-native caller policy; does not make continuation or file ids portable. */ + preserveReasoningEncryptedContent?: boolean; logCtx?: RequestLogContext; } @@ -252,12 +254,16 @@ export function applyAccountChangeConversationStateScrub( const accountChanged = (issuer != null && issuer !== servingAccountId) || (priorAccountId != null && priorAccountId !== servingAccountId); if (!accountChanged) return false; + // The issuer record can outlive the serving-route cache. Even a retained ciphertext cannot + // make its reasoning item id resolvable in the new account's store. + if (parsed) parsed._dropForeignReasoningItemIds = true; if (canPortConversationState(collectConversationStateCarriers(body)).portable) return false; const scrubbed = scrubUnportableConversationStateInPlace(body); if (!scrubbed) return false; if (parsed) { delete parsed.previousResponseId; - parsed._stripReasoningEncryptedContent = true; + parsed._stripNativeCompactionEncryptedContent = true; + if (args.preserveReasoningEncryptedContent !== true) parsed._stripReasoningEncryptedContent = true; } if (logCtx && logCtx.conversationStateScrub !== "account-change") { console.warn( diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 1864a7f6535..d0d874eb3f8 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -785,6 +785,8 @@ export async function retryCodexPoolOnAlternateAccount( applyAccountChangeConversationStateScrub({ body: parsed._rawBody, parsed, + preserveReasoningEncryptedContent: isCanonicalOpenAiForwardProvider(retryProvider) + && parsed._nativeReasoningRetention?.accountSwitch === true, bindingKey: binding.bindingKey, servingAccountId: binding.accountId, priorAccountId: firstAuthCtx.accountId, diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index b436ebdc9e6..1a96573252c 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -411,6 +411,7 @@ export async function consoleGoUploadRejectionBody( export function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { parsed._stripReasoningEncryptedContent = true; + parsed._stripNativeCompactionEncryptedContent = true; // The destination rejected state another serving identity minted. A reasoning item's id names // an item in that identity's store, so it goes with the blob. parsed._dropForeignReasoningItemIds = true; diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index efb5cd41587..bcd5a49267e 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -21,6 +21,7 @@ import { durableReplayDestinationIdentity, bindReasoningReplayScope, reasoningReplayServingIdentityChanged, + reasoningReplayServingIdentityChange, reasoningReplayItemStoreChanged, reasoningReplayOpaqueBlobRejectionMemoized, } from "../../responses/reasoning-replay-cache"; @@ -29,6 +30,7 @@ import type { CodexAuthContext } from "../../codex/auth-context"; import { thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; import { randomUUID } from "node:crypto"; import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough"; +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; /** * Adapters whose continuation state must survive Codex's store:false requests. @@ -141,8 +143,18 @@ export function bindRouteReasoningReplayScope(args: { ); // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal // after the first mismatch, but it cannot make history minted by the prior route decodable. - if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) { - parsed._stripReasoningEncryptedContent = true; + const identityChange = reasoningReplayServingIdentityChange(parsed._reasoningReplayScope); + if (identityChange) { + // Reasoning retention does not establish portability of native compacted history. + parsed._stripNativeCompactionEncryptedContent = true; + const retention = parsed._nativeReasoningRetention; + const mayRetainReasoning = isCanonicalOpenAiForwardProvider(provider) + && !identityChange.providerChanged + && !identityChange.destinationChanged + && !identityChange.adapterChanged + && (!identityChange.modelChanged || retention?.modelSwitch === true) + && (!identityChange.credentialChanged || retention?.accountSwitch === true); + if (!mayRetainReasoning) parsed._stripReasoningEncryptedContent = true; // Only a different destination or credential makes the replayed item ids unresolvable; a // model change on the same store keeps them. if (reasoningReplayItemStoreChanged(parsed._reasoningReplayScope)) { @@ -151,6 +163,7 @@ export function bindRouteReasoningReplayScope(args: { } if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) { parsed._stripReasoningEncryptedContent = true; + parsed._stripNativeCompactionEncryptedContent = true; parsed._dropForeignReasoningItemIds = true; } bindProviderContinuationForRoute(parsed, continuationOwner); diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index df7e5e39c6e..61aa4a68657 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -77,6 +77,7 @@ import { } from "../../router"; import { evidenceFromBody } from "../../routing/request-evidence"; import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { resolveNativeReasoningRetention } from "../../config/schema/native-reasoning-retention"; import { isThreadSpawnRequest } from "../effort-policy"; import { resolveSubagentFallbackChain, @@ -379,6 +380,7 @@ export async function prepareResponsesRequest( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + parsed._nativeReasoningRetention = resolveNativeReasoningRetention(config); parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // The body may have been rebuilt since the inbound observation (previous-response // expansion); alias the parsed raw body to the same draft so the outbound @@ -639,6 +641,7 @@ export async function prepareResponsesRequest( // This is the same condition an account change already reports (account-change-state.ts), // and the serializer turns a stored summary into readable text instead of dropping it. parsed._stripReasoningEncryptedContent = true; + parsed._stripNativeCompactionEncryptedContent = true; if (parsed._compactionRequest === true) parsed._portableCompaction = true; } logCtx.routeDecision = route.routeDecision; @@ -937,6 +940,7 @@ export async function prepareResponsesRequest( "_promptCacheKeyIsSharedCohort", "_cursorClientThreadId", "_reasoningReplayScope", + "_nativeReasoningRetention", "_cursorIsolateConversation", ]; for (const key of kept) { @@ -1396,6 +1400,8 @@ export async function prepareResponsesRequest( applyAccountChangeConversationStateScrub({ body: parsed._rawBody, parsed, + preserveReasoningEncryptedContent: isCanonicalOpenAiForwardProvider(route.provider) + && parsed._nativeReasoningRetention?.accountSwitch === true, bindingKey: binding.bindingKey, servingAccountId: binding.accountId, logCtx, diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 92acb6531bd..43fe66c5737 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -68,6 +68,8 @@ const RUNTURN_WS_ROUTE_STATE_KEYS = [ "_providerContinuationOwner", "_providerContinuationCandidate", "_stripReasoningEncryptedContent", + "_stripNativeCompactionEncryptedContent", + "_nativeReasoningRetention", "_dropForeignReasoningItemIds", "_reasoningReplayScope", ] as const; diff --git a/src/types/config.ts b/src/types/config.ts index ce3a31353ac..da710475e76 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -760,6 +760,8 @@ export interface OcxConfig { }; /** Opt-in failure-only recovery; never replaces the initial compaction model. */ compactionRecovery?: { enabled: boolean; model: string; allowDevinInvalidArgument?: boolean }; + /** Canonical ChatGPT reasoning replay allowances; both default off. */ + nativeReasoningRetention?: { modelSwitch?: boolean; accountSwitch?: boolean }; /** * Destination model for Codex's own memory pipeline, per phase * (src/server/responses/memory-models.ts). diff --git a/src/types/request.ts b/src/types/request.ts index 6e3edc3a8a7..18ec2d37c00 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -87,11 +87,15 @@ export interface OcxParsedRequest { _cursorClientThreadId?: string; /** Conversation/provider/account/model-bound namespace for reasoning replay state. */ _reasoningReplayScope?: OcxReasoningReplayScopeRef; + /** Trusted configuration snapshot; never read from the client's request body. */ + _nativeReasoningRetention?: Readonly<{ modelSwitch: boolean; accountSwitch: boolean }>; /** * Set by bindRouteReasoningReplayScope after a proven serving-identity change, or by * prepareOpaqueBlobRecovery after an authoritative rejection; consumers strip replayed blobs. */ _stripReasoningEncryptedContent?: boolean; + /** Native compaction remains nonportable even when reasoning-only retention is enabled. */ + _stripNativeCompactionEncryptedContent?: boolean; /** * Set when replayed reasoning item ids name items in a store this destination cannot read: by * prepareOpaqueBlobRecovery before the one recovery rebuild, and by bindRouteReasoningReplayScope diff --git a/structure/INDEX.md b/structure/INDEX.md index bb86db3a6ce..c97556b03f7 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -39,6 +39,7 @@ The wire surfaces a client actually talks to. | [`local-messaging.md`](local-messaging.md) | Command-local metadata discovery, Unix RPC queued submission, peer envelopes, receipts and owned lifecycle bounds. | | [`transports/byte-accounting.md`](transports/byte-accounting.md) | Request-copy and stream-buffer byte accounting shared by parsing, SSE rewriting, the adapters, and the translator budget. | | [`transports/responses.md`](transports/responses.md) | The Responses HTTP/SSE endpoint, dispatch, credential and upload boundaries, and core module ownership. | +| [`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md) | Optional native reasoning ciphertext forwarding on model/account switches, configuration and management controls. | | [`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | Mixed-wire model defaults, xAI agent-message continuation, declared-tool membership, and passthrough SSE stream shapes. | | [`transports/responses-failover.md`](transports/responses-failover.md) | Upstream reset retry, the ambiguous-resend gate, combo failover and commit boundaries, compaction routing, and output headroom. | | [`transports/policy-fallback.md`](transports/policy-fallback.md) | Original policy authorization, concrete candidate retries and physical destination deduplication. | @@ -114,11 +115,11 @@ A source area can be described by more than one doc, because these docs are orga | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
[`companion.md`](companion.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | -| `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | +| `gui/` | [`overview.md`](overview.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`design-methodology.md`](design-methodology.md)
[`companion.md`](companion.md) | | `scripts/` | [`overview.md`](overview.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `scripts/generate-ocx-skill-surface.ts` | [`cli-management.md`](cli-management.md) | | `skills/ocx/` | [`cli-management.md`](cli-management.md) | -| `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | +| `src/adapters/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/cursor.md`](providers/cursor.md)
[`providers/chat-compat.md`](providers/chat-compat.md)
[`adapters/registry.md`](adapters/registry.md) | | `src/bridge.ts` | [`transports/responses.md`](transports/responses.md) | | `src/bridge/` | [`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md) | | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | @@ -133,7 +134,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md) | -| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | +| `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md) | | `src/config/persisted-mutation.ts` | [`runtime.md`](runtime.md) | | `src/generated/` | [`runtime.md`](runtime.md) | | `src/github/` | [`runtime.md`](runtime.md) | @@ -155,10 +156,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/reasoning-effort.ts` | [`runtime.md`](runtime.md) | | `src/remote-control/` | [`remote-workspace.md`](remote-workspace.md) | | `src/remote/` | [`runtime.md`](runtime.md) | -| `src/responses/` | [`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | +| `src/responses/` | [`runtime.md`](runtime.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/kiro.md`](providers/kiro.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md) | -| `src/server/` | [`runtime.md`](runtime.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/server/` | [`runtime.md`](runtime.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/management/companion-routes.ts` | [`desktop-shell.md`](desktop-shell.md) | @@ -170,7 +171,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/storage/` | [`runtime.md`](runtime.md) | | `src/tray/` | [`runtime.md`](runtime.md) | | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | -| `src/types/` | [`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/types/` | [`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`companion.md`](companion.md) | | `src/usage/timeline.ts` | [`gui-and-management-api.md`](gui-and-management-api.md) | diff --git a/structure/config.md b/structure/config.md index 211f6708d9f..91fa1ecd22c 100644 --- a/structure/config.md +++ b/structure/config.md @@ -2,7 +2,7 @@ Quota activation reuses the existing next-reset fields without adding a polling configuration key. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). `src/types/config.ts` defines `anthropicAccountPool.routes` as ordered model rules. `src/oauth/anthropic-model-routes.ts` validates bounded names, full case-sensitive globs and stored account IDs; `src/config/diagnostics.ts` rejects malformed candidate writes. Load retains a malformed hand edit so unrelated providers survive, while the enabled Anthropic selector refuses it locally before dispatch. Saved rules remain inert when the pool is disabled; affinity is process-local. Request logs use only the rule’s 1-based `route:#` position, never its configured name. -Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native reasoning retention follows [its optional configuration contract](transports/native-reasoning-retention.md#configuration-and-management). Native function-result injection follows [the separate opt-in control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5887fc78088..f8229b59b06 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -34,7 +34,7 @@ preferences, while `src/server/management/companion-routes.ts` exposes those set usage timeline assembled by `src/usage/timeline.ts` to local clients. Query, filter-echo and missing-measurement behavior follows the [companion usage contract](companion.md). -Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native reasoning controls follow [the shared retention contract](transports/native-reasoning-retention.md#configuration-and-management). Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation is a local CLI surface, not a management API or GUI update permission. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). Native steering follows [the shared WebSocket contract](transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/manifest.json b/structure/manifest.json index 4a3e75c03cb..752e183986e 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -184,6 +184,20 @@ "src/server/" ] }, + { + "path": "transports/native-reasoning-retention.md", + "tier": 3, + "title": "Native Reasoning Retention", + "scope": "Optional native reasoning ciphertext forwarding on model/account switches, configuration and management controls.", + "documents": [ + "src/config/", + "src/types/", + "src/responses/", + "src/server/", + "src/adapters/", + "gui/" + ] + }, { "path": "transports/responses-wire-shapes.md", "tier": 3, @@ -209,16 +223,16 @@ ] }, { - "path": "transports/policy-fallback.md", - "tier": 3, - "title": "Policy Candidate Fallback", - "scope": "Original policy authorization, concrete candidate retries and physical destination deduplication.", - "documents": [ - "src/server/" - ] - }, - { - "path": "transports/responses-spend.md", + "path": "transports/policy-fallback.md", + "tier": 3, + "title": "Policy Candidate Fallback", + "scope": "Original policy authorization, concrete candidate retries and physical destination deduplication.", + "documents": [ + "src/server/" + ] + }, + { + "path": "transports/responses-spend.md", "tier": 3, "title": "Responses Spend Reservations", "scope": "Credential-hop and durable spend reservations, and what a spent budget tells the client.", @@ -291,7 +305,9 @@ "tier": 4, "title": "Anthropic Account Thresholds", "scope": "Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes.", - "documents": ["src/oauth/"] + "documents": [ + "src/oauth/" + ] }, { "path": "providers-and-adapters.md", diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index c408ce0629e..019315dbccf 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,6 +37,8 @@ preserving a stale one would block every later migration. ## Model and wire identity +The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. + Native Spark membership and its model-specific request/tool exceptions are removed; the shared [catalog retirement policy](../catalog.md#shared-catalog) preserves historical user selections. diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md new file mode 100644 index 00000000000..8853265fb9b --- /dev/null +++ b/structure/transports/native-reasoning-retention.md @@ -0,0 +1,19 @@ +# Native Reasoning Retention + +## Configuration and management + +`src/config/schema/native-reasoning-retention.ts` owns strict optional boolean `nativeReasoningRetention.modelSwitch` and `nativeReasoningRetention.accountSwitch` fields, both resolved off. Invalid disk blocks disable retention with a warning without discarding unrelated config; candidate writes reject invalid fields and unknown keys. Runtime scope and recovery precedence follow [native reasoning retention](#runtime-boundary). + +`native-reasoning-retention-live-cli` owns the deferred dedicated live CLI verb for `PUT /api/native-reasoning-retention`. The dashboard and management API apply it live today; `ocx config set/unset nativeReasoningRetention` edits the same block on disk without live convergence, so restart after those local edits. The route registry records this follow-up rather than granting a mutating local-transport exemption. + +`src/server/management/native-reasoning-retention-routes.ts` owns `GET/PUT /api/native-reasoning-retention`. GET returns resolved `modelSwitch` and `accountSwitch` booleans. PUT accepts a strict partial boolean object, preserving omitted fields, or JSON `null` to remove the block and restore defaults. Invalid writes and failures before publication leave live state unchanged; a failure after publication keeps the committed state authoritative. `gui/src/components/NativeReasoningRetentionPanel.tsx` exposes the two opt-ins on the dashboard overview; it requires a valid read before saving and fences stale server responses. Runtime scope and rejection precedence follow [native reasoning retention](#runtime-boundary). + +## Runtime boundary + +`src/types/request.ts` declares trusted parsed policy fields; `src/responses/reasoning-replay-cache.ts` reports known durable identity deltas without widening cache keys. `src/adapters/openai-responses/passthrough.ts` applies reasoning and compaction stripping independently. + +`src/server/responses/request-prepare.ts` snapshots the resolved policy into trusted parsed state. `src/server/responses/core-replay.ts` applies it to the selected concrete route, including each fresh Combo candidate. Retention is eligible only on the canonical ChatGPT Responses forward destination, with an unchanged provider, adapter and durable endpoint. A known model change requires `modelSwitch`; a known credential change requires `accountSwitch`; simultaneous changes require both. It does not widen the serving identity used for reasoning caches or strict plaintext Combo eligibility. Unknown or expired serving provenance retains the existing destination-only behavior rather than inventing a compatibility match. + +Account retention is experimental forwarding permission, not evidence that the backend accepts or reuses another caller's reasoning. `src/server/responses/account-change-state.ts` continues removing account-bound continuation references and foreign reasoning item ids; uploaded-file refusal stays independent. Opaque-blob rejection recovery and its rejection memo override retention. Previously stripped ciphertext is never restored in a later recovery leg. + +Only reasoning `encrypted_content` is covered. Native compaction cleanup has its own internal flag and preserves the existing identity-change behavior. Direct native `/v1/responses/compact` sanitization is unchanged. API-key providers, arbitrary forward aliases and translated destinations receive no new permission. The configuration and management surface is described above. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index dc5434d5337..059fe8cd5d2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -339,8 +339,8 @@ ChatGPT forward surface, the official OpenAI API, or a provider with the explici `decodesNativeCompactionBlobs` capability. The destination gate alone is insufficient because more than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob without being able to decode the previous backend's. The same serving-identity mismatch signal -therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the -existing opaque-note path. When the thread has no recorded identity, the destination-only behavior +therefore strips reasoning `encrypted_content` unless the narrowly scoped [native reasoning retention](native-reasoning-retention.md#runtime-boundary) policy permits it. Native compaction blobs still degrade through the +existing opaque-note path independently of that policy. When the thread has no recorded identity, the destination-only behavior is deliberately unchanged. Forward auth alone is not evidence: noncanonical forward providers receive no caller credentials and may point at any backend. On any other routed destination the blob also degrades to the same opaque note the bridged parser uses, because forwarding it there fails the @@ -367,7 +367,7 @@ account minted a conversation's carried state (`previous_response_id`, encrypted provider conversation or file ids). `src/server/responses/account-change-state.ts` applies that record on `/v1/responses` and `/v1/responses/compact`, including same-request alternate-account retries and the compact routed fallback: when the serving account differs, the proxy drops the -continuation id and strips encrypted reasoning with the existing helpers before dispatch, keeps +continuation id and provider conversation reference. Parsed Responses requests also remove foreign reasoning item ids and apply the encrypted-reasoning policy before dispatch, keeping readable user text, and records `conversationStateScrub: "account-change"` on the request log without account identifiers. Once the new account issues its own state, later turns carry it normally. `canPortConversationState` is local until `src/routing/identity-domains.ts` lands. diff --git a/tests/adapters/reasoning-replay-identity.test.ts b/tests/adapters/reasoning-replay-identity.test.ts index 8709c6ea65d..a63d63555fd 100644 --- a/tests/adapters/reasoning-replay-identity.test.ts +++ b/tests/adapters/reasoning-replay-identity.test.ts @@ -13,6 +13,7 @@ import { reasoningReplayOpaqueBlobRejectionMemoized, reasoningReplayOAuthCredentialIdentity, reasoningReplayServingIdentityChanged, + reasoningReplayServingIdentityChange, rememberReasoningForCall, rememberReasoningReplayOpaqueBlobRejection, } from "../../src/responses/reasoning-replay-cache"; @@ -145,6 +146,22 @@ describe("reasoning replay provider and credential identity", () => { })).toBe(false); }); + test("known serving deltas separate model compatibility from credential and destination ownership", () => { + commitReasoningReplayServingIdentity(scope()); + expect(reasoningReplayServingIdentityChange(scope())).toBeUndefined(); + expect(reasoningReplayServingIdentityChange(scope({ modelId: "other-model" }))).toEqual({ + providerChanged: false, destinationChanged: false, adapterChanged: false, + modelChanged: true, credentialChanged: false, + }); + expect(reasoningReplayServingIdentityChange(scope({ + modelId: "other-model", credentialDurableIdentity: "credential:other-slot", + }))).toMatchObject({ modelChanged: true, credentialChanged: true, destinationChanged: false }); + expect(reasoningReplayServingIdentityChange(scope({ + providerDestinationDurableIdentity: "destination:other-backend", + }))).toMatchObject({ destinationChanged: true, modelChanged: false, credentialChanged: false }); + expect(reasoningReplayServingIdentityChange({ clientThreadId: "unknown-thread" })).toBeUndefined(); + }); + test("opaque-blob rejection memos use the durable serving identity and refuse incomplete scopes", () => { const rejected = scope({ modelId: "destination-b-model" }); rememberReasoningReplayOpaqueBlobRejection(rejected); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 2c59a884dfc..65e2be61e8d 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -581,6 +581,7 @@ describe("headless GUI parity CLI", () => { ["/api/anthropic/reset-grants", "(none — GUI reset-grant dialog; spend requires a dashboard session)"], ["/api/protocols", "ocx api protocols/explain/policy"], ["/api/settings", "ocx system"], + ["/api/native-reasoning-retention", "(none — dedicated live CLI deferred; local policy via ocx config)"], // Routing Intelligence (RI-04..RI-10): profiles + dry-run are mirrored by // `ocx route policy`. Analytics is GUI-first for now; the same request // history remains available through observe/index tooling. diff --git a/tests/config/config-native-reasoning-retention.test.ts b/tests/config/config-native-reasoning-retention.test.ts new file mode 100644 index 00000000000..5c15f3fbe38 --- /dev/null +++ b/tests/config/config-native-reasoning-retention.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigPath, getDefaultConfig, loadConfig, validateConfigCandidate } from "../../src/config"; +import { configDiagnosticsFromRaw } from "../../src/config/diagnostics"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { resolveNativeReasoningRetention } from "../../src/config/schema/native-reasoning-retention"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const off = { modelSwitch: false, accountSwitch: false }; +const invalid = [null, [], true, "on", { modelSwitch: "true" }, { accountSwitch: 1 }, + { modelSwitch: true, accountSwitch: null }, { modelSwitch: true, endpointSwitch: true }]; + +describe("native reasoning retention configuration", () => { + test("absent allowances stay off and valid partial booleans are preserved", () => { + expect(getDefaultConfig().nativeReasoningRetention).toBeUndefined(); + expect(resolveNativeReasoningRetention(getDefaultConfig())).toEqual(off); + for (const policy of [{}, { modelSwitch: true }, { accountSwitch: true }, + { modelSwitch: false, accountSwitch: true }, { modelSwitch: true, accountSwitch: true }]) { + const candidate = { ...getDefaultConfig(), nativeReasoningRetention: policy }; + const parsed = validateConfigCandidate(candidate); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.config.nativeReasoningRetention).toEqual(policy); + expect(resolveNativeReasoningRetention(parsed.config)).toEqual({ + modelSwitch: "modelSwitch" in policy && policy.modelSwitch === true, + accountSwitch: "accountSwitch" in policy && policy.accountSwitch === true, + }); + } + } + }); + + test("invalid writes reject the whole block while invalid disk values disable both allowances", () => { + for (const policy of invalid) { + const candidate = { ...getDefaultConfig(), nativeReasoningRetention: policy }; + const result = validateConfigCandidate(candidate); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("nativeReasoningRetention"); + const parsed = configSchema.parse(candidate); + expect(parsed.nativeReasoningRetention).toBeUndefined(); + expect(parsed.providers).toEqual(candidate.providers); + expect(resolveNativeReasoningRetention(parsed)).toEqual(off); + const diagnostics = configDiagnosticsFromRaw(JSON.stringify(candidate)); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.warnings?.join(" ")).toContain("nativeReasoningRetention ignored"); + expect(diagnostics.config.providers).toEqual(candidate.providers); + } + }); + + test("load warns without discarding unrelated configuration", () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-native-retention-config-")); + process.env.OPENCODEX_HOME = home; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const candidate = { ...getDefaultConfig(), nativeReasoningRetention: { modelSwitch: true, accountSwitch: "true" } }; + writeFileSync(getConfigPath(), JSON.stringify(candidate), "utf8"); + const loaded = loadConfig(); + expect(loaded.nativeReasoningRetention).toBeUndefined(); + expect(loaded.providers).toEqual(candidate.providers); + expect(warning.mock.calls.some(call => String(call[0]).includes("invalid nativeReasoningRetention"))).toBe(true); + } finally { + warning.mockRestore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0c03e737f8f..dd76086160b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,9 @@ { + "config-native-reasoning-retention.test.ts": "config", + "server-native-reasoning-retention.test.ts": "server", "claude-picker-upload.test.ts": "claude-integration", - "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", + "messages-native-scope.test.ts": "claude-integration", + "catalog-auto-review-row.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", diff --git a/tests/responses/responses-account-change-scrub.test.ts b/tests/responses/responses-account-change-scrub.test.ts index 59be5738d70..6eaee2d5e95 100644 --- a/tests/responses/responses-account-change-scrub.test.ts +++ b/tests/responses/responses-account-change-scrub.test.ts @@ -14,6 +14,8 @@ import { rememberConversationStateIssuer, } from "../../src/codex/routing"; import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxParsedRequest } from "../../src/types"; +import { sanitizeReasoningInputContent } from "../../src/adapters/openai-responses/reasoning"; const BINDING_KEY = "thread-account-change-scrub"; const ENCRYPTED = "gAAAA" + "A".repeat(80); @@ -138,6 +140,42 @@ describe("Codex pool account-change conversation-state scrub", () => { clearConversationStateIssuerMap(); }); + test("reasoning retention still drops continuation ids and keeps compaction stripping armed", () => { + rememberConversationStateIssuer(BINDING_KEY, "account-a"); + const body = turnBody(); + const parsed: Pick = { + previousResponseId: "resp_account_a", + }; + expect(applyAccountChangeConversationStateScrub({ + body, parsed, bindingKey: BINDING_KEY, servingAccountId: "account-b", + preserveReasoningEncryptedContent: true, + })).toBe(true); + expect(body.previous_response_id).toBeUndefined(); + expect(parsed.previousResponseId).toBeUndefined(); + expect(parsed._stripReasoningEncryptedContent).toBeUndefined(); + expect(parsed._stripNativeCompactionEncryptedContent).toBe(true); + expect(parsed._dropForeignReasoningItemIds).toBe(true); + expect(body.input[1]).toEqual(reasoningBlob()); + }); + + test("issuer-only account evidence removes foreign reasoning ids on stored replay without losing retained ciphertext", () => { + rememberConversationStateIssuer(BINDING_KEY, "account-a"); + const body = { model: "gpt-5.4", store: true, input: [reasoningBlob()] }; + const parsed: Pick = {}; + expect(applyAccountChangeConversationStateScrub({ + body, parsed, bindingKey: BINDING_KEY, servingAccountId: "account-b", + preserveReasoningEncryptedContent: true, + })).toBe(false); + expect(parsed._dropForeignReasoningItemIds).toBe(true); + const outbound = sanitizeReasoningInputContent(body, { + dropForeignItemId: parsed._dropForeignReasoningItemIds, + stripEncryptedContent: parsed._stripReasoningEncryptedContent, + }) as typeof body; + expect(outbound.input[0]!.id).toBeUndefined(); + expect(outbound.input[0]!.encrypted_content).toBe(ENCRYPTED); + expect(body.input[0]!.id).toBe("rs_account_change"); + }); + test("a turn served by the same account keeps previous_response_id and encrypted reasoning", () => { rememberConversationStateIssuer(BINDING_KEY, "account-a"); const body = turnBody(); diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index 1fcf62d7ab4..e0b03af5496 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -3,7 +3,9 @@ import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ADAPTER_REGISTRY } from "../../src/adapters/registry"; -import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { clearReasoningReplayCacheForTests, commitReasoningReplayServingIdentity } from "../../src/responses/reasoning-replay-cache"; +import { parseRequest } from "../../src/responses/parser"; +import { bindRouteReasoningReplayScope } from "../../src/server/responses/core-replay"; import { OPAQUE_COMPACTION_NOTE } from "../../src/responses/compaction"; import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; import * as authContextModule from "../../src/codex/auth-context"; @@ -309,6 +311,23 @@ function nativeConfig(): OcxConfig { } as OcxConfig; } +function nativeReplayAuth(): authContextModule.CodexAuthContext { + return { + kind: "pool", accountId: "pool-retention", generation: 1, writerGeneration: 1, + accessToken: "test-native-retention-token", chatgptAccountId: "workspace-retention", + }; +} + +function seedNativeReplayIdentity(threadId: string): void { + const parsed = parseRequest({ model: "gpt-5.6-sol", input: reasoningReplayInput() }); + parsed._reasoningReplayScope = { clientThreadId: threadId }; + bindRouteReasoningReplayScope({ + parsed, providerName: "openai", provider: nativeConfig().providers.openai!, + adapterName: "openai-responses", codexAuthContext: nativeReplayAuth(), + }); + commitReasoningReplayServingIdentity(parsed._reasoningReplayScope); +} + function nativeAgentMessageRequest(stream = false): Request { return new Request("http://localhost/v1/responses", { method: "POST", @@ -558,6 +577,74 @@ describe("opaque blob recovery trigger", () => { }); describe("opaque blob recovery through /v1/responses", () => { + test.each([ + { label: "provider label", providerName: "another-openai" }, + { label: "destination", baseUrl: "https://another.example.test/v1" }, + { label: "adapter", adapterName: "openai-chat" }, + { label: "noncanonical forward destination", baseUrl: "https://chatgpt.com.example.test/backend-api/codex" }, + { label: "API-key authentication", authMode: "key" as const }, + ])("native retention cannot bypass a $label change", ({ providerName, baseUrl, adapterName, authMode }) => { + const threadId = "thread-native-retention-boundary"; + seedNativeReplayIdentity(threadId); + const parsed = parseRequest({ model: "gpt-6-astra", input: reasoningReplayInput() }); + parsed._reasoningReplayScope = { clientThreadId: threadId }; + parsed._nativeReasoningRetention = { modelSwitch: true, accountSwitch: true }; + const provider = { + ...nativeConfig().providers.openai!, + ...(baseUrl ? { baseUrl } : {}), + ...(authMode ? { authMode, apiKey: "test-other-key" } : {}), + }; + bindRouteReasoningReplayScope({ + parsed, providerName: providerName ?? "openai", provider, + adapterName: adapterName ?? "openai-responses", codexAuthContext: nativeReplayAuth(), + }); + expect(parsed._stripReasoningEncryptedContent).toBe(true); + expect(parsed._stripNativeCompactionEncryptedContent).toBe(true); + }); + + test("a foreign Combo candidate cannot clear reasoning for a later native candidate", async () => { + const threadId = "thread-native-retention-combo"; + seedNativeReplayIdentity(threadId); + saveCodexAccountCredential("pool-retention", { + accessToken: "test-native-retention-token", refreshToken: "test-native-retention-refresh", + expiresAt: Date.now() + 300_000, chatgptAccountId: "workspace-retention", + }); + const settings = nativeConfig(); + settings.providers.openai!.codexAccountMode = "pool"; + settings.nativeReasoningRetention = { modelSwitch: true, accountSwitch: true }; + settings.codexAccounts = [{ id: "pool-retention", email: "retention@example.test", chatgptAccountId: "workspace-retention", isMain: false }]; + settings.providers.foreign = { + adapter: "openai-responses", baseUrl: "https://foreign.example.test/v1", authMode: "key", + apiKey: "test-foreign-retention-key", + }; + settings.combos = { "retention-fallback": { + strategy: "failover", targets: [{ provider: "foreign", model: "model-a" }, { provider: "openai", model: "gpt-6-astra" }], + } }; + const outbound: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + outbound.push({ url, body: JSON.parse(String(init?.body)) }); + return url.includes("foreign.example.test") + ? Response.json({ error: { type: "invalid_request_error", code: "model_not_found", message: "model not found" } }, { status: 404 }) + : success("resp-native-combo-retention"); + }) as typeof fetch; + const authSpy = spyOn(authContextModule, "resolveCodexAuthContext").mockResolvedValue(nativeReplayAuth()); + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": threadId }, + body: JSON.stringify({ model: "combo/retention-fallback", stream: false, store: false, input: reasoningReplayInput() }), + }), settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + } finally { authSpy.mockRestore(); } + expect(outbound).toHaveLength(2); + expect(outbound[0]!.url).toContain("foreign.example.test"); + expect(hasBlob(outbound[0]!.body)).toBe(false); + expect(outbound[1]!.url).toContain("chatgpt.com"); + expect((outbound[1]!.body.input as Array>)[1]!.encrypted_content).toBe(BLOB); + expect((outbound[1]!.body.input as Array>)[2]!.type).toBe("message"); + }); + test("lowers Fernet-shaped agent plaintext before native dispatch", async () => { const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -1139,7 +1226,18 @@ data: ${JSON.stringify(created)} }); } - test("#2247 strips reasoning and compaction ciphertext before a pooled thread moves accounts", async () => { + test.each([ + { label: "default account switch", accountChanged: true, modelChanged: false, retention: undefined, retain: false }, + { label: "opted-in account switch", accountChanged: true, modelChanged: false, retention: { accountSwitch: true }, retain: true }, + { label: "default model switch", accountChanged: false, modelChanged: true, retention: undefined, retain: false }, + { label: "opted-in model switch", accountChanged: false, modelChanged: true, retention: { modelSwitch: true }, retain: true }, + { label: "account option does not allow model switch", accountChanged: false, modelChanged: true, retention: { accountSwitch: true }, retain: false }, + { label: "model option does not allow account switch", accountChanged: true, modelChanged: false, retention: { modelSwitch: true }, retain: false }, + { label: "both options allow simultaneous switch", accountChanged: true, modelChanged: true, retention: { modelSwitch: true, accountSwitch: true }, retain: true }, + { label: "model option alone rejects simultaneous switch", accountChanged: true, modelChanged: true, retention: { modelSwitch: true }, retain: false }, + { label: "account option alone rejects simultaneous switch", accountChanged: true, modelChanged: true, retention: { accountSwitch: true }, retain: false }, + { label: "authoritative rejection overrides retention", accountChanged: false, modelChanged: true, retention: { modelSwitch: true }, retain: true, reject: true }, + ])("native reasoning replay: $label", async ({ accountChanged, modelChanged, retention, retain, reject }) => { const outbound: Array<{ accountId: string | null; body: Record }> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); @@ -1147,6 +1245,9 @@ data: ${JSON.stringify(created)} accountId: headers.get("chatgpt-account-id"), body: JSON.parse(String(init?.body)) as Record, }); + if (reject && outbound.length === 2) { + return new Response(CALLER_MISMATCH_BLOB_ERROR, { status: 400 }); + } return success(`resp-${outbound.length}`); }) as typeof fetch; for (const account of [ @@ -1171,14 +1272,23 @@ data: ${JSON.stringify(created)} }) .mockResolvedValueOnce({ kind: "pool", - accountId: "pool-b", - writerGeneration: 12, + accountId: accountChanged ? "pool-b" : "pool-a", + writerGeneration: accountChanged ? 12 : 11, + generation: 1, + accessToken: accountChanged ? "shared-test-token-b" : "shared-test-token-a", + chatgptAccountId: accountChanged ? "workspace-b" : "workspace-a", + }) + .mockResolvedValue({ + kind: "pool", + accountId: accountChanged ? "pool-b" : "pool-a", + writerGeneration: accountChanged ? 12 : 11, generation: 1, - accessToken: "shared-test-token-b", - chatgptAccountId: "workspace-b", + accessToken: accountChanged ? "shared-test-token-b" : "shared-test-token-a", + chatgptAccountId: accountChanged ? "workspace-b" : "workspace-a", }); const poolConfig = { defaultProvider: "openai", + ...(retention ? { nativeReasoningRetention: retention } : {}), providers: { openai: { adapter: "openai-responses", @@ -1192,14 +1302,14 @@ data: ${JSON.stringify(created)} { id: "pool-b", email: "b@example.test", chatgptAccountId: "workspace-b", isMain: false }, ], } as OcxConfig; - const poolRequest = () => new Request("http://localhost/v1/responses", { + const poolRequest = (turn: number) => new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": "thread-2247-pool-switch", }, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: turn > 0 && modelChanged ? "gpt-6-astra" : "gpt-5.6-sol", stream: false, store: false, input: reasoningReplayInput(), @@ -1207,34 +1317,43 @@ data: ${JSON.stringify(created)} }); try { - for (let turn = 0; turn < 2; turn += 1) { - const response = await handleResponses(poolRequest(), poolConfig, { model: "", provider: "" }); + for (let turn = 0; turn < (reject ? 3 : 2); turn += 1) { + const response = await handleResponses(poolRequest(turn), poolConfig, { model: "", provider: "" }); expect(response.status).toBe(200); await response.text(); } - expect(authSpy).toHaveBeenCalledTimes(2); + expect(authSpy).toHaveBeenCalledTimes(reject ? 3 : 2); } finally { authSpy.mockRestore(); } - expect(outbound).toHaveLength(2); - expect(outbound.map(entry => entry.accountId)).toEqual(["workspace-a", "workspace-b"]); + expect(outbound).toHaveLength(reject ? 4 : 2); + expect(outbound[0]!.accountId).toBe("workspace-a"); + expect(outbound.slice(1).map(entry => entry.accountId)).toEqual( + Array(reject ? 3 : 1).fill(accountChanged ? "workspace-b" : "workspace-a"), + ); expect(hasBlob(outbound[0]!.body)).toBe(true); const firstInput = outbound[0]!.body.input as Array>; expect(firstInput[1]?.encrypted_content).toBe(BLOB); expect(firstInput[2]).toEqual({ type: "compaction", encrypted_content: BLOB }); - expect(hasBlob(outbound[1]!.body)).toBe(false); + expect(hasBlob(outbound[1]!.body)).toBe(retain); const secondInput = outbound[1]!.body.input as Array>; expect(secondInput[1]).toEqual({ type: "reasoning", content: [], summary: [{ type: "summary_text", text: "prior reasoning" }], + ...(retain ? { encrypted_content: BLOB } : {}), }); expect(secondInput[2]).toEqual({ type: "message", role: "user", content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], }); + if (reject) { + // The rejected request is rebuilt exactly once. The next turn obeys the successful + // recovery memo even though the operator's retention option is still enabled. + expect(outbound.slice(2).every(entry => !hasBlob(entry.body))).toBe(true); + } }); test("#2247 retries the reported ChatGPT unverifiable-ciphertext rejection once", async () => { diff --git a/tests/server/server-native-reasoning-retention.test.ts b/tests/server/server-native-reasoning-retention.test.ts new file mode 100644 index 00000000000..75e51e599fe --- /dev/null +++ b/tests/server/server-native-reasoning-retention.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultConfig } from "../../src/config"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { ConfigWritePublishedError } from "../../src/config/persist-unlocked"; +import { configRebaseDeletionKeys } from "../../src/config/rebase-provenance"; +import { handleNativeReasoningRetentionRoutes } from "../../src/server/management/native-reasoning-retention-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; + +const off = { modelSwitch: false, accountSwitch: false }; + +function harness(saveError?: Error) { + const config = getDefaultConfig(); + const saved: OcxConfig[] = []; + async function call(method: string, body?: unknown, rawBody?: string, pathname = "/api/native-reasoning-retention") { + const url = new URL(`http://localhost${pathname}`); + return handleNativeReasoningRetentionRoutes({ + url, req: new Request(url, { method, ...(method === "GET" ? {} : { + headers: { "content-type": "application/json" }, body: rawBody ?? JSON.stringify(body), + }) }), + config, version: "test", deps: { saveConfigPreservingClaudeCode: (candidate: OcxConfig) => { + saved.push(structuredClone(candidate)); + if (saveError) throw saveError; + } }, + } as unknown as ManagementContext); + } + return { config, saved, call }; +} + +describe("native reasoning retention management routes", () => { + test("management dispatch mounts both methods and persists the same live policy", async () => { + const config = getDefaultConfig(); + const saves: OcxConfig[] = []; + const deps = { saveConfigPreservingClaudeCode: (candidate: OcxConfig) => saves.push(structuredClone(candidate)) }; + const url = new URL("http://127.0.0.1:10100/api/native-reasoning-retention"); + const get = await handleManagementAPI(new ManagementRequest(url), url, config, deps); + expect(get?.status).toBe(200); + expect(await get!.json()).toEqual(off); + const put = await handleManagementAPI(new ManagementRequest(url, { + method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelSwitch: true }), + }), url, config, deps); + expect(put?.status).toBe(200); + expect(await put!.json()).toEqual({ modelSwitch: true, accountSwitch: false }); + expect(saves).toHaveLength(1); + expect(saves[0]?.nativeReasoningRetention).toEqual({ modelSwitch: true }); + }); + + test("GET returns resolved defaults and never persists", async () => { + const h = harness(); + const response = await h.call("GET"); + expect(response?.status).toBe(200); + expect(await response!.json()).toEqual(off); + h.config.nativeReasoningRetention = { accountSwitch: true }; + expect(await (await h.call("GET"))!.json()).toEqual({ modelSwitch: false, accountSwitch: true }); + expect(h.saved).toHaveLength(0); + }); + + test("PUT preserves omitted fields and changes no routing or account settings", async () => { + const h = harness(); + const original = structuredClone(h.config); + expect((await h.call("PUT", { modelSwitch: true }))?.status).toBe(200); + expect(h.config.nativeReasoningRetention).toEqual({ modelSwitch: true }); + const response = await h.call("PUT", { accountSwitch: true }); + expect(await response!.json()).toEqual({ modelSwitch: true, accountSwitch: true }); + expect((await h.call("PUT", { modelSwitch: false }))?.status).toBe(200); + expect(h.config.nativeReasoningRetention).toEqual({ modelSwitch: false, accountSwitch: true }); + const { nativeReasoningRetention: _policy, ...rest } = h.config; + expect(rest).toEqual(original); + expect(h.saved).toHaveLength(3); + }); + + test("empty partial PUT preserves policy; null explicitly resets the entire block", async () => { + const h = harness(); + h.config.nativeReasoningRetention = { modelSwitch: true, accountSwitch: true }; + expect(await (await h.call("PUT", {}))!.json()).toEqual({ modelSwitch: true, accountSwitch: true }); + const reset = await h.call("PUT", null); + expect(reset?.status).toBe(200); + expect(await reset!.json()).toEqual(off); + expect(h.config.nativeReasoningRetention).toBeUndefined(); + expect(configRebaseDeletionKeys(h.config).has("nativeReasoningRetention")).toBe(true); + expect(h.saved).toHaveLength(2); + }); + + test("invalid fields, types and JSON reject before mutation or save", async () => { + const h = harness(); + h.config.nativeReasoningRetention = { modelSwitch: true }; + const original = structuredClone(h.config); + for (const body of [[], true, "on", { modelSwitch: "true" }, { accountSwitch: null }, + { modelSwitch: false, accountSwitch: 1 }, { modelSwitch: false, endpointSwitch: true }]) { + expect((await h.call("PUT", body))?.status).toBe(400); + expect(h.config).toEqual(original); + } + expect((await h.call("PUT", undefined, "{"))?.status).toBe(400); + expect((await h.call("PUT", undefined, ""))?.status).toBe(400); + expect(h.config).toEqual(original); + expect(h.saved).toHaveLength(0); + }); + + test("failed saves restore both the previous field and deletion intent", async () => { + const h = harness(new Error("fixture-save-failed")); + const original = { modelSwitch: true, accountSwitch: true }; + h.config.nativeReasoningRetention = original; + await expect(h.call("PUT", null)).rejects.toThrow("fixture-save-failed"); + expect(h.config.nativeReasoningRetention).toBe(original); + expect(configRebaseDeletionKeys(h.config).has("nativeReasoningRetention")).toBe(false); + await expect(h.call("PUT", { modelSwitch: false })).rejects.toThrow("fixture-save-failed"); + expect(h.config.nativeReasoningRetention).toBe(original); + }); + + test("save failure preserves an absent field; publication failures keep committed policy", async () => { + const failed = harness(new Error("fixture-save-failed")); + await expect(failed.call("PUT", { modelSwitch: true })).rejects.toThrow("fixture-save-failed"); + expect(Object.hasOwn(failed.config, "nativeReasoningRetention")).toBe(false); + const published = harness(new ConfigWritePublishedError(new Error("fixture-bookkeeping-failed"))); + await expect(published.call("PUT", { modelSwitch: true })).rejects.toBeInstanceOf(ConfigWritePublishedError); + expect(published.config.nativeReasoningRetention).toEqual({ modelSwitch: true }); + }); + + test("unrelated paths and unsupported methods fall through", async () => { + const h = harness(); + expect(await h.call("POST", {})).toBeNull(); + expect(await h.call("GET", undefined, undefined, "/api/native-reasoning-retention-extra")).toBeNull(); + expect(h.saved).toHaveLength(0); + }); +}); From 534cd6f1970c739daa37df1faab628ce47de97a8 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:42:22 +0900 Subject: [PATCH 2/3] fix(responses): recover streamed refusals of retained reasoning ciphertext Native reasoning retention deliberately forwards ciphertext minted under another model or account. ChatGPT can refuse it inside a 200 stream (a Codex WebSocket error frame or response.failed) instead of an HTTP 4xx, which bypassed the single sanitized rebuild. For requests whose ciphertext was actually retained across a change, the passthrough now inspects the zero-output prelude for a self-identified ciphertext refusal and routes it through the existing one-shot opaque-blob recovery and rejection memo. Also update the ingress source-oracle test for the direct-request decode that now precedes the retention snapshot, register the new test file, and document the streamed recovery in the retention structure doc. --- scripts/test-layout/layout.json | 2 +- src/server/responses/core-opaque-recovery.ts | 33 ++++ src/server/responses/core-replay.ts | 1 + src/server/responses/passthrough-dispatch.ts | 6 +- src/server/responses/run-turn-execution.ts | 1 + src/types/request.ts | 2 + .../transports/native-reasoning-retention.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + ...retained-reasoning-stream-recovery.test.ts | 165 ++++++++++++++++++ ...r-native-reasoning-retention-merge.test.ts | 3 +- 10 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 tests/responses/responses-retained-reasoning-stream-recovery.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4fa5b6096fe..e98ae1d6884 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -3,7 +3,7 @@ "root": "tests", "explicitShards": ["layout-clients.json"], "explicit": { "service-log.test.ts": "service", - "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", + "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index d2a1cb6e77f..9c446e2c135 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -276,6 +276,39 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { } +/** + * Opted-in native reasoning retention forwards ciphertext another model or account minted. ChatGPT + * can refuse it inside a 200 stream -- a Codex WebSocket `error` frame or `response.failed` -- + * rather than as an HTTP 4xx, so the passthrough inspects the zero-output prelude of exactly these + * requests. Ordinary turns keep the function-output-only inspection. + */ +export function retainedReasoningStreamCandidate(parsed: OcxParsedRequest, outboundBody: string | undefined): boolean { + return parsed._nativeReasoningRetainedAcrossRoute === true + && parsed._stripReasoningEncryptedContent !== true + && outboundResponsesBodyCarriesOpaqueBlob(outboundBody); +} + +/** + * Whether a stream-prelude payload is a self-identified refusal of retained ciphertext. A match marks + * the projected failure as the 4xx it is, so the shared single-shot trigger accepts it. + */ +export function retainedReasoningStreamRejection( + payload: unknown, + parsed: OcxParsedRequest, + outboundBody: string | undefined, + preflightLog: RequestLogContext, +): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + if (!retainedReasoningStreamCandidate(parsed, outboundBody)) return false; + const { type, response } = payload as { type?: unknown; response?: { error?: unknown } }; + if (type !== "error" && type !== "response.failed") return false; + const envelope = type === "error" ? payload : { error: response?.error }; + if (!isSelfIdentifiedOpaqueBlobRejection(JSON.stringify(envelope))) return false; + preflightLog.terminalHttpStatus = 400; + return true; +} + + /** * Whether an upstream Responses 4xx authoritatively rejected opaque replay state. * diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index e3dc359d681..959f8b4b223 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -170,6 +170,7 @@ export function bindRouteReasoningReplayScope(args: { && (!identityChange.modelChanged || retention?.modelSwitch === true) && (!identityChange.credentialChanged || retention?.accountSwitch === true); if (!mayRetainReasoning) parsed._stripReasoningEncryptedContent = true; + else parsed._nativeReasoningRetainedAcrossRoute = true; // Only a different destination or credential makes the replayed item ids unresolvable; a // model change on the same store keeps them. if (reasoningReplayItemStoreChanged(parsed._reasoningReplayScope)) { diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 58a1d8be395..789d415cdf8 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -154,7 +154,7 @@ import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; import { attemptOpaqueBlobRecovery, - isEncryptedFunctionOutputRejection, + isEncryptedFunctionOutputRejection, retainedReasoningStreamCandidate, retainedReasoningStreamRejection, outboundResponsesBodyCarriesEncryptedFunctionOutput, resetStreamedOpaqueBlobLogContext, consoleGoUploadRejectionBody, @@ -1749,7 +1749,7 @@ export async function preparePassthroughExchange( && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && upstreamRequestsStream)) && !opaqueBlobRecoveryGuard.attempted && !configuredTransientSendBudgetExhausted() - && outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body); + && (outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body) || retainedReasoningStreamCandidate(parsed, request.body)); if (streamedFunctionOutputCandidate) { const preflightLog: RequestLogContext = { model: logCtx.model, provider: logCtx.provider }; const preflight = await preflightComboStreamResponse(upstreamResponse, preflightLog, @@ -1762,7 +1762,7 @@ export async function preparePassthroughExchange( // intentionally understands only Responses error/message fields. Preserve only this // exact identity so the projected 502 can enter the existing single-shot recovery. if (decryptRejection) preflightLog.upstreamError = ENCRYPTED_FUNCTION_OUTPUT_REJECTION; - return decryptRejection; + return decryptRejection || retainedReasoningStreamRejection(payload, parsed, request.body, preflightLog); }, { allowMissingContentType: !recoveryContentType && upstreamRequestsStream, replayReadErrors: true, diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 8186d365a03..16e987c9b16 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -73,6 +73,7 @@ const RUNTURN_WS_ROUTE_STATE_KEYS = [ "_stripReasoningEncryptedContent", "_stripNativeCompactionEncryptedContent", "_nativeReasoningRetention", + "_nativeReasoningRetainedAcrossRoute", "_dropForeignReasoningItemIds", "_reasoningReplayScope", ] as const; diff --git a/src/types/request.ts b/src/types/request.ts index 1e881d57121..d4e77c98d72 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -101,6 +101,8 @@ export interface OcxParsedRequest { _stripReasoningEncryptedContent?: boolean; /** Native compaction remains nonportable even when reasoning-only retention is enabled. */ _stripNativeCompactionEncryptedContent?: boolean; + /** Retention kept reasoning ciphertext across a model/account change; arms streamed rejection recovery. */ + _nativeReasoningRetainedAcrossRoute?: boolean; /** * Set when replayed reasoning item ids name items in a store this destination cannot read: by * prepareOpaqueBlobRecovery before the one recovery rebuild, and by bindRouteReasoningReplayScope diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index 4129d3ca65c..1d4a1bfdde2 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -14,6 +14,6 @@ `src/server/responses/request-prepare.ts` snapshots the resolved policy into trusted parsed state. `src/server/responses/core-replay.ts` applies it to the selected concrete route, including each fresh Combo candidate. Retention is eligible only on the canonical ChatGPT Responses forward destination, with an unchanged provider, adapter and durable endpoint. A known model change requires `modelSwitch`; a known credential change requires `accountSwitch`; simultaneous changes require both. It does not widen the serving identity used for reasoning caches or strict plaintext Combo eligibility. Unknown or expired serving provenance retains the existing destination-only behavior rather than inventing a compatibility match. -Account retention is experimental forwarding permission, not evidence that the backend accepts or reuses another caller's reasoning. `src/server/responses/account-change-state.ts` continues removing account-bound continuation references and foreign reasoning item ids; uploaded-file refusal stays independent. Opaque-blob rejection recovery and its rejection memo override retention. Previously stripped ciphertext is never restored in a later recovery leg. Claude-native per-blob replay tags remain authoritative: retention never permits a blob whose durable endpoint or generation-bearing credential fails its tag check. +Account retention is experimental forwarding permission, not evidence that the backend accepts or reuses another caller's reasoning. `src/server/responses/account-change-state.ts` continues removing account-bound continuation references and foreign reasoning item ids; uploaded-file refusal stays independent. Opaque-blob rejection recovery and its rejection memo override retention. When retention actually kept ciphertext across a change, `src/server/responses/passthrough-dispatch.ts` also inspects the zero-output stream prelude, so a refusal ChatGPT sends inside a 200 stream (a Codex WebSocket `error` frame or `response.failed`) takes the same single sanitized rebuild as an HTTP 4xx refusal. Previously stripped ciphertext is never restored in a later recovery leg. Claude-native per-blob replay tags remain authoritative: retention never permits a blob whose durable endpoint or generation-bearing credential fails its tag check. Only reasoning `encrypted_content` is covered. Native compaction cleanup has its own internal flag and preserves the existing identity-change behavior. Direct native `/v1/responses/compact` sanitization is unchanged. API-key providers, arbitrary forward aliases and translated destinations receive no new permission. The configuration and management surface is described above. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 51ef8472da7..521667e32e0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,6 +2,7 @@ "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", + "responses-retained-reasoning-stream-recovery.test.ts": "responses", "codex-history-pending-wal.test.ts": "codex-integration", "service-log.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", diff --git a/tests/responses/responses-retained-reasoning-stream-recovery.test.ts b/tests/responses/responses-retained-reasoning-stream-recovery.test.ts new file mode 100644 index 00000000000..3799a137852 --- /dev/null +++ b/tests/responses/responses-retained-reasoning-stream-recovery.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as authContextModule from "../../src/codex/auth-context"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Retention deliberately forwards ciphertext another route minted. ChatGPT can refuse it inside a +// 200 stream (a Codex WebSocket error frame or a response.failed terminal) instead of an HTTP 4xx; +// that refusal must still strip the blob and rebuild exactly once, like the HTTP rejection does. +const BLOB = "retained-reasoning-ciphertext"; +const REJECTION = { + message: "The encrypted content could not be verified.", + type: "invalid_request_error", + code: "invalid_encrypted_content", +}; +const originalFetch = globalThis.fetch; +const originalHome = process.env.OPENCODEX_HOME; +let home = ""; +let releaseSpendHome: (() => void) | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-retained-reasoning-stream-")); + process.env.OPENCODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearReasoningReplayCacheForTests(); + resetThoughtSignatureReplayForTests(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); +}); + +// A bare error frame ends the stream with no terminal event or [DONE], as ChatGPT sends it. +function sse(done: boolean, ...payloads: unknown[]): Response { + const body = payloads.map(payload => `data: ${JSON.stringify(payload)}\n\n`).join("") + (done ? "data: [DONE]\n\n" : ""); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +const completed = (id: string) => sse(true, { type: "response.completed", response: { id, status: "completed", model: "m", output: [] } }); +const frames = { + "WebSocket error frame": () => sse( + false, + { type: "response.created", response: { id: "resp-error", status: "in_progress" } }, + { type: "error", status: 400, error: REJECTION }, + ), + "response.failed terminal": () => sse( + true, + { type: "response.created", response: { id: "resp-failed", status: "in_progress" } }, + { type: "response.failed", response: { id: "resp-failed", status: "failed", error: REJECTION } }, + ), +} as const; + +function turn(model: string): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "x-codex-parent-thread-id": "thread-retained-stream" }, + body: JSON.stringify({ + model, + stream: true, + store: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }, + { type: "reasoning", content: [], summary: [{ type: "summary_text", text: "prior" }], encrypted_content: BLOB }, + { type: "message", role: "user", content: [{ type: "input_text", text: "after" }] }, + ], + }), + }); +} + +function retentionConfig(): OcxConfig { + return { + defaultProvider: "openai", + nativeReasoningRetention: { modelSwitch: true }, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + codexAccounts: [{ id: "pool-a", email: "a@example.test", chatgptAccountId: "workspace-a", isMain: false }], + } as OcxConfig; +} + +function poolAccount(): ReturnType { + saveCodexAccountCredential("pool-a", { + accessToken: "shared-test-token-a", + refreshToken: "pool-a-refresh-token", + expiresAt: Date.now() + 300_000, + chatgptAccountId: "workspace-a", + }); + return spyOn(authContextModule, "resolveCodexAuthContext").mockResolvedValue({ + kind: "pool", + accountId: "pool-a", + writerGeneration: 11, + generation: 1, + accessToken: "shared-test-token-a", + chatgptAccountId: "workspace-a", + }); +} + +describe("retained native reasoning rejected inside a stream", () => { + for (const [shape, rejected] of Object.entries(frames)) { + test(`strips and rebuilds once after a ${shape}`, async () => { + const outbound: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(String(init?.body)); + return outbound.length === 2 ? rejected() : completed(`resp-${outbound.length}`); + }) as typeof fetch; + const authSpy = poolAccount(); + const config = retentionConfig(); + try { + const first = await handleResponses(turn("gpt-5.6-sol"), config, { model: "", provider: "" }); + expect(first.status).toBe(200); + await first.text(); + const second = await handleResponses(turn("gpt-6-astra"), config, { model: "", provider: "" }); + const body = await second.text(); + expect(second.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain("invalid_encrypted_content"); + } finally { + authSpy.mockRestore(); + } + expect(outbound).toHaveLength(3); + // The model switch was allowed to carry the ciphertext; the refusal removes it for the + // single rebuild only. + expect(outbound[1]).toContain(BLOB); + expect(outbound[2]).not.toContain(BLOB); + }); + } + + test("a repeated streamed refusal is rebuilt only once and then reaches the client", async () => { + const outbound: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(String(init?.body)); + return outbound.length === 1 ? completed("resp-1") : frames["response.failed terminal"](); + }) as typeof fetch; + const authSpy = poolAccount(); + try { + await (await handleResponses(turn("gpt-5.6-sol"), retentionConfig(), { model: "", provider: "" })).text(); + const body = await (await handleResponses(turn("gpt-6-astra"), retentionConfig(), { model: "", provider: "" })).text(); + expect(body).toContain("invalid_encrypted_content"); + } finally { + authSpy.mockRestore(); + } + expect(outbound).toHaveLength(3); + expect(outbound[1]).toContain(BLOB); + expect(outbound[2]).not.toContain(BLOB); + }); +}); diff --git a/tests/server/server-native-reasoning-retention-merge.test.ts b/tests/server/server-native-reasoning-retention-merge.test.ts index db94d933183..f8d2a061430 100644 --- a/tests/server/server-native-reasoning-retention-merge.test.ts +++ b/tests/server/server-native-reasoning-retention-merge.test.ts @@ -58,7 +58,8 @@ function bind(request: ReturnType, context = account()) { describe("native reasoning retention with upstream replay ownership", () => { test("ingress snapshots retention alongside Claude replay and mint metadata", () => { const source = readFileSync(repoPath("src/server/responses/request-prepare.ts"), "utf8"); - expect(source).toMatch(/parsed = parseRequest\(body\);\s*parsed\._nativeReasoningRetention = resolveNativeReasoningRetention\(config\);\s*if \(options\.inboundWire === "anthropic"\) \{\s*parsed\._nativeReasoningReplay = options\.nativeReasoningReplay;\s*parsed\._nativeReasoningMint = options\.nativeReasoningMint;/); + // The snapshot follows the Antigravity direct-request decode, which replaces `parsed`. + expect(source).toMatch(/parsed = parseRequest\(body\);[\s\S]{0,400}?if \(options\.directRequest\) parsed = options\.directRequest\.decode\(parsed\);\s*parsed\._nativeReasoningRetention = resolveNativeReasoningRetention\(config\);\s*if \(options\.inboundWire === "anthropic"\) \{\s*parsed\._nativeReasoningReplay = options\.nativeReasoningReplay;\s*parsed\._nativeReasoningMint = options\.nativeReasoningMint;/); }); test("untagged Responses history can retain ciphertext while native compaction stripping remains armed", () => { From 8a7bceb7cac828c13a32dae4f9a8aa155ab8ffbd Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:48:27 +0900 Subject: [PATCH 3/3] fix(responses): keep withheld retained-reasoning refusals unprojected A spent ambiguous replacement forbids the sanitized rebuild, so the retained-reasoning stream preflight must not consume and project the refusal into a synthetic 400. Arm it only while the resend is still available, set the retained marker only while stripping is off, and clear it once stripping becomes authoritative. --- src/server/responses/core-opaque-recovery.ts | 1 + src/server/responses/core-replay.ts | 4 ++- src/server/responses/passthrough-dispatch.ts | 4 +-- ...retained-reasoning-stream-recovery.test.ts | 26 +++++++++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 9c446e2c135..016afa5e1d5 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -444,6 +444,7 @@ export async function consoleGoUploadRejectionBody( export function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { parsed._stripReasoningEncryptedContent = true; + delete parsed._nativeReasoningRetainedAcrossRoute; parsed._stripNativeCompactionEncryptedContent = true; // The destination rejected state another serving identity minted. A reasoning item's id names // an item in that identity's store, so it goes with the blob. diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index 959f8b4b223..35f5d2569d6 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -170,7 +170,7 @@ export function bindRouteReasoningReplayScope(args: { && (!identityChange.modelChanged || retention?.modelSwitch === true) && (!identityChange.credentialChanged || retention?.accountSwitch === true); if (!mayRetainReasoning) parsed._stripReasoningEncryptedContent = true; - else parsed._nativeReasoningRetainedAcrossRoute = true; + else if (parsed._stripReasoningEncryptedContent !== true) parsed._nativeReasoningRetainedAcrossRoute = true; // Only a different destination or credential makes the replayed item ids unresolvable; a // model change on the same store keeps them. if (reasoningReplayItemStoreChanged(parsed._reasoningReplayScope)) { @@ -182,6 +182,8 @@ export function bindRouteReasoningReplayScope(args: { parsed._stripNativeCompactionEncryptedContent = true; parsed._dropForeignReasoningItemIds = true; } + // Stripping is authoritative: nothing retained remains to be refused. + if (parsed._stripReasoningEncryptedContent === true) delete parsed._nativeReasoningRetainedAcrossRoute; bindProviderContinuationForRoute(parsed, continuationOwner); } diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 789d415cdf8..419cfe69933 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -1749,7 +1749,7 @@ export async function preparePassthroughExchange( && (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && upstreamRequestsStream)) && !opaqueBlobRecoveryGuard.attempted && !configuredTransientSendBudgetExhausted() - && (outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body) || retainedReasoningStreamCandidate(parsed, request.body)); + && (outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body) || (!sendBudgetState.ambiguousResendSpent && retainedReasoningStreamCandidate(parsed, request.body))); if (streamedFunctionOutputCandidate) { const preflightLog: RequestLogContext = { model: logCtx.model, provider: logCtx.provider }; const preflight = await preflightComboStreamResponse(upstreamResponse, preflightLog, @@ -1762,7 +1762,7 @@ export async function preparePassthroughExchange( // intentionally understands only Responses error/message fields. Preserve only this // exact identity so the projected 502 can enter the existing single-shot recovery. if (decryptRejection) preflightLog.upstreamError = ENCRYPTED_FUNCTION_OUTPUT_REJECTION; - return decryptRejection || retainedReasoningStreamRejection(payload, parsed, request.body, preflightLog); + return decryptRejection || (!sendBudgetState.ambiguousResendSpent && retainedReasoningStreamRejection(payload, parsed, request.body, preflightLog)); }, { allowMissingContentType: !recoveryContentType && upstreamRequestsStream, replayReadErrors: true, diff --git a/tests/responses/responses-retained-reasoning-stream-recovery.test.ts b/tests/responses/responses-retained-reasoning-stream-recovery.test.ts index 3799a137852..a04247b7acd 100644 --- a/tests/responses/responses-retained-reasoning-stream-recovery.test.ts +++ b/tests/responses/responses-retained-reasoning-stream-recovery.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as authContextModule from "../../src/codex/auth-context"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; import { resetThoughtSignatureReplayForTests } from "../../src/responses/thought-signature-replay"; import { handleResponses } from "../../src/server/responses/core"; @@ -162,4 +163,29 @@ describe("retained native reasoning rejected inside a stream", () => { expect(outbound[1]).toContain(BLOB); expect(outbound[2]).not.toContain(BLOB); }); + + test("an already spent ambiguous replacement leaves the refusal stream untouched", async () => { + const outbound: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(String(init?.body)); + return outbound.length === 1 ? completed("resp-1") : frames["response.failed terminal"](); + }) as typeof fetch; + const authSpy = poolAccount(); + const sendBudget = createRequestExecutionBudget(); + expect(sendBudget.claimAmbiguousResend(1)).toBe(true); + try { + await (await handleResponses(turn("gpt-5.6-sol"), retentionConfig(), { model: "", provider: "" })).text(); + const second = await handleResponses(turn("gpt-6-astra"), retentionConfig(), { model: "", provider: "" }, { sendBudget }); + // No rebuild is allowed, so the preflight must not consume the stream into a synthetic 400: + // the client receives the upstream frames, including the response id a projection drops. + expect(second.status).toBe(200); + const body = await second.text(); + expect(body).toContain("response.created"); + expect(body).toContain("resp-failed"); + expect(body).toContain("invalid_encrypted_content"); + } finally { + authSpy.mockRestore(); + } + expect(outbound).toHaveLength(2); + }); });