diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index dbdb5501e3b..78f333d5763 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -148,71 +148,260 @@ describe('AnthropicContentGenerator', () => { const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || {}) as Record; + // Beta headers moved out of defaultHeaders — see PR #3788 review feedback. + // Only User-Agent and customHeaders remain at construction time. expect(headers['User-Agent']).toContain('QwenCode/1.2.3'); - expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); expect(headers['X-Custom']).toBe('1'); + expect(headers['anthropic-beta']).toBeUndefined(); }); - it('adds the effort beta header when reasoning.effort is set', async () => { - const { AnthropicContentGenerator } = await importGenerator(); - void new AnthropicContentGenerator( - { + // Per-request header behavior moved into the generateContent describe + // block below — see "anthropic-beta header" cases. + + // Per-request anthropic-beta is computed from the actual fields present + // in the request body (rather than the constructor-time reasoning config), + // so the wire shape stays consistent when a per-request opt-out drops + // `thinking` / `output_config`. See PR #3788 review feedback. + describe('per-request anthropic-beta header', () => { + const baseConfig: ContentGeneratorConfig = { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 100 }, + schemaCompliance: 'auto', + }; + + async function callOnce( + config: ContentGeneratorConfig, + requestConfig?: object, + ) { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', model: 'claude-test', - apiKey: 'test-key', - baseUrl: 'https://example.invalid', - timeout: 10_000, - maxRetries: 2, - samplingParams: {}, - schemaCompliance: 'auto', + content: [{ type: 'text', text: 'ok' }], + }); + const generator = new AnthropicContentGenerator(config, mockConfig); + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hi', + ...(requestConfig ? { config: requestConfig } : {}), + } as unknown as GenerateContentParameters); + const [, options] = anthropicState.lastCreateArgs as AnthropicCreateArgs; + return ((options as { headers?: Record })?.headers || + {}) as Record; + } + + it('sends interleaved-thinking + effort beta when both are present in the body', async () => { + const headers = await callOnce({ + ...baseConfig, reasoning: { effort: 'medium' }, - }, - mockConfig, - ); + }); + expect(headers['anthropic-beta']).toContain( + 'interleaved-thinking-2025-05-14', + ); + expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); + }); - const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || - {}) as Record; - expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); - }); + it('sends only interleaved-thinking when effort is not set', async () => { + const headers = await callOnce({ + ...baseConfig, + // No reasoning config: thinking defaults to enabled, no effort. + }); + expect(headers['anthropic-beta']).toBe('interleaved-thinking-2025-05-14'); + }); - it('does not add the effort beta header when reasoning.effort is not set', async () => { - const { AnthropicContentGenerator } = await importGenerator(); - void new AnthropicContentGenerator( - { - model: 'claude-test', - apiKey: 'test-key', - baseUrl: 'https://example.invalid', - timeout: 10_000, - maxRetries: 2, - samplingParams: {}, - schemaCompliance: 'auto', - }, - mockConfig, - ); + it('omits beta header when reasoning is disabled (no thinking, no effort)', async () => { + const headers = await callOnce({ ...baseConfig, reasoning: false }); + expect(headers['anthropic-beta']).toBeUndefined(); + }); - const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || - {}) as Record; - expect(headers['anthropic-beta']).not.toContain('effort-2025-11-24'); - }); + it('merges user-supplied customHeaders[anthropic-beta] with computed flags (no overwrite)', async () => { + // Users configure additional Anthropic beta flags via customHeaders. + // The per-request override must add to that list, not replace it. + const headers = await callOnce({ + ...baseConfig, + reasoning: { effort: 'medium' }, + customHeaders: { 'anthropic-beta': 'experimental-x,experimental-y' }, + }); + const beta = headers['anthropic-beta'] ?? ''; + expect(beta.split(',')).toEqual( + expect.arrayContaining([ + 'experimental-x', + 'experimental-y', + 'interleaved-thinking-2025-05-14', + 'effort-2025-11-24', + ]), + ); + }); - it('omits the anthropic beta header when reasoning is disabled', async () => { - const { AnthropicContentGenerator } = await importGenerator(); - void new AnthropicContentGenerator( - { - model: 'claude-test', - apiKey: 'test-key', - baseUrl: 'https://example.invalid', - timeout: 10_000, - maxRetries: 2, - samplingParams: {}, - schemaCompliance: 'auto', + it('passes user-supplied customHeaders[anthropic-beta] through even when no thinking/effort is enabled', async () => { + const headers = await callOnce({ + ...baseConfig, reasoning: false, - }, - mockConfig, - ); + customHeaders: { 'anthropic-beta': 'experimental-x' }, + }); + expect(headers['anthropic-beta']).toBe('experimental-x'); + }); - const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || - {}) as Record; - expect(headers['anthropic-beta']).toBeUndefined(); + it('does not leak customHeaders[anthropic-beta] (any casing) into defaultHeaders', async () => { + // The per-request path owns anthropic-beta. If we also copied a + // mixed-case `Anthropic-Beta` from customHeaders into defaultHeaders, + // the wire request would carry two physical headers for the same + // logical name — one mixed-case (verbatim from defaultHeaders) and one + // lowercase (from the per-request override). SDK behavior on duplicate + // headers with different casings is undefined. + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + ...baseConfig, + customHeaders: { + 'Anthropic-Beta': 'user-flag', + 'X-Other': 'kept', + }, + }, + mockConfig, + ); + const defaultHeaders = (anthropicState.constructorOptions?.[ + 'defaultHeaders' + ] || {}) as Record; + expect(defaultHeaders['Anthropic-Beta']).toBeUndefined(); + expect(defaultHeaders['anthropic-beta']).toBeUndefined(); + expect(defaultHeaders['ANTHROPIC-BETA']).toBeUndefined(); + // Unrelated customHeaders are still passed through. + expect(defaultHeaders['X-Other']).toBe('kept'); + }); + + it('honors customHeaders[anthropic-beta] under mixed-case keys (Anthropic-Beta / ANTHROPIC-BETA)', async () => { + // HTTP header names are case-insensitive; Anthropic SDK lower-cases + // headers when merging. Make sure our merge logic also matches + // case-insensitively so the user-configured beta flag isn't silently + // overwritten by the per-request value. + const headersUpper = await callOnce({ + ...baseConfig, + reasoning: { effort: 'medium' }, + customHeaders: { 'ANTHROPIC-BETA': 'experimental-x' }, + }); + expect(headersUpper['anthropic-beta']).toContain('experimental-x'); + expect(headersUpper['anthropic-beta']).toContain( + 'interleaved-thinking-2025-05-14', + ); + + const headersTitle = await callOnce({ + ...baseConfig, + reasoning: { effort: 'medium' }, + customHeaders: { 'Anthropic-Beta': 'experimental-y' }, + }); + expect(headersTitle['anthropic-beta']).toContain('experimental-y'); + expect(headersTitle['anthropic-beta']).toContain( + 'interleaved-thinking-2025-05-14', + ); + }); + + it('dedupes beta flags so duplicates from customHeaders are not repeated', async () => { + const headers = await callOnce({ + ...baseConfig, + reasoning: { effort: 'medium' }, + customHeaders: { + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + }, + }); + const beta = headers['anthropic-beta'] ?? ''; + const occurrences = beta + .split(',') + .filter((f) => f.trim() === 'interleaved-thinking-2025-05-14'); + expect(occurrences).toHaveLength(1); + }); + + it('omits beta header when per-request thinkingConfig.includeThoughts=false', async () => { + // Even though the global reasoning config sets effort, the per-request + // opt-out drops both `thinking` and `output_config` from the body — and + // the beta header must follow. + const headers = await callOnce( + { ...baseConfig, reasoning: { effort: 'medium' } }, + { thinkingConfig: { includeThoughts: false } }, + ); + expect(headers['anthropic-beta']).toBeUndefined(); + }); + + it('keeps customHeaders + User-Agent in defaultHeaders while sending computed anthropic-beta per-request', async () => { + // The per-request override must NOT replace existing defaultHeaders + // (User-Agent and unrelated customHeaders entries) — it should only + // contribute the computed `anthropic-beta` flags. Defends against a + // future regression where headers might be set via a path that wipes + // out the constructor-time defaults. + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'claude-test', + content: [{ type: 'text', text: 'ok' }], + }); + const generator = new AnthropicContentGenerator( + { + ...baseConfig, + reasoning: { effort: 'medium' }, + customHeaders: { 'X-Custom': 'v1' }, + }, + mockConfig, + ); + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hi', + } as unknown as GenerateContentParameters); + + // defaultHeaders carries User-Agent and customHeaders (not beta). + const defaultHeaders = (anthropicState.constructorOptions?.[ + 'defaultHeaders' + ] || {}) as Record; + expect(defaultHeaders['User-Agent']).toContain('QwenCode/1.2.3'); + expect(defaultHeaders['X-Custom']).toBe('v1'); + expect(defaultHeaders['anthropic-beta']).toBeUndefined(); + + // Per-request headers carry only the computed beta flags. + const [, options] = anthropicState.lastCreateArgs as AnthropicCreateArgs; + const reqHeaders = ((options as { headers?: Record }) + ?.headers || {}) as Record; + expect(reqHeaders['User-Agent']).toBeUndefined(); + expect(reqHeaders['X-Custom']).toBeUndefined(); + expect(reqHeaders['anthropic-beta']).toContain( + 'interleaved-thinking-2025-05-14', + ); + }); + + it('also sends the computed beta header on streaming requests', async () => { + // generateContentStream() goes through a separate code path from + // generateContent(); make sure the per-request header attaches there + // too so streaming Anthropic/DeepSeek requests stay consistent. + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue( + (async function* () { + yield { type: 'message_stop' }; + })(), + ); + + const generator = new AnthropicContentGenerator( + { ...baseConfig, reasoning: { effort: 'medium' } }, + mockConfig, + ); + const stream = await generator.generateContentStream({ + model: 'models/ignored', + contents: 'Hi', + } as unknown as GenerateContentParameters); + // Drain the stream so create() has been called. + for await (const _chunk of stream) { + void _chunk; + } + + const [, options] = anthropicState.lastCreateArgs as AnthropicCreateArgs; + const headers = ((options as { headers?: Record }) + ?.headers || {}) as Record; + expect(headers['anthropic-beta']).toContain( + 'interleaved-thinking-2025-05-14', + ); + expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); + }); }); describe('generateContent', () => { @@ -494,6 +683,420 @@ describe('AnthropicContentGenerator', () => { }); }); + // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's + // anthropic-compatible API rejects requests in thinking mode when a prior + // assistant turn carrying `tool_use` omits a thinking block. Plain-text + // assistant turns without thinking are accepted unchanged. + describe('DeepSeek anthropic-compatible provider', () => { + // Helper: tool-use assistant turn missing thinking — the only shape that + // actually triggers DeepSeek's HTTP 400. + const toolUseConversation = [ + { role: 'user' as const, parts: [{ text: 'Run tool' }] }, + { + role: 'model' as const, + parts: [{ functionCall: { id: 't1', name: 'tool', args: {} } }], + }, + { + role: 'user' as const, + parts: [ + { + functionResponse: { + id: 't1', + name: 'tool', + response: { output: 'ok' }, + }, + }, + ], + }, + ]; + + it('injects empty thinking blocks on tool-use assistant turns when baseUrl is api.deepseek.com', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'deepseek-v4-pro', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'deepseek-v4-pro', + apiKey: 'test-key', + baseUrl: 'https://api.deepseek.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + it('detects deepseek by model name even when baseUrl is different', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'deepseek-v4-pro', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'deepseek-v4-pro', + apiKey: 'test-key', + baseUrl: 'https://my-proxy.example.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + it('matches regional DeepSeek subdomains (e.g. us.api.deepseek.com)', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'unrelated-model', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'unrelated-model', + apiKey: 'test-key', + baseUrl: 'https://us.api.deepseek.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + const toolOnlyAssistant = { + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'tool', input: {} }], + }; + + it('does not inject empty thinking blocks for non-deepseek providers', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'claude-test', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://api.anthropic.com', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + // Non-deepseek provider: even tool_use turns get no injection. + expect(messages[1]).toEqual(toolOnlyAssistant); + }); + + it('does not match spoofed hostnames like api.deepseek.com.evil.com', async () => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'claude-test', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://api.deepseek.com.evil.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + // Hostname differs from api.deepseek.com — must not inject even on + // tool_use turns. + expect(messages[1]).toEqual(toolOnlyAssistant); + }); + + it('does not inject when reasoning is explicitly disabled', async () => { + // Even on a confirmed-DeepSeek provider with a tool-use turn, if the + // request omits the top-level `thinking` parameter (because + // reasoning=false), shipping synthetic thinking blocks would be a + // protocol violation. + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'deepseek-v4-pro', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'deepseek-v4-pro', + apiKey: 'test-key', + baseUrl: 'https://api.deepseek.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + reasoning: false, + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ thinking: expect.anything() }), + ); + expect(messages[1]).toEqual(toolOnlyAssistant); + }); + + it('strips real thought parts from assistant history when reasoning is disabled', async () => { + // suggestionGenerator / forkedAgent path: the top-level `thinking` + // parameter is dropped, but the session history may still carry + // `thought: true` parts that the converter would otherwise replay as + // thinking blocks — same protocol mismatch the gate is meant to avoid. + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'deepseek-v4-pro', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'deepseek-v4-pro', + apiKey: 'test-key', + baseUrl: 'https://api.deepseek.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + reasoning: false, + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { text: 'real reasoning', thought: true, thoughtSignature: 's1' }, + { text: 'Hello!' }, + ], + }, + { role: 'user', parts: [{ text: 'Bye' }] }, + ], + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ thinking: expect.anything() }), + ); + // Existing thinking block dropped — no protocol mismatch. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); + + it('reflects runtime model changes (no stale provider cache)', async () => { + // Config.setModel() mutates contentGeneratorConfig.model in place. A + // generator constructed against a non-DeepSeek model must start + // injecting thinking blocks once the model is switched to DeepSeek + // without re-creating the generator. + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'claude-test', + content: [{ type: 'text', text: 'ok' }], + }); + + const config: ContentGeneratorConfig = { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://example.invalid', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + }; + + const generator = new AnthropicContentGenerator(config, mockConfig); + + // Initial model isn't DeepSeek — no injection. + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + let [req] = anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect( + (req as { messages: unknown[] }).messages[1] as { content: unknown }, + ).toEqual(toolOnlyAssistant); + + // Hot-update the model in place, mimicking Config.setModel(). + config.model = 'deepseek-chat'; + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + } as unknown as GenerateContentParameters); + [req] = anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect( + (req as { messages: unknown[] }).messages[1] as { content: unknown }, + ).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + it('does not inject when request sets thinkingConfig.includeThoughts=false', async () => { + // Same concern as above but for the per-request override used by + // suggestionGenerator / forkedAgent / ArenaManager. Both the top-level + // `thinking` field AND the reasoning-shaped `output_config` must be + // suppressed — leaving either behind reintroduces the protocol + // mismatch this gate is designed to avoid. + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'msg-1', + model: 'deepseek-v4-pro', + content: [{ type: 'text', text: 'ok' }], + }); + + const generator = new AnthropicContentGenerator( + { + model: 'deepseek-v4-pro', + apiKey: 'test-key', + baseUrl: 'https://api.deepseek.com/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + reasoning: { effort: 'medium' }, + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: toolUseConversation, + config: { thinkingConfig: { includeThoughts: false } }, + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ thinking: expect.anything() }), + ); + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ output_config: expect.anything() }), + ); + expect(messages[1]).toEqual(toolOnlyAssistant); + }); + }); + describe('countTokens', () => { it('counts tokens using the request tokenizer', async () => { const { AnthropicContentGenerator } = await importGenerator(); diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 5fa4c32e13a..d7d4b79bf0b 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -39,6 +39,35 @@ import { const debugLogger = createDebugLogger('ANTHROPIC'); +/** + * DeepSeek's anthropic-compatible API rejects requests in thinking mode when + * a prior assistant turn carrying `tool_use` omits a thinking block. + * Plain-text assistant turns without thinking are accepted unchanged. Detect + * the provider by base URL hostname or model name so the converter can inject + * empty thinking blocks on the affected turns. + * https://github.com/QwenLM/qwen-code/issues/3786 + */ +function isDeepSeekAnthropicProvider( + contentGeneratorConfig: ContentGeneratorConfig, +): boolean { + const baseUrl = contentGeneratorConfig.baseUrl ?? ''; + if (baseUrl) { + try { + const hostname = new URL(baseUrl).hostname.toLowerCase(); + if ( + hostname === 'api.deepseek.com' || + hostname.endsWith('.api.deepseek.com') + ) { + return true; + } + } catch { + // Invalid URL — fall through to model-name detection. + } + } + const model = (contentGeneratorConfig.model ?? '').toLowerCase(); + return model.includes('deepseek'); +} + type StreamingBlockState = { type: string; id?: string; @@ -91,8 +120,10 @@ export class AnthropicContentGenerator implements ContentGenerator { request: GenerateContentParameters, ): Promise { const anthropicRequest = await this.buildRequest(request); + const headers = this.buildPerRequestHeaders(anthropicRequest); const response = (await this.client.messages.create(anthropicRequest, { signal: request.config?.abortSignal, + ...(headers ? { headers } : {}), })) as Message; return this.converter.convertAnthropicResponseToGemini(response); @@ -102,6 +133,7 @@ export class AnthropicContentGenerator implements ContentGenerator { request: GenerateContentParameters, ): Promise> { const anthropicRequest = await this.buildRequest(request); + const headers = this.buildPerRequestHeaders(anthropicRequest); const streamingRequest: MessageCreateParamsStreaming & { thinking?: { type: 'enabled'; budget_tokens: number }; } = { @@ -113,6 +145,7 @@ export class AnthropicContentGenerator implements ContentGenerator { streamingRequest as MessageCreateParamsStreaming, { signal: request.config?.abortSignal, + ...(headers ? { headers } : {}), }, )) as AsyncIterable; @@ -155,48 +188,124 @@ export class AnthropicContentGenerator implements ContentGenerator { } private buildHeaders(): Record { + // Beta headers are computed per-request in buildPerRequestHeaders so they + // stay in sync with what the request body actually carries — see #3788 + // review feedback. Constructor headers carry only User-Agent and any + // user-supplied custom headers EXCEPT anthropic-beta (any casing): the + // per-request path owns that header, and copying it into defaultHeaders + // would cause two physical headers on the wire (one mixed-case, one + // lowercase) when the per-request override fires. const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { customHeaders } = this.contentGeneratorConfig; + const headers: Record = { 'User-Agent': userAgent }; + if (customHeaders) { + for (const [key, value] of Object.entries(customHeaders)) { + if (key.toLowerCase() === 'anthropic-beta') continue; + headers[key] = value; + } + } + return headers; + } + + /** + * Compute `anthropic-beta` from the actual fields present in the request + * body. Keeps the header consistent with the body even when a per-request + * `thinkingConfig.includeThoughts: false` opt-out drops `thinking` / + * `output_config` after the constructor has already run. + * + * User-supplied `customHeaders['anthropic-beta']` flags are merged in (and + * deduped) so the per-request override doesn't wipe out the existing + * customHeaders escape hatch for unrelated beta features. The lookup is + * case-insensitive — HTTP header names are case-insensitive by spec, so a + * user-configured `Anthropic-Beta` or `ANTHROPIC-BETA` is honored too. + */ + private buildPerRequestHeaders( + anthropicRequest: MessageCreateParamsWithThinking, + ): Record | undefined { const betas: string[] = []; - const reasoning = this.contentGeneratorConfig.reasoning; - // Interleaved thinking is used when we send the `thinking` field. - if (reasoning !== false) { - betas.push('interleaved-thinking-2025-05-14'); + for (const flag of this.collectCustomBetaFlags()) { + betas.push(flag); } - // Effort (beta) is enabled when reasoning.effort is set. - if (reasoning !== false && reasoning?.effort !== undefined) { + if (anthropicRequest.thinking) { + betas.push('interleaved-thinking-2025-05-14'); + } + if (anthropicRequest.output_config) { betas.push('effort-2025-11-24'); } - const headers: Record = { - 'User-Agent': userAgent, - }; + if (betas.length === 0) return undefined; + const unique = Array.from(new Set(betas)); + return { 'anthropic-beta': unique.join(',') }; + } - if (betas.length) { - headers['anthropic-beta'] = betas.join(','); + /** + * Read every customHeaders entry whose key (case-insensitively) is + * `anthropic-beta` and yield the comma-separated flags from each. Multiple + * matching entries are concatenated; later ones may produce duplicates + * which the caller dedupes. + */ + private collectCustomBetaFlags(): string[] { + const customHeaders = this.contentGeneratorConfig.customHeaders; + if (!customHeaders) return []; + + const flags: string[] = []; + for (const [key, value] of Object.entries(customHeaders)) { + if (key.toLowerCase() !== 'anthropic-beta') continue; + if (typeof value !== 'string' || !value) continue; + for (const flag of value.split(',')) { + const trimmed = flag.trim(); + if (trimmed) flags.push(trimmed); + } } - - return customHeaders ? { ...headers, ...customHeaders } : headers; + return flags; } private async buildRequest( request: GenerateContentParameters, ): Promise { - const { system, messages } = - this.converter.convertGeminiRequestToAnthropic(request); + const sampling = this.buildSamplingParameters(request); + const thinking = this.buildThinkingConfig(request); + const outputConfig = this.buildOutputConfig(request); + + // Compute per-request: `Config.setModel()` mutates contentGeneratorConfig + // in place, so a constructor-time cache could go stale on a runtime + // model switch. The detector is cheap (URL parse + string compare). + const isDeepSeek = isDeepSeekAnthropicProvider(this.contentGeneratorConfig); + + // On DeepSeek the converter must keep history aligned with the top-level + // `thinking` parameter to avoid HTTP 400: + // - thinking on → inject empty thinking on tool_use turns missing one + // (issue #3786 trigger) + // - thinking off → strip pre-existing thinking blocks from assistant + // history so a request without `thinking` config + // doesn't ship stray thinking blocks. Matters for + // code paths that pass `includeThoughts: false` + // against a session whose history already contains + // `thought: true` parts (suggestionGenerator / + // ArenaManager / forkedAgent). + const deepseekThinkingOn = isDeepSeek && !!thinking; + const stripAssistantThinking = isDeepSeek && !thinking; + + const { system, messages } = this.converter.convertGeminiRequestToAnthropic( + request, + { + // Both run together: normalization fills missing signatures so the + // injection pass treats those blocks as already-present, and the + // injection adds a synthetic block on tool_use turns lacking one. + normalizeAssistantThinkingSignature: deepseekThinkingOn, + injectThinkingOnToolUseTurns: deepseekThinkingOn, + stripAssistantThinking, + }, + ); const tools = request.config?.tools ? await this.converter.convertGeminiToolsToAnthropic(request.config.tools) : undefined; - const sampling = this.buildSamplingParameters(request); - const thinking = this.buildThinkingConfig(request); - const outputConfig = this.buildOutputConfig(); - return { model: this.contentGeneratorConfig.model, system, @@ -291,9 +400,16 @@ export class AnthropicContentGenerator implements ContentGenerator { }; } - private buildOutputConfig(): - | { effort: 'low' | 'medium' | 'high' } - | undefined { + private buildOutputConfig( + request: GenerateContentParameters, + ): { effort: 'low' | 'medium' | 'high' } | undefined { + // Honor per-request opt-out so side queries (suggestionGenerator, + // ArenaManager, forkedAgent) don't leak a reasoning-shaped output_config + // alongside an absent top-level `thinking` parameter. + if (request.config?.thinkingConfig?.includeThoughts === false) { + return undefined; + } + const reasoning = this.contentGeneratorConfig.reasoning; if (reasoning === false || reasoning === undefined) { return undefined; diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 7f3eb305377..c7d4158d9ee 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -647,6 +647,442 @@ describe('AnthropicContentConverter', () => { }); }); + // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's + // anthropic-compatible API rejects requests in thinking mode when a prior + // assistant turn carrying `tool_use` omits a thinking block. Plain-text + // assistant turns without thinking are accepted unchanged, so the converter + // injects an empty thinking block only on tool-use turns when the caller + // opts in. + describe('DeepSeek thinking-mode normalization, injection, and stripping', () => { + // The two options paired together replicate the DeepSeek "thinking on" + // behavior wired in AnthropicContentGenerator.buildRequest. + const enableThinking = { + normalizeAssistantThinkingSignature: true, + injectThinkingOnToolUseTurns: true, + }; + + it('does not inject on plain-text assistant turns (DeepSeek tolerates them)', () => { + // Verified against api.deepseek.com/anthropic: plain-text assistant + // turns without thinking are accepted. Avoid bloating replay history + // with synthetic blocks the API does not require. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); + + it('injects an empty thinking block on tool-calling assistant turns missing one', () => { + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'List files' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'glob', + args: { pattern: '**/*.md' }, + }, + }, + ], + }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { + type: 'tool_use', + id: 'call-1', + name: 'glob', + input: { pattern: '**/*.md' }, + }, + ], + }); + }); + + it('preserves existing thinking blocks on tool-use assistant turns', () => { + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Run tool' }] }, + { + role: 'model', + parts: [ + { + text: 'Let me think', + thought: true, + thoughtSignature: 'sig', + }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, + ], + }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me think', signature: 'sig' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + it('does not modify user messages', () => { + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [{ role: 'user', parts: [{ text: 'Hi' }] }], + }, + enableThinking, + ); + + expect(messages).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } }, + ], + }, + ]); + }); + + it('does nothing when option is disabled (default)', () => { + const { messages } = converter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + ], + }); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); + + it('injects thinking blocks on every tool-using assistant turn in a multi-turn history', () => { + const toolUse = (id: string) => ({ + functionCall: { id, name: 'tool', args: {} }, + }); + const toolResult = (id: string) => ({ + functionResponse: { id, name: 'tool', response: { output: 'ok' } }, + }); + + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Q1' }] }, + { role: 'model', parts: [toolUse('t1')] }, + { role: 'user', parts: [toolResult('t1')] }, + { role: 'model', parts: [toolUse('t2')] }, + { role: 'user', parts: [toolResult('t2')] }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toMatchObject({ role: 'assistant' }); + expect(messages[3]).toMatchObject({ role: 'assistant' }); + expect((messages[1] as { content: unknown[] }).content[0]).toEqual({ + type: 'thinking', + thinking: '', + signature: '', + }); + expect((messages[3] as { content: unknown[] }).content[0]).toEqual({ + type: 'thinking', + thinking: '', + signature: '', + }); + }); + + it('preserves thinking-only assistant turns rather than emit empty content (Anthropic rejects content: [])', () => { + // A turn whose only blocks are thinking/redacted_thinking can occur + // when a previous round was cut off by max_tokens before any text or + // tool_use was emitted. Stripping unconditionally would leave + // `content: []`, which Anthropic API rejects, and dropping the message + // would break user/assistant alternation. Keep the original blocks + // instead — DeepSeek empirically tolerates the residual mismatch. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { + text: 'pondering', + thought: true, + thoughtSignature: 'sig', + }, + ], + }, + { role: 'user', parts: [{ text: 'Continue' }] }, + ], + }, + { stripAssistantThinking: true }, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'pondering', signature: 'sig' }, + ], + }); + }); + + it('strips thinking blocks from assistant turns when stripAssistantThinking is set', () => { + // suggestionGenerator / forkedAgent path: history has real thought + // parts but the side-query disables thinking. The converter must drop + // those blocks so the outgoing request matches the absent top-level + // `thinking` config. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { + text: 'reasoning', + thought: true, + thoughtSignature: 'sig', + }, + { text: 'Hello!' }, + ], + }, + { + role: 'model', + parts: [ + { text: 'more reasoning', thought: true }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, + ], + }, + ], + }, + { stripAssistantThinking: true }, + ); + + // Both assistant turns have their thinking blocks removed. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + expect(messages[2]).toEqual({ + role: 'assistant', + content: [{ type: 'tool_use', id: 't1', name: 'tool', input: {} }], + }); + }); + + it('strips redacted_thinking blocks too when stripAssistantThinking is set', () => { + // The strip path must cover both `thinking` and `redacted_thinking`. + // processContent doesn't synthesize redacted_thinking from Gemini parts, + // so reach into the private helper directly with a constructed message. + const messages = [ + { + role: 'assistant' as const, + content: [ + { type: 'redacted_thinking', data: 'opaque' }, + { type: 'text', text: 'Hello!' }, + { type: 'thinking', thinking: 'reasoning', signature: 'sig' }, + ], + }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (converter as any).stripThinkingFromAssistantMessages(messages); + + expect(messages[0].content).toEqual([{ type: 'text', text: 'Hello!' }]); + }); + + it('treats a redacted_thinking block as already-satisfying (no synthetic injection)', () => { + // redacted_thinking has no `signature` field by spec — its `data` is + // the opaque token. Distinct from a non-compliant `thinking` block + // missing its required signature. The injector must leave redacted + // turns alone. processContent doesn't synthesize redacted_thinking + // from Gemini parts, so reach into the private helper directly. + const messages = [ + { + role: 'assistant' as const, + content: [ + { type: 'redacted_thinking', data: 'opaque' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (converter as any).injectEmptyThinkingOnToolUseTurns(messages); + + expect(messages[0].content).toEqual([ + { type: 'redacted_thinking', data: 'opaque' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ]); + }); + + it('normalizes a non-compliant thinking block (no signature field) on a tool-use turn', () => { + // A part `{ text: '', thought: true }` (e.g. round-tripped from a + // `redacted_thinking` response that lost its `data` field via the + // Gemini Part representation) converts to a thinking block without a + // `signature` field. The cleanup adds an empty signature in place; + // because the normalized block now satisfies the requirement, Step 2 + // does not prepend a synthetic. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Run tool' }] }, + { + role: 'model', + parts: [ + { text: '', thought: true }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, + ], + }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + it('preserves an existing compliant thinking block on a tool-use turn', () => { + // A thinking block with a real `signature` field is fully compliant — + // the injector must not duplicate it. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Run tool' }] }, + { + role: 'model', + parts: [ + { + text: 'real thinking', + thought: true, + thoughtSignature: 'real-sig', + }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, + ], + }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'real thinking', + signature: 'real-sig', + }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ], + }); + }); + + it('normalizes non-compliant thinking blocks (adds empty signature) on plain-text turns', () => { + // A part `{ thought: true, text: '...' }` (the normal shape from + // OpenAI/Gemini/agent-runtime where users may switch providers + // mid-session, or a `redacted_thinking` round-tripped through Gemini- + // Part) converts to `{ type: 'thinking', thinking: '...' }` without + // signature. The cleanup adds an empty signature in place to make the + // block spec-compliant while preserving the original thinking text. + // No synthetic is prepended on a plain-text turn (no tool_use). + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { text: 'cross-provider thoughts', thought: true }, + { text: 'Hello!' }, + ], + }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'cross-provider thoughts', + signature: '', + }, + { type: 'text', text: 'Hello!' }, + ], + }); + }); + + it('injects on mixed text+tool_use assistant turns missing thinking', () => { + // Common shape: model says something, then calls a tool. With no + // thinking, this is still a tool-use turn that needs the synthetic. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Look this up' }] }, + { + role: 'model', + parts: [ + { text: 'Let me check that' }, + { functionCall: { id: 't1', name: 'lookup', args: {} } }, + ], + }, + ], + }, + enableThinking, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'text', text: 'Let me check that' }, + { type: 'tool_use', id: 't1', name: 'lookup', input: {} }, + ], + }); + }); + }); + describe('convertGeminiToolsToAnthropic', () => { it('converts Tool.functionDeclarations to Anthropic tools and runs schema conversion', async () => { const tools = [ diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index ec1e24742b2..81f6e908d4c 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -31,6 +31,44 @@ type AnthropicToolParam = Anthropic.Tool & { }; type AnthropicContentBlockParam = Anthropic.ContentBlockParam; +export interface ConvertGeminiRequestToAnthropicOptions { + /** + * On every assistant turn, fill in `signature: ''` on any `thinking` block + * that lacks the required `signature` field. Preserves the original + * `thinking` text. Common case: cross-provider history where non-Anthropic + * generators (OpenAI / Gemini / agent-runtime) only set `thought: true`, + * or `redacted_thinking` blocks that lost their `data` field through the + * Gemini-Part round trip. + */ + normalizeAssistantThinkingSignature?: boolean; + /** + * On assistant turns containing `tool_use` but lacking any thinking block, + * prepend a synthetic empty thinking block. Required by DeepSeek's + * anthropic-compatible API when thinking mode is enabled — without this, + * follow-up requests fail with HTTP 400 ("The content[].thinking in the + * thinking mode must be passed back to the API."). + * + * Pair with `normalizeAssistantThinkingSignature` so that any + * signature-less `thinking` block already present is normalized (filled + * with `signature: ''`) before this pass runs. After normalization the + * block has a valid `signature` and is treated as already-satisfying, so + * no synthetic block is prepended and the original thinking text is + * preserved on the wire. + * + * Must be gated on the same per-request condition that emits the + * top-level `thinking` config so disabled-thinking requests don't ship + * stray thinking blocks. https://github.com/QwenLM/qwen-code/issues/3786 + */ + injectThinkingOnToolUseTurns?: boolean; + /** + * Strip thinking and redacted_thinking blocks from assistant messages. + * Used to keep DeepSeek requests consistent when thinking mode is off but + * session history still carries `thought: true` parts (e.g. side-queries + * spawned with `thinkingConfig.includeThoughts: false`). + */ + stripAssistantThinking?: boolean; +} + export class AnthropicContentConverter { private model: string; private schemaCompliance: SchemaComplianceMode; @@ -46,7 +84,10 @@ export class AnthropicContentConverter { this.enableCacheControl = enableCacheControl; } - convertGeminiRequestToAnthropic(request: GenerateContentParameters): { + convertGeminiRequestToAnthropic( + request: GenerateContentParameters, + options: ConvertGeminiRequestToAnthropicOptions = {}, + ): { system?: Anthropic.TextBlockParam[] | string; messages: AnthropicMessageParam[]; } { @@ -58,6 +99,18 @@ export class AnthropicContentConverter { this.processContents(request.contents, messages); + if (options.stripAssistantThinking) { + this.stripThinkingFromAssistantMessages(messages); + } + // Normalization runs before injection so non-compliant blocks are seen + // as already-present (and not duplicated) by the injection pass. + if (options.normalizeAssistantThinkingSignature) { + this.fillMissingThinkingSignatures(messages); + } + if (options.injectThinkingOnToolUseTurns) { + this.injectEmptyThinkingOnToolUseTurns(messages); + } + // Add cache_control to enable prompt caching (if enabled) const system = this.enableCacheControl ? this.buildSystemWithCacheControl(systemText) @@ -544,6 +597,129 @@ export class AnthropicContentConverter { ]; } + /** + * Remove thinking and redacted_thinking blocks from assistant messages. + * Used by DeepSeek when thinking mode is off but session history still + * has `thought: true` parts — keeps the request body in sync with the + * absent top-level `thinking` config. + * + * If stripping would leave an assistant message with no content blocks + * (a thinking-only turn, e.g. one cut off by max_tokens before any text + * or tool_use was emitted), we keep the original blocks. An empty + * `content: []` is rejected by the Anthropic API, and dropping the + * message would break the required user/assistant alternation. DeepSeek + * empirically tolerates the residual `thinking-block + no-thinking-config` + * shape (verified against api.deepseek.com/anthropic), so leaving it as + * an unaltered passthrough is the safer fallback. + */ + private stripThinkingFromAssistantMessages( + messages: AnthropicMessageParam[], + ): void { + for (const message of messages) { + if (message.role !== 'assistant') continue; + if (!Array.isArray(message.content)) continue; + + const filtered = message.content.filter((block) => { + const t = (block as { type?: string }).type; + return t !== 'thinking' && t !== 'redacted_thinking'; + }); + if (filtered.length === 0) continue; + if (filtered.length !== message.content.length) { + message.content = filtered; + } + } + } + + /** + * Fill in `signature: ''` on every assistant `thinking` block that lacks + * a `signature` field. Preserves the original thinking text. Common cases: + * + * - Cross-provider history where the upstream generator (OpenAI / Gemini / + * agent-runtime) only set `thought: true` without a signature. + * - `redacted_thinking` blocks whose `data` field didn't survive the + * round-trip through Gemini Part format. + * + * DeepSeek empirically accepts empty signatures, so this keeps the wire + * shape spec-compliant without discarding any preserved thinking text. + */ + private fillMissingThinkingSignatures( + messages: AnthropicMessageParam[], + ): void { + for (const message of messages) { + if (message.role !== 'assistant') continue; + if (!Array.isArray(message.content)) continue; + + let modified = false; + const normalized = message.content.map((block) => { + const b = block as { type?: string; signature?: unknown }; + if (b.type === 'thinking' && typeof b.signature !== 'string') { + modified = true; + return { + ...(block as object), + signature: '', + } as unknown as AnthropicContentBlockParam; + } + return block; + }); + if (modified) { + message.content = normalized; + } + } + } + + /** + * DeepSeek's anthropic-compatible API rejects follow-up requests when an + * assistant turn carrying `tool_use` omits a thinking block while thinking + * mode is on, returning HTTP 400 ("The content[].thinking in the thinking + * mode must be passed back to the API."). The model can legitimately + * return a tool round without thinking content, so prepend a synthetic + * empty thinking block when one is missing. + * + * Live verification against api.deepseek.com/anthropic confirmed the + * trigger is specific to tool_use turns — plain-text assistant turns + * without thinking are accepted unchanged. We mirror that boundary here + * to avoid bloating replay history with synthetic blocks for turns the + * API already accepts. + * + * Should be paired with `fillMissingThinkingSignatures` running first + * so that signature-less `thinking` blocks become compliant in place + * (preserving their original text), and this pass then sees them as + * already-satisfying. https://github.com/QwenLM/qwen-code/issues/3786 + */ + private injectEmptyThinkingOnToolUseTurns( + messages: AnthropicMessageParam[], + ): void { + for (const message of messages) { + if (message.role !== 'assistant') continue; + if (!Array.isArray(message.content)) continue; + + const blocks = message.content; + + const hasToolUse = blocks.some( + (block) => (block as { type?: string }).type === 'tool_use', + ); + if (!hasToolUse) continue; + + const hasThinking = blocks.some((block) => { + const t = (block as { type?: string }).type; + return t === 'thinking' || t === 'redacted_thinking'; + }); + if (hasThinking) continue; + + // DeepSeek currently accepts an empty `signature` for synthetic + // thinking blocks. The `signature` field is an opaque token in the + // Anthropic spec, so this is a workaround — if DeepSeek tightens + // validation in the future, we may need to switch to + // `redacted_thinking` or another approach. + const emptyThinking = { + type: 'thinking', + thinking: '', + signature: '', + } as unknown as AnthropicContentBlockParam; + message.content = [emptyThinking, ...blocks]; + } + } + /** * Add cache_control to the last user message's content. * This enables prompt caching for the conversation context.