Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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' }),
]),
}),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/maker-host/codex-proxy-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
createEncryptedContentRecoveryRule,
createImageGenerationIdRecoveryRule,
createResponsesItemIdPrefixRecoveryRule,
createResponsesItemIdLengthRecoveryRule,
createInstructionsInjectionTransform,
createInstructionsRegistry,
createXaiModelInputRecoveryRule,
Expand All @@ -40,6 +41,7 @@ import {
stripEncryptedContentFromBody,
stripImageGenerationItemsWithoutIdFromBody,
stripNonCanonicalResponsesItemIdsFromBody,
shortenOversizedResponsesItemIdsFromBody,
stripNonAnthropicFields,
type ForwardLifecycleFailure,
type ForwardLifecycleObserver,
Expand Down Expand Up @@ -112,6 +114,7 @@ import {
encryptedStripController,
imageGenerationStripController,
responsesItemIdStripController,
responsesItemIdLengthStripController,
xaiModelInputStripController,
} from './thread-strip-controllers.js';
import { createMakerLogger } from './logger-adapter.js';
Expand Down Expand Up @@ -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),
});
Expand All @@ -214,6 +220,7 @@ const CODEX_BODY_RECOVERY_RULES = [
encryptedContentRecoveryRule,
imageGenerationIdRecoveryRule,
responsesItemIdPrefixRecoveryRule,
responsesItemIdLengthRecoveryRule,
xaiModelInputRecoveryRule,
vllmResponsesCompatibilityRule,
] as const;
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
3 changes: 3 additions & 0 deletions packages/anthropic-compat-proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export {
createEncryptedContentRecoveryRule,
createImageGenerationIdRecoveryRule,
createResponsesItemIdPrefixRecoveryRule,
createResponsesItemIdLengthRecoveryRule,
createToolExchangeAdjacencyRecoveryRule,
compactOversizedImageHistory,
createToolUseProviderSpecificFieldsRecoveryRule,
Expand All @@ -61,6 +62,8 @@ export {
stripImageGenerationItemsWithoutIdFromBody,
stripNonAnthropicFields,
stripNonCanonicalResponsesItemIdsFromBody,
shortenOversizedResponsesItemIdsFromBody,
shortenResponsesItemId,
stripToolUseProviderSpecificFields,
stripToolUseProviderSpecificFieldsFromBody,
} from './transform.js';
Expand Down
75 changes: 75 additions & 0 deletions packages/anthropic-compat-proxy/src/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
stripNonAnthropicFields,
stripNonCanonicalResponsesItemIdsFromBody,
createResponsesItemIdPrefixRecoveryRule,
createResponsesItemIdLengthRecoveryRule,
shortenOversizedResponsesItemIdsFromBody,
shortenResponsesItemId,
stripToolUseProviderSpecificFields,
stripToolUseProviderSpecificFieldsFromBody,
} from './transform.js';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 3 additions & 0 deletions packages/anthropic-compat-proxy/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export {
stripToolUseProviderSpecificFieldsFromBody,
stripNonCanonicalResponsesItemIdsFromBody,
createResponsesItemIdPrefixRecoveryRule,
shortenOversizedResponsesItemIdsFromBody,
shortenResponsesItemId,
createResponsesItemIdLengthRecoveryRule,
stripToolUseProviderSpecificFields,
stripEncryptedContentFromBody,
stripImageGenerationItemsWithoutIdFromBody,
Expand Down
Loading
Loading