diff --git a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts index 578750fe1ed..3023265fa6b 100644 --- a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts @@ -129,7 +129,15 @@ vi.mock('@cindy/anthropic-compat-proxy', async (importOriginal) => { }), stripEncryptedContentFromBody: () => null, stripImageGenerationItemsWithoutIdFromBody: () => null, + createResponsesItemIdLengthRecoveryRule: () => ({ + id: 'responses_item_id_length', + enabled: () => true, + matches: (text: string) => + /input\[\d+\]\.id[\s\S]{0,120}?(?:string too long|string_above_max_length)|string_above_max_length[\s\S]{0,120}?input\[\d+\]\.id/i.test(text), + strip: () => null, + }), stripNonCanonicalResponsesItemIdsFromBody: () => null, + shortenOversizedResponsesItemIdsFromBody: () => null, stripNonAnthropicFields: mockState.stripNonAnthropicFields, // 视觉桥 transform:默认短路(controller 未注入 → shouldBridge 恒 false → null 透传)。 createVisionBridgeTransform: () => (() => null), @@ -2738,13 +2746,14 @@ describe('codex proxy host', () => { // upstream 是函数形态(每请求现取,model-access 下发可运行期换 endpoint); // 断言其当前求值 = 网关 base + /v1 upstream: expect.any(Function), - // [encrypted activeStrip, image generation activeStrip, provider-aware Guardian reviewer, locked Subagent route, instructions 注入, locked Subagent exec guard, Gateway 原生 web_search, 跨来源压缩块兼容, xAI ModelInput activeStrip, responses item id activeStrip(#4738), exec function adapter, strict gateway history 兼容, xAI ModelInput sanitize, DeepSeek V4 custom tool 兼容, xAI Responses 兼容, XD Gateway Grok 兼容, ByteDance Seed tool 兼容, MiniMax effort 兼容, provider model rewrite, provider 参数归一, 视觉桥(controller 未注入 → 短路透传), 工具 ID 校正, stripNonAnthropicFields] + // [encrypted activeStrip, image generation activeStrip, provider-aware Guardian reviewer, locked Subagent route, instructions 注入, locked Subagent exec guard, Gateway 原生 web_search, 跨来源压缩块兼容, xAI ModelInput activeStrip, responses item id activeStrip(#4738), responses item id length activeStrip(#4227), exec function adapter, strict gateway history 兼容, xAI ModelInput sanitize, DeepSeek V4 custom tool 兼容, xAI Responses 兼容, XD Gateway Grok 兼容, ByteDance Seed tool 兼容, MiniMax effort 兼容, provider model rewrite, provider 参数归一, 视觉桥(controller 未注入 → 短路透传), 工具 ID 校正, stripNonAnthropicFields] transformRequest: [ expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), expect.any(Function), + expect.any(Function), ], transformResponse: expect.any(Function), routingTransform: expect.any(Function), @@ -2753,6 +2762,7 @@ describe('codex proxy host', () => { expect.objectContaining({ id: 'encrypted_content' }), expect.objectContaining({ id: 'image_generation_id' }), expect.objectContaining({ id: 'responses_item_id_prefix' }), + expect.objectContaining({ id: 'responses_item_id_length' }), expect.objectContaining({ id: 'xai_model_input' }), ]), }), @@ -2863,8 +2873,15 @@ describe('codex proxy host', () => { threadId: 'thread-msg-id', message: "Invalid 'input[290].id': 'chatcmpl-8f2a1c_msg_0'. Expected an ID that begins with 'msg'.", })).toBe('responses_item_id_prefix'); + expect(host.armCodexHttpRecovery({ + sessionId: 'session-id-length', + threadId: 'thread-id-length', + message: 'Bad request', + additionalDetails: "Invalid 'input[74].id': string too long. Expected a string with maximum length 64, but got a string with length 84 instead.", + })).toBe('responses_item_id_length'); expect(proxyOpts.resolveWebSocketUpstream(ctxForThread('thread-encrypted'))).toBeNull(); + expect(proxyOpts.resolveWebSocketUpstream(ctxForThread('thread-id-length'))).toBeNull(); expect(proxyOpts.resolveWebSocketUpstream(ctxForThread('thread-image'))).toBeNull(); expect(proxyOpts.resolveWebSocketUpstream(ctxForThread('thread-safe'))).toBe( 'https://chatgpt.com/backend-api/codex', @@ -6415,7 +6432,7 @@ describe('codex proxy host', () => { await host.ensureCodexProxyReady(); const transforms = mockState.createAnthropicCompatProxy.mock.calls[0]?.[0]?.transformRequest ?? []; - expect(transforms).toHaveLength(24); // encrypted activeStrip, image generation activeStrip, provider-aware Guardian reviewer, locked Subagent route, instructions 注入, locked Subagent exec guard, Gateway 原生 web_search, 跨来源压缩块兼容, xAI ModelInput activeStrip, responses item id activeStrip(#4738), exec function adapter, strict gateway history 兼容, xAI ModelInput sanitize, DeepSeek V4 custom tool 兼容, xAI Responses 兼容, XD Gateway Grok 兼容, ByteDance Seed tool 兼容, MiniMax effort 兼容, provider model rewrite, provider 参数归一, 视觉桥(短路), 工具 ID 校正, stripNonAnthropicFields, dump + expect(transforms).toHaveLength(25); // encrypted activeStrip, image generation activeStrip, provider-aware Guardian reviewer, locked Subagent route, instructions 注入, locked Subagent exec guard, Gateway 原生 web_search, 跨来源压缩块兼容, xAI ModelInput activeStrip, responses item id activeStrip(#4738), responses item id length activeStrip(#4227), exec function adapter, strict gateway history 兼容, xAI ModelInput sanitize, DeepSeek V4 custom tool 兼容, xAI Responses 兼容, XD Gateway Grok 兼容, ByteDance Seed tool 兼容, MiniMax effort 兼容, provider model rewrite, provider 参数归一, 视觉桥(短路), 工具 ID 校正, stripNonAnthropicFields, dump const ctx = { method: 'POST', url: '/v1/responses', diff --git a/apps/desktop/src/main/maker-host/codex-proxy-host.ts b/apps/desktop/src/main/maker-host/codex-proxy-host.ts index eb69137662a..57a5ee7fc8f 100644 --- a/apps/desktop/src/main/maker-host/codex-proxy-host.ts +++ b/apps/desktop/src/main/maker-host/codex-proxy-host.ts @@ -30,6 +30,7 @@ import { createEncryptedContentRecoveryRule, createImageGenerationIdRecoveryRule, createResponsesItemIdPrefixRecoveryRule, + createResponsesItemIdLengthRecoveryRule, createInstructionsInjectionTransform, createInstructionsRegistry, createXaiModelInputRecoveryRule, @@ -40,6 +41,7 @@ import { stripEncryptedContentFromBody, stripImageGenerationItemsWithoutIdFromBody, stripNonCanonicalResponsesItemIdsFromBody, + shortenOversizedResponsesItemIdsFromBody, stripNonAnthropicFields, type ForwardLifecycleFailure, type ForwardLifecycleObserver, @@ -112,6 +114,7 @@ import { encryptedStripController, imageGenerationStripController, responsesItemIdStripController, + responsesItemIdLengthStripController, xaiModelInputStripController, } from './thread-strip-controllers.js'; import { createMakerLogger } from './logger-adapter.js'; @@ -206,6 +209,9 @@ const imageGenerationIdRecoveryRule = createImageGenerationIdRecoveryRule({ const responsesItemIdPrefixRecoveryRule = createResponsesItemIdPrefixRecoveryRule({ onRetry: (threadId, model) => responsesItemIdStripController.markActive(threadId, model), }); +const responsesItemIdLengthRecoveryRule = createResponsesItemIdLengthRecoveryRule({ + onRetry: (threadId, model) => responsesItemIdLengthStripController.markActive(threadId, model), +}); const xaiModelInputRecoveryRule = createXaiModelInputRecoveryRule({ onRetry: (threadId, model) => xaiModelInputStripController.markActive(threadId, model), }); @@ -214,6 +220,7 @@ const CODEX_BODY_RECOVERY_RULES = [ encryptedContentRecoveryRule, imageGenerationIdRecoveryRule, responsesItemIdPrefixRecoveryRule, + responsesItemIdLengthRecoveryRule, xaiModelInputRecoveryRule, vllmResponsesCompatibilityRule, ] as const; @@ -2914,6 +2921,12 @@ function createTransformRequestChain( enabled: () => true, strip: stripNonCanonicalResponsesItemIdsFromBody, }), + // issue #4227: 同理, 上游拒绝过一次超长 item id 后, 该 thread 后续发送前预改写。 + createActiveStripTransform({ + controller: responsesItemIdLengthStripController, + enabled: () => true, + strip: shortenOversizedResponsesItemIdsFromBody, + }), // Providers that explicitly lack Responses custom tools still accept ordinary // functions. Adapt before provider sanitizers, then restore custom_tool_call // events on the matching response stream. diff --git a/apps/desktop/src/main/maker-host/thread-strip-controllers.ts b/apps/desktop/src/main/maker-host/thread-strip-controllers.ts index 1088a351132..ddf75ecc861 100644 --- a/apps/desktop/src/main/maker-host/thread-strip-controllers.ts +++ b/apps/desktop/src/main/maker-host/thread-strip-controllers.ts @@ -15,6 +15,10 @@ export const imageGenerationStripController = createThreadStripController(); // 切 GPT 后 `chatcmpl-…_msg_0` 被 Responses 拒绝)—— always-on; 恢复一次后该 thread 发送前预洗。 export const responsesItemIdStripController = createThreadStripController(); +// Responses 历史 item id 超长主动改写(issue #4227: Gateway/grok 历史的 `ws_…_call-…` 84 字符 +// id 切 OpenAI 后被 64 上限拒绝)—— always-on; 恢复一次后该 thread 发送前预改写。 +export const responsesItemIdLengthStripController = createThreadStripController(); + // 空 thinking 块主动剥离 —— always-on(删空块零成本)。必须与上面是独立实例。 export const emptyThinkingStripController = createThreadStripController(); diff --git a/packages/anthropic-compat-proxy/src/index.ts b/packages/anthropic-compat-proxy/src/index.ts index 81af9b7118c..c56197eaef7 100644 --- a/packages/anthropic-compat-proxy/src/index.ts +++ b/packages/anthropic-compat-proxy/src/index.ts @@ -45,6 +45,7 @@ export { createEncryptedContentRecoveryRule, createImageGenerationIdRecoveryRule, createResponsesItemIdPrefixRecoveryRule, + createResponsesItemIdLengthRecoveryRule, createToolExchangeAdjacencyRecoveryRule, compactOversizedImageHistory, createToolUseProviderSpecificFieldsRecoveryRule, @@ -61,6 +62,8 @@ export { stripImageGenerationItemsWithoutIdFromBody, stripNonAnthropicFields, stripNonCanonicalResponsesItemIdsFromBody, + shortenOversizedResponsesItemIdsFromBody, + shortenResponsesItemId, stripToolUseProviderSpecificFields, stripToolUseProviderSpecificFieldsFromBody, } from './transform.js'; diff --git a/packages/anthropic-compat-proxy/src/transform.test.ts b/packages/anthropic-compat-proxy/src/transform.test.ts index e3e088288f0..655ada73d8e 100644 --- a/packages/anthropic-compat-proxy/src/transform.test.ts +++ b/packages/anthropic-compat-proxy/src/transform.test.ts @@ -25,6 +25,9 @@ import { stripNonAnthropicFields, stripNonCanonicalResponsesItemIdsFromBody, createResponsesItemIdPrefixRecoveryRule, + createResponsesItemIdLengthRecoveryRule, + shortenOversizedResponsesItemIdsFromBody, + shortenResponsesItemId, stripToolUseProviderSpecificFields, stripToolUseProviderSpecificFieldsFromBody, } from './transform.js'; @@ -576,6 +579,61 @@ describe('stripNonCanonicalResponsesItemIdsFromBody (issue #4738)', () => { }); }); +describe('shortenOversizedResponsesItemIdsFromBody (issue #4227)', () => { + const longWs = 'ws_0cb4cb13-dee8-9afb-aece-c5ec0b3bf2bf_call-94cd3890-d2bb-41ae-8c39-9613c70a322c-24'; + const longTco = 'tco_0cb4cb13-dee8-9afb-aece-c5ec0b3bf2bf_call-94cd3890-d2bb-41ae-8c39-9613c70a322c-24'; + + it('rewrites only ids longer than 64 chars, keeping the type prefix and a stable digest', () => { + expect(longWs).toHaveLength(84); + expect(longTco).toHaveLength(85); + const shortWs = shortenResponsesItemId(longWs); + expect(shortWs).toHaveLength(64); + expect(shortWs.startsWith('ws_')).toBe(true); + expect(shortWs).toMatch(/^ws_[0-9a-f]{61}$/); + // 稳定: 每轮回放同一原 id 得到同一短 id; 不同原 id 不同短 id + expect(shortenResponsesItemId(longWs)).toBe(shortWs); + expect(shortenResponsesItemId(longTco)).not.toBe(shortWs); + expect(shortenResponsesItemId(longTco).startsWith('tco_')).toBe(true); + // 未超长原样; 无字母前缀的超长 id 只用 hash + expect(shortenResponsesItemId('fc_short')).toBe('fc_short'); + expect(shortenResponsesItemId('x'.repeat(64))).toBe('x'.repeat(64)); + expect(shortenResponsesItemId('0123456789'.repeat(7))).toMatch(/^[0-9a-f]{64}$/); + }); + + it('rewrites oversized ids in the top-level input only and leaves call_id, bodies and short ids alone', () => { + const body = buf({ + model: 'gpt-5.6-luna', + input: [ + { type: 'message', role: 'user', content: 'hi' }, + { type: 'web_search_call', id: longWs, status: 'completed' }, + { type: 'function_call', id: longTco, call_id: longTco, name: 'tool', arguments: '{}' }, + { type: 'function_call_output', call_id: longTco, output: JSON.stringify({ input: [{ id: longWs }] }) }, + { type: 'message', id: 'msg_ok', role: 'assistant', content: [{ type: 'output_text', text: longWs }] }, + ], + metadata: { id: longWs }, + }); + const out = shortenOversizedResponsesItemIdsFromBody(body); + expect(out).not.toBeNull(); + const parsed = JSON.parse(out!.toString('utf8')); + expect(parsed.input[1].id).toBe(shortenResponsesItemId(longWs)); + expect(parsed.input[2].id).toBe(shortenResponsesItemId(longTco)); + // call_id 配对与正文不动 + expect(parsed.input[2].call_id).toBe(longTco); + expect(parsed.input[3].call_id).toBe(longTco); + expect(parsed.input[3].output).toContain(longWs); + expect(parsed.input[4]).toEqual({ type: 'message', id: 'msg_ok', role: 'assistant', content: [{ type: 'output_text', text: longWs }] }); + expect(parsed.metadata).toEqual({ id: longWs }); + // 幂等: 改写后再跑没有可改的 + expect(shortenOversizedResponsesItemIdsFromBody(out!)).toBeNull(); + }); + + it('returns null when nothing is oversized, for non-JSON, and for non-Responses bodies', () => { + expect(shortenOversizedResponsesItemIdsFromBody(buf({ input: [{ type: 'message', id: 'msg_1', role: 'assistant', content: [] }] }))).toBeNull(); + expect(shortenOversizedResponsesItemIdsFromBody(buf({ messages: [{ role: 'user', content: longWs }] }))).toBeNull(); + expect(shortenOversizedResponsesItemIdsFromBody(Buffer.from('not json', 'utf8'))).toBeNull(); + }); +}); + describe('stripEmptyThinkingFromBody', () => { it('removes an empty-content thinking block, keeping the sibling text', () => { const body = buf({ @@ -1079,6 +1137,23 @@ describe('recovery rule factories', () => { expect(rule.strip(buf({ input: [{ type: 'message', id: 'msg_x', role: 'assistant', content: [] }] }))).toBeNull(); }); + it('responses item id length rule matches the OpenAI 64-char id error and shortens oversized ids (issue #4227)', () => { + const rule = createResponsesItemIdLengthRecoveryRule(); + expect(rule.id).toBe('responses_item_id_length'); + expect(rule.enabled()).toBe(true); + // 2026-09-20 两处实测原文 + expect(rule.matches("Invalid 'input[74].id': string too long. Expected a string with maximum length 64, but got a string with length 84 instead.")).toBe(true); + expect(rule.matches('[ApiIdParam] [input[59].id] [string_above_max_length] Invalid input[59].id: string too long. Expected a string with maximum length 64, but got a string with length 84 instead')).toBe(true); + expect(rule.matches(JSON.stringify({ error: { message: "Invalid 'input[35].id': string too long. Expected a string with maximum length 64, but got a string with length 83 instead.", type: 'invalid_request_error', code: 'string_above_max_length', param: 'input[35].id' } }))).toBe(true); + // 其他字段的长度错误、前缀错误、密文错误不接管 + expect(rule.matches("Invalid 'input[2].call_id': string too long. Expected a string with maximum length 64")).toBe(false); + expect(rule.matches("Invalid 'input[290].id': 'chatcmpl-8f2a1c_msg_0'. Expected an ID that begins with 'msg'.")).toBe(false); + expect(rule.matches('invalid_encrypted_content')).toBe(false); + const longId = `ws_${'a'.repeat(80)}`; + expect(rule.strip(buf({ input: [{ type: 'web_search_call', id: longId }] }))).not.toBeNull(); + expect(rule.strip(buf({ input: [{ type: 'web_search_call', id: 'ws_short' }] }))).toBeNull(); + }); + it('empty-thinking rule matches only its error text, is always-on by default, and strips empty thinking', () => { const rule = createEmptyThinkingRecoveryRule(); expect(rule.id).toBe('empty_thinking'); diff --git a/packages/anthropic-compat-proxy/src/transform.ts b/packages/anthropic-compat-proxy/src/transform.ts index 71b91ab5fc9..00e11aacec4 100644 --- a/packages/anthropic-compat-proxy/src/transform.ts +++ b/packages/anthropic-compat-proxy/src/transform.ts @@ -4,6 +4,9 @@ export { stripToolUseProviderSpecificFieldsFromBody, stripNonCanonicalResponsesItemIdsFromBody, createResponsesItemIdPrefixRecoveryRule, + shortenOversizedResponsesItemIdsFromBody, + shortenResponsesItemId, + createResponsesItemIdLengthRecoveryRule, stripToolUseProviderSpecificFields, stripEncryptedContentFromBody, stripImageGenerationItemsWithoutIdFromBody, diff --git a/packages/model-compat/src/cindy/proxy/transform.ts b/packages/model-compat/src/cindy/proxy/transform.ts index 056ac8f02dc..a00cf003182 100644 --- a/packages/model-compat/src/cindy/proxy/transform.ts +++ b/packages/model-compat/src/cindy/proxy/transform.ts @@ -12,6 +12,7 @@ */ import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; import { DEFAULT_THREAD_ID_HEADERS, selectedHeaderValue } from './headers.js'; import type { ThreadStripController } from './thread-strip-controller.js'; @@ -392,6 +393,65 @@ export function stripNonCanonicalResponsesItemIdsFromBody(rawBody: Buffer): Buff } } +/** OpenAI Responses 对 `input[n].id` 的长度上限(issue #4227 实测 "maximum length 64")。 */ +export const RESPONSES_ITEM_ID_MAX_LENGTH = 64; + +/** + * 把一个超长的 Responses item id 改写成 `<类型前缀>_`、总长 ≤ 64 的稳定 id。 + * + * 前缀取原 id 第一个下划线前的字母段(`ws_` / `tco_` / `fc_` / `msg_` …), 上游按前缀判定 + * item 类型, 必须保留; 其余用原 id 的 sha256 十六进制截断填满, 同一原 id 每轮回放映射一致, + * 不同原 id 互不碰撞(截断后仍 ≥ 55 hex)。没有字母前缀的 id 直接用 hash 截断。 + * 未超长的 id 原样返回。 + */ +export function shortenResponsesItemId(id: string): string { + if (id.length <= RESPONSES_ITEM_ID_MAX_LENGTH) return id; + const prefix = /^([A-Za-z]+)_/.exec(id)?.[1]; + const digest = createHash('sha256').update(id, 'utf8').digest('hex'); + if (!prefix) return digest.slice(0, RESPONSES_ITEM_ID_MAX_LENGTH); + return `${prefix}_${digest}`.slice(0, RESPONSES_ITEM_ID_MAX_LENGTH); +} + +function shortenOversizedResponsesInputIds(body: unknown): number { + if (!isPlainObject(body) || !Array.isArray(body.input)) return 0; + let rewritten = 0; + for (const item of body.input) { + if (!isPlainObject(item) || typeof item.id !== 'string') continue; + if (item.id.length <= RESPONSES_ITEM_ID_MAX_LENGTH) continue; + item.id = shortenResponsesItemId(item.id); + rewritten += 1; + } + return rewritten; +} + +/** + * 改写 Responses 请求体 input 历史里超过 64 字符的 item `id`。 + * + * 背景(issue #4227): 同一会话先走 Gateway 模型(grok 等), 历史里 web_search / tool 类 + * item 的 id 形如 `ws__call--`(84~85 字符); 切到 OpenAI Responses 后 + * Codex 原样回放, 上游 400: + * "Invalid 'input[74].id': string too long. Expected a string with maximum length 64, but got a string with length 84 instead." + * 之后每轮、重试与 compact 都被同一条历史拦住。 + * + * 只改写 id 本身(保留类型前缀 + 稳定 hash), `call_id`、正文与其他字段不动: 工具配对靠 + * `call_id`, item `id` 只需在本次请求内稳定且类型可辨。只扫顶层 `body.input`; 没有可改的返回 null。 + */ +export function shortenOversizedResponsesItemIdsFromBody(rawBody: Buffer): Buffer | null { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody.toString('utf8')); + } catch { + return null; + } + const rewritten = shortenOversizedResponsesInputIds(parsed); + if (rewritten === 0) return null; + try { + return Buffer.from(JSON.stringify(parsed), 'utf8'); + } catch { + return null; + } +} + /** thinking 块的 `thinking` 字段是否为空(空串 / 缺失 / 非字符串都算空)。 */ function isEmptyThinkingBlock(block: unknown): boolean { if (!isPlainObject(block)) return false; @@ -1532,6 +1592,13 @@ const EMPTY_ASSISTANT_MESSAGE_RE = /with role 'assistant' must not be empty/i; const RESPONSES_ITEM_ID_PREFIX_RE = /Invalid\s+\\?["']input\[\d+\]\.id\\?["']:\s*\\?["'](?!(?:fc|fco|ctc|ctco)_)[^"'\\]+\\?["']\.?\s+Expected an ID that begins with \\?["'](?:msg|rs)\\?["']/i; +// OpenAI Responses 400(issue #4227, 2026-09-20 两处实测): +// "Invalid 'input[74].id': string too long. Expected a string with maximum length 64, but got a string with length 84 instead." +// "[ApiIdParam] [input[59].id] [string_above_max_length] Invalid input[59].id: string too long. …" +// 只匹配 `input[n].id` 的长度校验; `call_id` 或其他字段的长度错误不接管。 +const RESPONSES_ITEM_ID_LENGTH_RE = + /input\[\d+\]\.id\\?["']?[\s\S]{0,120}?(?:string too long|string_above_max_length)|string_above_max_length[\s\S]{0,120}?input\[\d+\]\.id/i; + // Azure/LiteLLM 400: "Image generation items without `id` are not supported for this request." const IMAGE_GENERATION_WITHOUT_ID_RE = /image generation items without [`']?id[`']? are not supported/i; @@ -1632,6 +1699,25 @@ export function createResponsesItemIdPrefixRecoveryRule(opts: { }; } +/** + * Responses 历史 item id 超长 400 恢复规则(issue #4227): 把 > 64 字符的 item id 改写成 + * 保留前缀的稳定短 id 后重发。默认 always-on; 只在明确命中上游错误时触发。 + */ +export function createResponsesItemIdLengthRecoveryRule(opts: { + enabled?: () => boolean; + onRetry?: (threadId: string, model: string) => void; + threadIdHeaders?: readonly string[]; +} = {}): RecoveryRule { + return { + id: 'responses_item_id_length', + enabled: opts.enabled ?? (() => true), + matches: (text) => RESPONSES_ITEM_ID_LENGTH_RE.test(text), + strip: shortenOversizedResponsesItemIdsFromBody, + onRetry: opts.onRetry, + threadIdHeaders: opts.threadIdHeaders, + }; +} + /** * `tool_use.provider_specific_fields` 400 恢复规则:清理历史后透明重试一次。 * 主动 transform 通常会先移除该字段;该规则覆盖 transform 未接入、旧会话