From 52850748863f368d854323ae455fa80f79928e9b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 5 Aug 2026 06:06:45 +0900 Subject: [PATCH 1/2] fix(deepseek): canonical item-id repair for UUID message/reasoning ids (#938) DeepSeek's Responses route emits bare UUID item ids that leave Codex stuck on an uncommitted turn. - ResponsesItemIdRepairConfig.repairInvalidIds: existing message/ reasoning ids without the canonical msg_/rs_ prefix mint stable type-scoped canonical ids (function_call ids and call_id untouched) - built-in DeepSeek gets the registry-only policy { repairInvalidIds, repairMissingTerminalIds } filled by derive (explicit config wins) - the bounded-JSON paths (#875) apply the same client normalization on both HTTP SSE synthesis and WS reframes; raw continuation snapshots keep upstream ids Supersedes PR #940 with a narrow reimplementation (its id-prefix idea; its response-id aliasing, envelope synthesis, and relay surgery are deliberately excluded, recorded in the 060 doc). --- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/config.ts | 1 + src/providers/derive.ts | 15 ++ src/providers/registry.ts | 15 ++ src/server/responses-item-id-repair.ts | 13 +- src/types.ts | 6 + .../deepseek-responses-item-id-repair.test.ts | 157 ++++++++++++++++++ 11 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 tests/deepseek-responses-item-id-repair.test.ts 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 bc0b69a749..19e07d53d2 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -85,7 +85,7 @@ namespace 付き combo alias はその namespace prefix に selector を再利 | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | | `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | | `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 | -| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` |正確なプレースホルダー ID および欠落している端末 ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。 | +| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 | | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `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` のみを受け入れるモデル。強制的な選択は格下げされます。 | 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 964ca558ad..11ed1e2e74 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -85,7 +85,7 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | | `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | | `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. | -| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id와 누락된 종료 id를 복구합니다. function-call id는 다시 쓰지 않습니다. | +| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. | | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `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`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 98bb446c9d..70962e4c31 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -92,7 +92,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | -| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids and missing terminal ids. Function-call ids are never rewritten. | +| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | | `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. | 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 5984c48f86..7dc87358a0 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -95,7 +95,7 @@ cross-route credential fallback не существует. Строки API GPT- | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | | `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | | `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. | -| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id и отсутствующих terminal-id. Function-call id никогда не переписываются. | +| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id, отсутствующих terminal-id и (с `repairInvalidIds`) message/reasoning id без канонического префикса `msg_`/`rs_`. Function-call id никогда не переписываются. Встроенный DeepSeek включает последние два по умолчанию. | | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `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 понижается. | 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 56befac3db..450db54372 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 @@ -84,7 +84,7 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | | `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | | `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 | -| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id 和缺失的终止 id。function-call id 永远不会被重写。 | +| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id、缺失的终止 id,以及(`repairInvalidIds`)缺少规范 `msg_`/`rs_` 前缀的 message/reasoning id。function-call id 永远不会被重写。内置 DeepSeek 默认启用后两项。 | | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `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` 的模型;强制选择会被降级。 | diff --git a/src/config.ts b/src/config.ts index 08f803108d..ea7c373808 100644 --- a/src/config.ts +++ b/src/config.ts @@ -597,6 +597,7 @@ const providerConfigSchema = z.object({ message: z.array(z.string().min(1)).optional(), reasoning: z.array(z.string().min(1)).optional(), repairMissingTerminalIds: z.boolean().optional(), + repairInvalidIds: z.boolean().optional(), }).strict().optional(), responsesSnapshotRepair: z.boolean().optional(), }).passthrough(); diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 1cfbbd5dc1..87b3a0e15c 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -276,6 +276,21 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // the entry so an explicit user value stays distinguishable from the default. if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; + // Registry-only repair policy (#938): fill only when the runtime provider has + // no explicit policy, and deep-clone so saved/user values never alias the + // registry constant. + if (prov.responsesItemIdRepair === undefined && entry.responsesItemIdRepair) { + prov.responsesItemIdRepair = { + ...(entry.responsesItemIdRepair.message ? { message: [...entry.responsesItemIdRepair.message] } : {}), + ...(entry.responsesItemIdRepair.reasoning ? { reasoning: [...entry.responsesItemIdRepair.reasoning] } : {}), + ...(entry.responsesItemIdRepair.repairMissingTerminalIds !== undefined + ? { repairMissingTerminalIds: entry.responsesItemIdRepair.repairMissingTerminalIds } + : {}), + ...(entry.responsesItemIdRepair.repairInvalidIds !== undefined + ? { repairInvalidIds: entry.responsesItemIdRepair.repairInvalidIds } + : {}), + }; + } if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels]; if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels]; if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels]; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 0042f223c1..ae26b6964c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -161,6 +161,17 @@ export interface ProviderRegistryEntry { * can omit or indefinitely delay the terminal event. */ modelResponsesUpstreamStreaming?: Record; + /** + * Registry-only client-facing item-id repair policy (#938), filled onto the + * runtime provider only when the user has no explicit policy (derive.ts); + * never seeded into saved config. + */ + responsesItemIdRepair?: { + message?: string[]; + reasoning?: string[]; + repairMissingTerminalIds?: boolean; + repairInvalidIds?: boolean; + }; /** * Responses-API resource path for providers whose route is not `/v1/responses`. * Unlike `modelWireDefaults` above, this IS seeded into saved config: it describes @@ -1144,6 +1155,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // terminal event. Keep Codex on WebSocket, but use the provider's bounded JSON // response upstream so the bridge can synthesize a complete WS event sequence. modelResponsesUpstreamStreaming: { "deepseek-v4-flash": false }, + // DeepSeek's Responses route emits bare UUID item ids, which leave Codex + // clients stuck on an uncommitted turn (#938). Client-facing only — raw + // continuation snapshots keep the upstream ids. + responsesItemIdRepair: { repairInvalidIds: true, repairMissingTerminalIds: true }, // DeepSeek's Responses route is `POST /responses` with no `/v1` segment. Without // this the passthrough adapter falls back to its legacy `/v1/responses` // construction and the wire above can never route. diff --git a/src/server/responses-item-id-repair.ts b/src/server/responses-item-id-repair.ts index 1e399154bc..e385f1ee41 100644 --- a/src/server/responses-item-id-repair.ts +++ b/src/server/responses-item-id-repair.ts @@ -7,6 +7,7 @@ type RepairableItemType = "message" | "reasoning"; interface ResponsesItemIdRepairState { readonly repairMissingTerminalIds: boolean; + readonly repairInvalidIds: boolean; readonly placeholders: Record>; readonly outputIds: Record>; readonly scope: string; @@ -53,6 +54,7 @@ function mintCanonicalId(type: RepairableItemType, scope: string, outputIndex: n function createRepairState(config: ResponsesItemIdRepairConfig, budget?: TranslatorBudget): ResponsesItemIdRepairState { const state = { repairMissingTerminalIds: config.repairMissingTerminalIds === true, + repairInvalidIds: config.repairInvalidIds === true, placeholders: { message: new Set(config.message ?? []), reasoning: new Set(config.reasoning ?? []), @@ -85,9 +87,13 @@ function rememberMappedId( if (!rawId) return null; const mapped = state.placeholders[type].has(rawId) ? mintCanonicalId(type, state.scope, outputIndex) - : state.repairMissingTerminalIds - ? rawId - : null; + : state.repairInvalidIds && !rawId.startsWith(REPAIRABLE_PREFIXES[type]) + // An existing id without the canonical msg_/rs_ prefix (bare UUIDs from + // DeepSeek's Responses route) leaves Codex stuck on Thinking (#938). + ? mintCanonicalId(type, state.scope, outputIndex) + : state.repairMissingTerminalIds + ? rawId + : null; if (!mapped) return null; state.budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([outputIndex, rawId, mapped])).byteLength, { kind: "item_ids" }); state.outputIds[type].set(outputIndex, mapped); @@ -219,6 +225,7 @@ export function relaySseWithResponsesItemIdRepair( export function hasResponsesItemIdRepair(config: ResponsesItemIdRepairConfig | undefined): boolean { return config?.repairMissingTerminalIds === true + || config?.repairInvalidIds === true || (config?.message?.length ?? 0) > 0 || (config?.reasoning?.length ?? 0) > 0; } diff --git a/src/types.ts b/src/types.ts index 3c23b8e483..69b8e9c569 100644 --- a/src/types.ts +++ b/src/types.ts @@ -924,6 +924,12 @@ export interface ResponsesItemIdRepairConfig { reasoning?: string[]; /** Backfill missing `output_item.done` / terminal snapshot ids from the matching output_index. */ repairMissingTerminalIds?: boolean; + /** + * Treat existing message/reasoning ids without the canonical `msg_`/`rs_` prefix (e.g. bare + * UUIDs from DeepSeek's Responses route) as invalid and mint canonical replacements (#938). + * function_call ids and call_id pairing are never rewritten. + */ + repairInvalidIds?: boolean; } /** diff --git a/tests/deepseek-responses-item-id-repair.test.ts b/tests/deepseek-responses-item-id-repair.test.ts new file mode 100644 index 0000000000..2213973807 --- /dev/null +++ b/tests/deepseek-responses-item-id-repair.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { + createResponsesItemIdPayloadRewrite, + hasResponsesItemIdRepair, + repairResponsesJsonItemIds, +} from "../src/server/responses-item-id-repair"; +import { handleResponses } from "../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const UUID_MSG = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"; +const UUID_RS = "1b9d6bcd-bbfd-4b2d-9b9d-5c0a2fb41a1b"; +const UUID_FC = "550e8400-e29b-41d4-a716-446655440000"; + +function deepseekProvider(): OcxProviderConfig { + const provider = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; + enrichProviderFromRegistry("deepseek", provider); + return provider; +} + +describe("registry-derived DeepSeek repair policy (#938)", () => { + test("enrich fills the registry policy only when the provider has none", () => { + const filled = deepseekProvider(); + expect(filled.responsesItemIdRepair).toEqual({ repairInvalidIds: true, repairMissingTerminalIds: true }); + + const explicit = { ...filled, responsesItemIdRepair: { repairInvalidIds: false } }; + enrichProviderFromRegistry("deepseek", explicit); + expect(explicit.responsesItemIdRepair).toEqual({ repairInvalidIds: false }); + }); + + test("UUID message/reasoning ids become stable canonical ids; function_call ids are untouched", () => { + const rewrite = createResponsesItemIdPayloadRewrite( + deepseekProvider().responsesItemIdRepair!, + createTestTranslatorBudget(), + ); + const frame = (payload: unknown) => JSON.parse(rewrite(JSON.stringify(payload))) as Record; + + const reasoningAdded = frame({ + type: "response.output_item.added", + output_index: 0, + item: { type: "reasoning", id: UUID_RS, summary: [] }, + }); + const messageAdded = frame({ + type: "response.output_item.added", + output_index: 1, + item: { type: "message", id: UUID_MSG, role: "assistant", status: "in_progress", content: [] }, + }); + const rsId = (reasoningAdded.item as { id: string }).id; + const msgId = (messageAdded.item as { id: string }).id; + expect(rsId).toMatch(/^rs_ocx_[a-f0-9]+_0$/); + expect(msgId).toMatch(/^msg_ocx_[a-f0-9]+_1$/); + + // Every lifecycle occurrence of the same index gets the SAME canonical id. + const textDelta = frame({ type: "response.output_text.delta", item_id: UUID_MSG, output_index: 1, delta: "hi" }); + expect(textDelta.item_id).toBe(msgId); + const reasoningDone = frame({ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: UUID_RS, summary: [] } }); + expect((reasoningDone.item as { id: string }).id).toBe(rsId); + + // The terminal snapshot repeats the canonical ids. + const completed = frame({ + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "reasoning", id: UUID_RS, summary: [] }, + { type: "message", id: UUID_MSG, role: "assistant", status: "completed", content: [] }, + ], + }, + }); + const output = (completed.response as { output: { id: string }[] }).output; + expect(output[0]!.id).toBe(rsId); + expect(output[1]!.id).toBe(msgId); + + // function_call id and call_id are never rewritten. + const callDone = frame({ + type: "response.output_item.done", + output_index: 2, + item: { type: "function_call", id: UUID_FC, call_id: "call_abc", name: "search", arguments: "{}" }, + }); + expect((callDone.item as { id: string }).id).toBe(UUID_FC); + expect((callDone.item as { call_id: string }).call_id).toBe("call_abc"); + }); + + test("canonical and placeholder ids behave as before (no repair when already canonical)", () => { + const rewrite = createResponsesItemIdPayloadRewrite( + deepseekProvider().responsesItemIdRepair!, + createTestTranslatorBudget(), + ); + const payload = JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_real_123", role: "assistant", status: "in_progress", content: [] }, + }); + expect(rewrite(payload)).toBe(payload); + }); + + test("repairResponsesJsonItemIds normalizes a whole bounded-JSON response", () => { + const repaired = repairResponsesJsonItemIds( + { + id: "resp_1", + status: "completed", + output: [ + { type: "reasoning", id: UUID_RS, summary: [] }, + { type: "message", id: UUID_MSG, role: "assistant", status: "completed", content: [] }, + ], + }, + deepseekProvider().responsesItemIdRepair!, + createTestTranslatorBudget(), + ); + const output = repaired.output as { id: string }[]; + expect(output[0]!.id).toMatch(/^rs_ocx_/); + expect(output[1]!.id).toMatch(/^msg_ocx_/); + }); +}); + +describe("bounded-JSON HTTP path carries canonical ids (#938 + #875)", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("the synthesized terminal SSE contains no upstream UUID item ids", async () => { + globalThis.fetch = (async () => Response.json({ + id: "resp_deepseek", + object: "response", + status: "completed", + output: [ + { type: "reasoning", id: UUID_RS, summary: [] }, + { type: "message", id: UUID_MSG, role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi", annotations: [] }] }, + { type: "function_call", id: UUID_FC, call_id: "call_keep", name: "search", arguments: "{}" }, + ], + })) as typeof fetch; + + const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-flash", input: "ping", stream: true }), + }), + config, + { model: "", provider: "" }, + {}, + ); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const text = await response.text(); + expect(text).not.toContain(UUID_MSG); + expect(text).not.toContain(UUID_RS); + expect(text).toContain("msg_ocx_"); + expect(text).toContain("rs_ocx_"); + // function_call identity survives byte-for-byte. + expect(text).toContain(UUID_FC); + expect(text).toContain("call_keep"); + expect(text).toContain("data: [DONE]"); + }); +}); From a26d6a7347fd5e88d7f6994c82f858bb502d8bfc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 5 Aug 2026 06:14:16 +0900 Subject: [PATCH 2/2] fix(deepseek): backfill the registry item-id repair policy on the live route path (#938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routedProviderConfig never enriched providers, so saved configs and fresh provider adds never received the registry repair policy — the e2e test masked this by enriching manually. Fill-only-when-absent with cloned arrays, matching the file's established backfill pattern; activation test now uses the ordinary un-enriched seed. --- src/router.ts | 17 +++++++++++++++++ tests/deepseek-responses-item-id-repair.test.ts | 8 ++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/router.ts b/src/router.ts index fd8af30f15..9b1fa5db1f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -296,6 +296,23 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), + // Registry-only client-facing repair policy (#938): fill only when the + // saved provider has no explicit policy; clone so runtime never aliases + // the registry constant. + ...(provider.responsesItemIdRepair === undefined && registryEntry.responsesItemIdRepair + ? { + responsesItemIdRepair: { + ...(registryEntry.responsesItemIdRepair.message ? { message: [...registryEntry.responsesItemIdRepair.message] } : {}), + ...(registryEntry.responsesItemIdRepair.reasoning ? { reasoning: [...registryEntry.responsesItemIdRepair.reasoning] } : {}), + ...(registryEntry.responsesItemIdRepair.repairMissingTerminalIds !== undefined + ? { repairMissingTerminalIds: registryEntry.responsesItemIdRepair.repairMissingTerminalIds } + : {}), + ...(registryEntry.responsesItemIdRepair.repairInvalidIds !== undefined + ? { repairInvalidIds: registryEntry.responsesItemIdRepair.repairInvalidIds } + : {}), + }, + } + : {}), authMode: canonicalAuthMode, apiKey: resolvedApiKey, // Backfill the Google wire mode + Vertex project/location from the registry when the user diff --git a/tests/deepseek-responses-item-id-repair.test.ts b/tests/deepseek-responses-item-id-repair.test.ts index 2213973807..44fe98a59c 100644 --- a/tests/deepseek-responses-item-id-repair.test.ts +++ b/tests/deepseek-responses-item-id-repair.test.ts @@ -120,7 +120,11 @@ describe("bounded-JSON HTTP path carries canonical ids (#938 + #875)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); - test("the synthesized terminal SSE contains no upstream UUID item ids", async () => { + test("the synthesized terminal SSE contains no upstream UUID item ids (un-enriched saved seed)", async () => { + // The live path must backfill the registry policy through routedProviderConfig — + // no manual enrichProviderFromRegistry (the ordinary saved-config shape). + const plainSeed = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; + expect(plainSeed.responsesItemIdRepair).toBeUndefined(); globalThis.fetch = (async () => Response.json({ id: "resp_deepseek", object: "response", @@ -132,7 +136,7 @@ describe("bounded-JSON HTTP path carries canonical ids (#938 + #875)", () => { ], })) as typeof fetch; - const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + const config = { providers: { deepseek: plainSeed } } as unknown as OcxConfig; const response = await handleResponses( new Request("http://localhost/v1/responses", { method: "POST",