diff --git a/src/services/api/client.ts b/src/services/api/client.ts index ab73d805a9..a46d9bccf6 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -180,8 +180,26 @@ export async function getAnthropicClient({ isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ) { const { createOpenAIShimClient } = await import('./openaiShim.js') + // Strip Anthropic-internal and auth headers before forwarding to 3P providers. + // Prevents session IDs, protection flags, and potentially the Anthropic API key + // from being sent to external endpoints. + const safeHeaders: Record = {} + for (const [k, v] of Object.entries(defaultHeaders)) { + const lower = k.toLowerCase() + if ( + lower.startsWith('x-anthropic') || + lower.startsWith('x-claude') || + lower.startsWith('anthropic-') || + lower === 'x-app' || + lower === 'x-client-app' || + lower === 'authorization' || + lower === 'x-api-key' || + lower === 'api-key' + ) continue + safeHeaders[k] = v + } return createOpenAIShimClient({ - defaultHeaders, + defaultHeaders: safeHeaders, maxRetries, timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10), }) as unknown as Anthropic diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index cc553d21b7..10f86669de 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -1812,6 +1812,163 @@ test('sanitizes malformed MCP tool schemas before sending them to OpenAI', async expect(properties?.priority).not.toHaveProperty('default') }) +test('preserves image content in tool results instead of silently dropping it', async () => { + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'gpt-4o', + choices: [ + { + message: { role: 'assistant', content: 'I see the screenshot' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'gpt-4o', + system: 'test', + messages: [ + { role: 'user', content: 'take a screenshot' }, + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'Bash', + input: { command: 'screenshot' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: [ + { type: 'text', text: 'Screenshot captured' }, + { type: 'image', source: { type: 'url', url: 'https://example.com/screenshot.png' } }, + ], + }, + ], + }, + ], + max_tokens: 64, + stream: false, + }) + + const msgs = requestBody?.messages as Array<{ role: string; content: string }> + const toolMsg = msgs.find(m => m.role === 'tool') + + expect(toolMsg?.content).toContain('Screenshot captured') + expect(toolMsg?.content).toContain('[Image]') +}) + +test('strips Anthropic-specific headers even when passed directly to the shim — defense in depth', async () => { + let capturedHeaders: Record | undefined + + globalThis.fetch = (async (_input, init) => { + capturedHeaders = init?.headers as Record + + return new Response( + JSON.stringify({ + id: 'chatcmpl-2', + model: 'gpt-4o', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = createOpenAIShimClient({ + defaultHeaders: { + 'x-anthropic-secret': 'should-be-stripped', + 'x-claude-session-id': 'also-stripped', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'prompt-caching-2024-07-31', + 'User-Agent': 'openclaude/1.0', + }, + }) as OpenAIShimClient + + await client.beta.messages.create( + { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }], max_tokens: 16, stream: false }, + { + headers: { + 'x-anthropic-per-request': 'also-bad', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'tools-2024-09-04', + 'X-Custom-Ok': 'fine', + }, + }, + ) + + expect(capturedHeaders).toBeDefined() + expect(capturedHeaders!['x-anthropic-secret']).toBeUndefined() + expect(capturedHeaders!['x-claude-session-id']).toBeUndefined() + expect(capturedHeaders!['x-anthropic-per-request']).toBeUndefined() + expect(capturedHeaders!['anthropic-version']).toBeUndefined() + expect(capturedHeaders!['anthropic-beta']).toBeUndefined() + expect(capturedHeaders!['User-Agent']).toBe('openclaude/1.0') + expect(capturedHeaders!['X-Custom-Ok']).toBe('fine') +}) + +test('does not forward cache_control blocks to 3P providers', async () => { + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + + return new Response( + JSON.stringify({ + id: 'chatcmpl-3', + model: 'gpt-4o', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'gpt-4o', + system: [{ type: 'text', text: 'You are helpful.', cache_control: { type: 'ephemeral' } }], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Hello', cache_control: { type: 'ephemeral' } }, + ], + }, + ], + max_tokens: 16, + stream: false, + }) + + const bodyStr = JSON.stringify(requestBody) + expect(bodyStr).not.toContain('cache_control') + expect(bodyStr).not.toContain('ephemeral') + + const msgs = requestBody?.messages as Array<{ role: string; content: unknown }> + const userMsg = msgs.find(m => m.role === 'user') + expect(typeof userMsg?.content === 'string' ? userMsg.content : JSON.stringify(userMsg?.content)).toContain('Hello') +}) + // --------------------------------------------------------------------------- // Issue #202 — consecutive role coalescing (Devstral, Mistral strict templates) // --------------------------------------------------------------------------- diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index c1c3f3fd24..30926a0d39 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -85,6 +85,32 @@ function sleepMs(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } +/** + * Removes Anthropic-specific headers from a headers map before forwarding to + * third-party providers. Acts as a last-resort defense-in-depth guard: the + * primary filtering happens in client.ts when the shim client is created, but + * this ensures that headers injected later (e.g. via options.headers on + * individual requests) are also scrubbed regardless of call path. + */ +function filterAnthropicHeaders(headers: Record): Record { + const safe: Record = {} + for (const [k, v] of Object.entries(headers)) { + const lower = k.toLowerCase() + if ( + lower.startsWith('x-anthropic') || + lower.startsWith('x-claude') || + lower.startsWith('anthropic-') || + lower === 'x-app' || + lower === 'x-client-app' || + lower === 'authorization' || + lower === 'x-api-key' || + lower === 'api-key' + ) continue + safe[k] = v + } + return safe +} + // --------------------------------------------------------------------------- // Types — minimal subset of Anthropic SDK types we need to produce // --------------------------------------------------------------------------- @@ -925,7 +951,10 @@ class OpenAIShimMessages { private providerOverride?: { model: string; baseURL: string; apiKey: string } constructor(defaultHeaders: Record, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh', providerOverride?: { model: string; baseURL: string; apiKey: string }) { - this.defaultHeaders = defaultHeaders + // Filter at construction time so Anthropic-specific headers can never + // reach a 3P endpoint regardless of how the shim client was created, + // including providerOverride-based routing that bypasses client.ts. + this.defaultHeaders = filterAnthropicHeaders(defaultHeaders) this.reasoningEffort = reasoningEffort this.providerOverride = providerOverride } @@ -1007,7 +1036,8 @@ class OpenAIShimMessages { params, defaultHeaders: { ...this.defaultHeaders, - ...(options?.headers ?? {}), + // Filter per-request headers for the same reason as _doOpenAIRequest. + ...filterAnthropicHeaders(options?.headers ?? {}), }, signal: options?.signal, }) @@ -1095,7 +1125,9 @@ class OpenAIShimMessages { const headers: Record = { 'Content-Type': 'application/json', ...this.defaultHeaders, - ...(options?.headers ?? {}), + // Filter per-request headers: last-resort guard against Anthropic-specific + // headers reaching 3P endpoints even when passed via options.headers directly. + ...filterAnthropicHeaders(options?.headers ?? {}), } const isGemini = isGeminiMode() diff --git a/src/utils/conversationRecovery.ts b/src/utils/conversationRecovery.ts index 3d4ad44bc6..abdbdb27a7 100644 --- a/src/utils/conversationRecovery.ts +++ b/src/utils/conversationRecovery.ts @@ -181,6 +181,7 @@ export type DeserializeResult = { /** * Remove thinking/redacted_thinking content blocks from assistant messages. * Messages that become empty after stripping are removed entirely. + * Must run before filterUnresolvedToolUses to avoid orphaned tool_result blocks. */ function stripThinkingBlocks(messages: NormalizedMessage[]): NormalizedMessage[] { return messages.reduce((acc, msg) => { @@ -235,9 +236,18 @@ export function deserializeMessagesWithInterruptDetection( } } + // Strip thinking blocks BEFORE filtering tool uses when resuming against a 3P + // provider. A thinking-only assistant message removed after filterUnresolvedToolUses + // would leave its paired tool_result orphaned, causing a 400 on resume. + const provider = getAPIProvider() + const isThirdPartyProvider = provider === 'openai' || provider === 'gemini' || provider === 'github' || provider === 'codex' + const preProcessed = isThirdPartyProvider + ? stripThinkingBlocks(migratedMessages as NormalizedMessage[]) + : migratedMessages + // Filter out unresolved tool uses and any synthetic messages that follow them const filteredToolUses = filterUnresolvedToolUses( - migratedMessages, + preProcessed, ) as NormalizedMessage[] // Filter out orphaned thinking-only assistant messages that can cause API errors