From 77ee3325b24df18ce4b1a3b5823fd1c1845d7e0c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:36:48 +0800 Subject: [PATCH 1/3] fix(openai-chat): inject reasoning placeholder when replay cache misses Closes #1193 --- src/adapters/openai-chat.ts | 18 +++++- tests/deepseek-reasoning-replay-gaps.test.ts | 62 ++++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 42a9236574..400e1e2d7e 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -347,6 +347,15 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon // recorded under every call id — join unique texts only. if (cached.length > 0) { reasoningContent = [...new Set(cached)].join("\n"); + } else { + // Fallback (extends #950, closes #1193): the replay cache is + // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on + // long sessions, and some tool rounds carry no recorded reasoning + // at all. DeepSeek thinking mode rejects ANY tool_call assistant + // message missing reasoning_content with HTTP 400, so inject a + // minimal placeholder rather than emit a bare continuation the + // upstream will reject. + reasoningContent = " "; } } if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) { @@ -413,10 +422,17 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId) ? peekReasoningForCall(toolCallId, replayCacheScope) : undefined; + // Same fallback as the main-assistant path: never emit a bare orphan + // tool_call continuation on a thinking-mode provider — inject a + // placeholder when the replay cache missed (the bounded cache can + // always miss on long sessions), or DeepSeek thinking mode 400s. + const orphanReasoning = + cachedReasoning + ?? (modelInList(provider.preserveReasoningContentModels, parsed.modelId) ? " " : undefined); out.push({ role: "assistant", content: emptyAssistantContent(provider), - ...(cachedReasoning ? { reasoning_content: cachedReasoning } : {}), + ...(orphanReasoning ? { reasoning_content: orphanReasoning } : {}), tool_calls: [{ id: toolCallId, type: "function", diff --git a/tests/deepseek-reasoning-replay-gaps.test.ts b/tests/deepseek-reasoning-replay-gaps.test.ts index 74787adc52..16d107a04a 100644 --- a/tests/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/deepseek-reasoning-replay-gaps.test.ts @@ -135,10 +135,64 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) expect(retry!["reasoning_content"]).toBe(REASONING); }); - test("documented non-bug: opaque encrypted-only reasoning is intentionally not replayed", () => { + test("GAP D (issue #1193): replay cache MISS on the main assistant path injects a placeholder", () => { + // The replay cache is bounded (64 entries / 256 KiB / 1 h TTL) and always + // misses on long sessions. DeepSeek thinking mode rejects ANY tool_call + // assistant message without reasoning_content (HTTP 400), so a cache miss + // must degrade to a minimal placeholder instead of a bare continuation. + const { messages } = wireFor([ + userMessage(), + { type: "compaction", encrypted_content: "ocx1:c3VtbWFyeQ==" }, + functionCallItem(), + functionCallOutputItem(), + ]); + const assistant = toolCallAssistant(messages); + expect(assistant).toBeDefined(); + expect(assistant!["reasoning_content"]).toBe(" "); + }); + + test("GAP E (issue #1193): replay cache MISS on the orphan-repair path injects a placeholder", () => { + // Same invariant for the synthesized orphan tool_call: with nothing + // recorded under the call id, repair still must not emit a bare + // continuation a thinking-mode provider will 400 on. + const { messages } = wireFor([userMessage(), functionCallOutputItem()]); + const assistant = toolCallAssistant(messages); + expect(assistant).toBeDefined(); + expect(assistant!["reasoning_content"]).toBe(" "); + }); + + test("negative control: models outside preserveReasoningContentModels never get a placeholder", () => { + // The placeholder fallback is scoped to thinking-mode providers; other + // models keep the previous bare-continuation behavior. Use a custom + // provider so no registry preset seeds a preserve list. + const parsed = parseRequest({ model: "custom-chat/plain-model", input: [userMessage(), functionCallOutputItem()], stream: true }); + const config: OcxConfig = { + port: 10100, + defaultProvider: "custom-chat", + providers: { + "custom-chat": { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + apiKey: "key", + models: ["plain-model"], + }, + }, + }; + const route = routeModel(config, parsed.modelId); + parsed.modelId = route.modelId; + const req = createOpenAIChatAdapter(route.provider).buildRequest(parsed as OcxParsedRequest); + const { messages } = JSON.parse(req.body as string) as { messages: Array> }; + const assistant = toolCallAssistant(messages); + expect(assistant).toBeDefined(); + expect(assistant!["reasoning_content"]).toBeUndefined(); + }); + + test("documented non-bug: opaque encrypted-only reasoning degrades to the placeholder, not invented plaintext", () => { // Native (non-ocxr1) encrypted reasoning has no readable text; the parser - // deliberately degrades instead of inventing replayable plaintext. Not a - // candidate for the opencode-go path (its reasoning is plaintext/ocxr1). + // deliberately degrades instead of inventing replayable plaintext. On a + // thinking-mode provider the fallback now attaches the minimal placeholder + // (issue #1193) rather than replaying anything, so the continuation stays + // valid without fabricating reasoning text. const { messages } = wireFor([ userMessage(), { type: "reasoning", id: "rs_1", encrypted_content: "some-opaque-blob" }, @@ -147,7 +201,7 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) ]); const assistant = toolCallAssistant(messages); expect(assistant).toBeDefined(); - expect(assistant!["reasoning_content"]).toBeUndefined(); + expect(assistant!["reasoning_content"]).toBe(" "); }); }); From 48961a91ea2042fa62906d50c5839a74026973c2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:24:29 +0800 Subject: [PATCH 2/3] fix(openai-chat): scope reasoning placeholder to models that require it Address review findings on #1205: - chatgpt-codex-connector P2: preserveReasoningContentModels only opts models into replaying reasoning that exists; MiniMax-M3 low effort maps to thinking disabled, so a fabricated placeholder could reach non-thinking histories. Add requiresReasoningPlaceholderModels (registry/derive/router/oauth/auth-cors plumbing, docs-site table) defaulting to the preserve list; minimax/minimax-cn seed [] to opt out. Custom preserve-only provider configs keep the #1193 fix via fallback. - CodeRabbit minor: treat a falsy cache hit as a miss in the orphan-repair path (defense-in-depth; the write path already rejects empty strings). Refs #1193 --- .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../ru/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + src/adapters/openai-chat.ts | 11 ++++-- src/oauth/index.ts | 1 + src/oauth/login-cli.ts | 1 + src/providers/derive.ts | 4 +++ src/providers/registry.ts | 8 ++++- src/router.ts | 2 ++ src/server/auth-cors.ts | 1 + src/types.ts | 8 +++++ tests/deepseek-reasoning-replay-gaps.test.ts | 35 +++++++++++++++++++ 14 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 57903e8534..0e83c6a8f2 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -92,6 +92,7 @@ namespace 付き combo または routing-profile alias はその namespace prefi | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | | `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。 | +| `requiresReasoningPlaceholderModels?` | `string[]` | `reasoning_content` を欠いた tool_call 継続を上流が拒否するモデル(DeepSeek thinking モード)。リプレイキャッシュが外れた場合に最小プレースホルダーを注入。未設定時は `preserveReasoningContentModels` を引き継ぎ、`[]` で明示的に無効化。 | | `thinkingToggleModels?` | `string[]` |エフォート ラダーではなく `thinking.enabled` を使用してモデルをチャットします。 | | `thinkingBudgetModels?` | `string[]` |整数 `thinking_budget` を使用したチャット モデル。労力は予算の一部にマッピングされます。 | | `noVisionModels?` | `string[]` |ビジョン サイドカーを通じて送信されるテキストのみのモデル。マッチングでは、Ollama `:size` タグが許容されます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 684ea712f5..f3faf2c152 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -92,6 +92,7 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. | +| `requiresReasoningPlaceholderModels?` | `string[]` | `reasoning_content`가 없는 tool_call 연속을 업스트림이 거부하는 모델(DeepSeek thinking 모드). 리플레이 캐시 미스 시 최소 플레이스홀더를 주입합니다. 미설정 시 `preserveReasoningContentModels`를 따륩며 `[]`로 명시적 해제 가능. | | `thinkingToggleModels?` | `string[]` | effort 계층 대신 `thinking.enabled`를 쓰는 chat 모델입니다. | | `thinkingBudgetModels?` | `string[]` | 정수 `thinking_budget`를 쓰는 chat 모델입니다. effort는 예산 비율로 매핑됩니다. | | `noVisionModels?` | `string[]` | vision sidecar로 보내는 텍스트 전용 모델입니다. 일치 판정은 Ollama `:size` 태그도 허용합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index aca630a1fd..6e14a57a3c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -100,6 +100,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. | +| `requiresReasoningPlaceholderModels?` | `string[]` | Models whose upstream rejects a tool_call continuation missing `reasoning_content` (DeepSeek thinking mode); a minimal placeholder is injected when the replay cache misses. Defaults to `preserveReasoningContentModels`; set `[]` to opt out. | | `thinkingToggleModels?` | `string[]` | Chat models using `thinking.enabled` rather than an effort ladder. | | `thinkingBudgetModels?` | `string[]` | Chat models using integer `thinking_budget`; effort maps to a budget fraction. | | `noVisionModels?` | `string[]` | Text-only models sent through the vision sidecar; matching tolerates an Ollama `:size` tag. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index abf75d650e..25f39ec00d 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -102,6 +102,7 @@ cross-route credential fallback не существует. Строки API GPT- | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | | `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. | +| `requiresReasoningPlaceholderModels?` | `string[]` | Модели, чей upstream отклоняет tool_call-продолжение без `reasoning_content` (DeepSeek thinking mode); при промахе replay-кэша подставляется минимальный placeholder. По умолчанию наследует `preserveReasoningContentModels`; `[]` отключает явно. | | `thinkingToggleModels?` | `string[]` | Chat-модели, использующие `thinking.enabled` вместо effort-ladder. | | `thinkingBudgetModels?` | `string[]` | Chat-модели, использующие целочисленный `thinking_budget`; effort отображается в долю бюджета. | | `noVisionModels?` | `string[]` | Text-only-модели, идущие через vision sidecar; при сопоставлении tolerируется тег Ollama вида `:size`. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 7e664ce58c..82f8b54d76 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -91,6 +91,7 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | | `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。 | +| `requiresReasoningPlaceholderModels?` | `string[]` | 上游会拒绝缺少 `reasoning_content` 的 tool_call 续接消息的模型(DeepSeek thinking 模式);重放缓存 miss 时注入最小占位符。缺省沿用 `preserveReasoningContentModels`;设为 `[]` 可显式关闭。 | | `thinkingToggleModels?` | `string[]` | 使用 `thinking.enabled` 而不是 effort 阶梯的 chat 模型。 | | `thinkingBudgetModels?` | `string[]` | 使用整数 `thinking_budget` 的 chat 模型;effort 会映射为预算比例。 | | `noVisionModels?` | `string[]` | 经由视觉 sidecar 发送的纯文本模型;匹配时会容忍 Ollama 的 `:size` 标记。 | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 400e1e2d7e..9042d8ee25 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -347,14 +347,17 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon // recorded under every call id — join unique texts only. if (cached.length > 0) { reasoningContent = [...new Set(cached)].join("\n"); - } else { + } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) { // Fallback (extends #950, closes #1193): the replay cache is // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on // long sessions, and some tool rounds carry no recorded reasoning // at all. DeepSeek thinking mode rejects ANY tool_call assistant // message missing reasoning_content with HTTP 400, so inject a // minimal placeholder rather than emit a bare continuation the - // upstream will reject. + // upstream will reject. Scoped to requiresReasoningPlaceholderModels + // (defaulting to the preserve list): preserve-listed providers with + // toggleable thinking (MiniMax low effort) opt out with `[]` so + // non-thinking histories are never given a fabricated placeholder. reasoningContent = " "; } } @@ -426,9 +429,11 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon // tool_call continuation on a thinking-mode provider — inject a // placeholder when the replay cache missed (the bounded cache can // always miss on long sessions), or DeepSeek thinking mode 400s. + // `||` (not `??`): the cache never stores empty strings, but treat a + // falsy hit as a miss so the placeholder still fires. const orphanReasoning = cachedReasoning - ?? (modelInList(provider.preserveReasoningContentModels, parsed.modelId) ? " " : undefined); + || (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId) ? " " : undefined); out.push({ role: "assistant", content: emptyAssistantContent(provider), diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 3487023929..b0035a1c02 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -726,6 +726,7 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ "noPenaltyModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", + "requiresReasoningPlaceholderModels", ]; const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity"; diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index dbcf755619..0afecb4281 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -102,6 +102,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s ...(def.noPenaltyModels ? { noPenaltyModels: [...def.noPenaltyModels] } : {}), ...(def.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...def.autoToolChoiceOnlyModels] } : {}), ...(def.preserveReasoningContentModels ? { preserveReasoningContentModels: [...def.preserveReasoningContentModels] } : {}), + ...(def.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...def.requiresReasoningPlaceholderModels] } : {}), ...(def.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: def.escapeBuiltinToolNames } : {}), }; } diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c947c0b4f8..fe8581b0ea 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -36,6 +36,7 @@ export interface DerivedKeyLoginProvider { noPenaltyModels?: string[]; autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; reasoningSplitModels?: string[]; thinkingToggleModels?: string[]; thinkingBudgetModels?: string[]; @@ -154,6 +155,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}), ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}), @@ -199,6 +201,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}), ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), + ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}), ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}), ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}), ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}), @@ -345,6 +348,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig } if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels]; if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels]; + if (!prov.requiresReasoningPlaceholderModels && seed.requiresReasoningPlaceholderModels) prov.requiresReasoningPlaceholderModels = [...seed.requiresReasoningPlaceholderModels]; if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels]; if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels]; if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels]; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 375c4ada76..69b6c3a676 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -221,6 +221,7 @@ export interface ProviderRegistryEntry { promptCacheKey?: boolean; autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; reasoningSplitModels?: string[]; thinkingToggleModels?: string[]; thinkingBudgetModels?: string[]; @@ -243,7 +244,7 @@ export type ProviderConfigSeed = Pick< | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" - | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "googleMode" | "project" | "location" | "headers" >; @@ -1984,6 +1985,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, preserveReasoningContentModels: MINIMAX_MODELS, + // MiniMax-M3 low effort maps to thinking disabled, so a legitimate tool + // round can carry no reasoning at all; only replay real recorded text, + // never a fabricated placeholder (chatgpt-codex-connector P2 on #1205). + requiresReasoningPlaceholderModels: [], reasoningSplitModels: MINIMAX_MODELS, thinkingToggleModels: ["MiniMax-M3"], jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key", @@ -1996,6 +2001,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, preserveReasoningContentModels: MINIMAX_MODELS, + requiresReasoningPlaceholderModels: [], reasoningSplitModels: MINIMAX_MODELS, thinkingToggleModels: ["MiniMax-M3"], jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key", diff --git a/src/router.ts b/src/router.ts index 599ec9bb94..c7fc68a94b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -286,6 +286,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels); const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels); const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels); + const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels); const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels); const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels); const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels); @@ -370,6 +371,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) ...(noPenaltyModels ? { noPenaltyModels } : {}), ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}), ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}), + ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}), ...(reasoningSplitModels ? { reasoningSplitModels } : {}), ...(thinkingToggleModels ? { thinkingToggleModels } : {}), ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 536526f3e3..a59bfba5f1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -579,6 +579,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "noPenaltyModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", + "requiresReasoningPlaceholderModels", "escapeBuiltinToolNames", ] as const) { copyIfDefined(dto, provider, key); diff --git a/src/types.ts b/src/types.ts index 481c15a4b0..1390939e01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1323,6 +1323,14 @@ export interface OcxProviderConfig { autoToolChoiceOnlyModels?: string[]; /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */ preserveReasoningContentModels?: string[]; + /** + * Model ids whose upstream hard-rejects a tool_call continuation missing + * `reasoning_content` (DeepSeek thinking mode: HTTP 400). When the replay + * cache misses, the adapter injects a minimal placeholder for these models. + * Defaults to `preserveReasoningContentModels` when unset; set `[]` to opt + * out explicitly (e.g. MiniMax, where low effort disables thinking). + */ + requiresReasoningPlaceholderModels?: string[]; /** * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays diff --git a/tests/deepseek-reasoning-replay-gaps.test.ts b/tests/deepseek-reasoning-replay-gaps.test.ts index 16d107a04a..76859116b5 100644 --- a/tests/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/deepseek-reasoning-replay-gaps.test.ts @@ -187,6 +187,41 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) expect(assistant!["reasoning_content"]).toBeUndefined(); }); + test("P2 guard: preserve-listed providers with toggleable thinking opt out of the placeholder (MiniMax)", () => { + // MiniMax-M3 low effort maps to thinking disabled, so a legitimate tool + // round can carry no reasoning; the registry seeds + // requiresReasoningPlaceholderModels: [] for minimax so a cache miss never + // fabricates one (chatgpt-codex-connector P2 on #1205). Real recorded + // reasoning still replays via preserveReasoningContentModels. + const minimaxWire = (input: unknown[]) => { + const parsed = parseRequest({ model: "minimax/MiniMax-M3", input, stream: true }); + const config: OcxConfig = { + port: 10100, + defaultProvider: "minimax", + providers: { + minimax: { + adapter: "openai-chat", + baseUrl: "https://api.minimax.io/v1", + apiKey: "key", + }, + }, + }; + const route = routeModel(config, parsed.modelId); + parsed.modelId = route.modelId; + const req = createOpenAIChatAdapter(route.provider).buildRequest(parsed as OcxParsedRequest); + return JSON.parse(req.body as string) as { messages: Array> }; + }; + // Cache miss on the orphan-repair path: no fabricated placeholder. + const miss = toolCallAssistant(minimaxWire([userMessage(), functionCallOutputItem()]).messages); + expect(miss).toBeDefined(); + expect(miss!["reasoning_content"]).toBeUndefined(); + // Cache hit on the same path: the recorded reasoning still replays. + rememberReasoningForCall("call_1", REASONING); + const hit = toolCallAssistant(minimaxWire([userMessage(), functionCallOutputItem()]).messages); + expect(hit).toBeDefined(); + expect(hit!["reasoning_content"]).toBe(REASONING); + }); + test("documented non-bug: opaque encrypted-only reasoning degrades to the placeholder, not invented plaintext", () => { // Native (non-ocxr1) encrypted reasoning has no readable text; the parser // deliberately degrades instead of inventing replayable plaintext. On a From 61283a42f5a8201a5eab92e94d2f2b0723cc6aa4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:27:03 +0800 Subject: [PATCH 3/3] fix(openai-chat): gate orphan placeholder on preserve list, keep opt-outs durable Address the remaining review findings on #1205: - chatgpt-codex-connector P2: the orphan-repair fallback checked only requiresReasoningPlaceholderModels, so a requires-only custom entry could fabricate reasoning_content on a path the main assistant history would never emit it on. Gate the orphan placeholder on the preserve list too. - chatgpt-codex-connector P2: Zhipu BigModel GLM ids are thinking-toggle models (low maps to disabled) AND preserve-listed, so the placeholder default could fabricate reasoning for non-thinking histories. Seed requiresReasoningPlaceholderModels: [] for zhipu-bigmodel, matching the MiniMax opt-out. - chatgpt-codex-connector P2: OAuth reconcile deleted an explicit requiresReasoningPlaceholderModels: [] opt-out on every startup because no OAuth preset seeds the field. Keep the field out of OAUTH_RECONCILE_FIELDS; registry seeds still reach existing rows via enrichProviderFromRegistry. - CodeRabbit minor: fix Korean spelling in the providers table. Refs #1193 --- .../ko/reference/configuration/providers.md | 2 +- src/adapters/openai-chat.ts | 8 ++++- src/oauth/index.ts | 6 +++- src/providers/registry.ts | 4 +++ tests/deepseek-reasoning-replay-gaps.test.ts | 30 +++++++++++++++++++ tests/oauth-provider-reconcile.test.ts | 19 ++++++++++++ 6 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index f3faf2c152..708f5fbe9c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -92,7 +92,7 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. | -| `requiresReasoningPlaceholderModels?` | `string[]` | `reasoning_content`가 없는 tool_call 연속을 업스트림이 거부하는 모델(DeepSeek thinking 모드). 리플레이 캐시 미스 시 최소 플레이스홀더를 주입합니다. 미설정 시 `preserveReasoningContentModels`를 따륩며 `[]`로 명시적 해제 가능. | +| `requiresReasoningPlaceholderModels?` | `string[]` | `reasoning_content`가 없는 tool_call 연속을 업스트림이 거부하는 모델(DeepSeek thinking 모드). 리플레이 캐시 미스 시 최소 플레이스홀더를 주입합니다. 미설정 시 `preserveReasoningContentModels`를 따르며 `[]`로 명시적 해제 가능. | | `thinkingToggleModels?` | `string[]` | effort 계층 대신 `thinking.enabled`를 쓰는 chat 모델입니다. | | `thinkingBudgetModels?` | `string[]` | 정수 `thinking_budget`를 쓰는 chat 모델입니다. effort는 예산 비율로 매핑됩니다. | | `noVisionModels?` | `string[]` | vision sidecar로 보내는 텍스트 전용 모델입니다. 일치 판정은 Ollama `:size` 태그도 허용합니다. | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 9042d8ee25..448d7297db 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -429,11 +429,17 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon // tool_call continuation on a thinking-mode provider — inject a // placeholder when the replay cache missed (the bounded cache can // always miss on long sessions), or DeepSeek thinking mode 400s. + // Gate on the preserve list too: reasoning_content is only ever + // serialized for preserve-listed models, so a requires-only custom + // entry must not fabricate it on this path (P2 on #1205). // `||` (not `??`): the cache never stores empty strings, but treat a // falsy hit as a miss so the placeholder still fires. const orphanReasoning = cachedReasoning - || (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId) ? " " : undefined); + || (modelInList(provider.preserveReasoningContentModels, parsed.modelId) + && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId) + ? " " + : undefined); out.push({ role: "assistant", content: emptyAssistantContent(provider), diff --git a/src/oauth/index.ts b/src/oauth/index.ts index b0035a1c02..0bb76c47a9 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -726,8 +726,12 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ "noPenaltyModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", - "requiresReasoningPlaceholderModels", ]; +// `requiresReasoningPlaceholderModels` is deliberately NOT reconciled here: no +// OAuth preset seeds it, so the delete-when-preset-undefined branch would wipe +// an explicit user opt-out (`[]`) on every startup. Registry seeds still reach +// existing rows through enrichProviderFromRegistry, which is fill-only and +// preserves explicit saved values. const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity"; const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 69b6c3a676..1957fdf715 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1711,6 +1711,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, true]), ), preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, + // GLM thinking is a binary toggle (low maps to disabled), so a legitimate + // tool round can carry no reasoning at all; never fabricate a placeholder + // for it, only replay real recorded text (P2 on #1205). + requiresReasoningPlaceholderModels: [], // No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a // false live claim yields an empty picker at runtime. Flip it on once someone verifies it. note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)", diff --git a/tests/deepseek-reasoning-replay-gaps.test.ts b/tests/deepseek-reasoning-replay-gaps.test.ts index 76859116b5..6b72f5aceb 100644 --- a/tests/deepseek-reasoning-replay-gaps.test.ts +++ b/tests/deepseek-reasoning-replay-gaps.test.ts @@ -222,6 +222,36 @@ describe("issue #950 — tool-call reasoning replay invariant (openai-chat wire) expect(hit!["reasoning_content"]).toBe(REASONING); }); + test("P2 guard: a requires-only custom model never gets a placeholder on the orphan path", () => { + // requiresReasoningPlaceholderModels narrows which preserve-listed models + // get a fabricated placeholder. A custom entry listing a model ONLY in the + // requires list (not in preserveReasoningContentModels) must behave like + // the main-assistant path, which never serializes reasoning_content for + // non-preserve models: the synthesized orphan tool_call stays bare + // (chatgpt-codex-connector P2 on #1205). + const parsed = parseRequest({ model: "custom-chat/plain-model", input: [userMessage(), functionCallOutputItem()], stream: true }); + const config: OcxConfig = { + port: 10100, + defaultProvider: "custom-chat", + providers: { + "custom-chat": { + adapter: "openai-chat", + baseUrl: "https://example.invalid/v1", + apiKey: "key", + models: ["plain-model"], + requiresReasoningPlaceholderModels: ["plain-model"], + }, + }, + }; + const route = routeModel(config, parsed.modelId); + parsed.modelId = route.modelId; + const req = createOpenAIChatAdapter(route.provider).buildRequest(parsed as OcxParsedRequest); + const { messages } = JSON.parse(req.body as string) as { messages: Array> }; + const assistant = toolCallAssistant(messages); + expect(assistant).toBeDefined(); + expect(assistant!["reasoning_content"]).toBeUndefined(); + }); + test("documented non-bug: opaque encrypted-only reasoning degrades to the placeholder, not invented plaintext", () => { // Native (non-ocxr1) encrypted reasoning has no readable text; the parser // deliberately degrades instead of inventing replayable plaintext. On a diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index 1ee0830656..91dd7b8498 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -158,4 +158,23 @@ describe("OAuth provider reconciliation", () => { upsertOAuthProvider(config, "google-antigravity"); expect(config.providers["google-antigravity"].liveModels).toBe(true); }); + + test("preserves an explicit requiresReasoningPlaceholderModels opt-out on OAuth providers", () => { + // No OAuth preset seeds the new field, so reconcile must never delete an + // explicit `[]` opt-out on startup (chatgpt-codex-connector P2 on #1205). + const config = { + port: 10100, + defaultProvider: "kimi", + googleAntigravityStaticCatalogVersion: 1, + providers: { + kimi: { + ...structuredClone(OAUTH_PROVIDERS.kimi.providerConfig), + requiresReasoningPlaceholderModels: [], + }, + }, + } satisfies OcxConfig; + + expect(reconcileOAuthProviders(config)).toBe(false); + expect(config.providers.kimi.requiresReasoningPlaceholderModels).toEqual([]); + }); });