Skip to content
Closed
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
20 changes: 19 additions & 1 deletion src/services/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}
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
Expand Down
157 changes: 157 additions & 0 deletions src/services/api/openaiShim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, string> | undefined

globalThis.fetch = (async (_input, init) => {
capturedHeaders = init?.headers as Record<string, string>

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<string, unknown> | 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)
// ---------------------------------------------------------------------------
Expand Down
38 changes: 35 additions & 3 deletions src/services/api/openaiShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,32 @@ function sleepMs(ms: number): Promise<void> {
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<string, string>): Record<string, string> {
const safe: Record<string, string> = {}
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -925,7 +951,10 @@ class OpenAIShimMessages {
private providerOverride?: { model: string; baseURL: string; apiKey: string }

constructor(defaultHeaders: Record<string, string>, 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
}
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -1095,7 +1125,9 @@ class OpenAIShimMessages {
const headers: Record<string, string> = {
'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()
Expand Down
12 changes: 11 additions & 1 deletion src/utils/conversationRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NormalizedMessage[]>((acc, msg) => {
Expand Down Expand Up @@ -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
Expand Down
Loading