From ddaedeba1b945beda37c500bbd012826f2fc7675 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 15:10:45 +0800 Subject: [PATCH 01/16] fix(core): inject thinking blocks for DeepSeek anthropic-compatible provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek's anthropic-compatible endpoint (https://api.deepseek.com/anthropic) rejects follow-up requests with HTTP 400 ("The content[].thinking in the thinking mode must be passed back to the API.") whenever a prior assistant turn carrying tool_use omits a thinking block. The model can legitimately return a tool round without thinking text, so qwen-code stored no thought parts and rebuilt the next request with no thinking block, tripping the API's check. Mirroring the existing OpenAI-side fix (#3729, #3747), the converter now detects DeepSeek by base URL or model name and prepends an empty { type: 'thinking', thinking: '', signature: '' } block to assistant turns missing one. Other anthropic-protocol providers are unaffected. Verified against the live api.deepseek.com/anthropic endpoint: - assistant with tool_use, no thinking → 400 (reproduces #3786) - assistant with tool_use, empty thinking injected → 200 OK Refs #3786 --- .../anthropicContentGenerator.test.ts | 133 +++++++++++++ .../anthropicContentGenerator.ts | 18 ++ .../converter.test.ts | 179 ++++++++++++++++++ .../anthropicContentGenerator/converter.ts | 45 +++++ 4 files changed, 375 insertions(+) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index dbdb5501e3b..0e42cc16d5e 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -494,6 +494,139 @@ describe('AnthropicContentGenerator', () => { }); }); + // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's + // anthropic-compatible API rejects subsequent requests when any prior + // assistant turn omits a thinking block while thinking mode is on. + describe('DeepSeek anthropic-compatible provider', () => { + it('injects empty thinking blocks on prior assistant turns when baseUrl points to 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: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ], + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + // Assistant turn should now have an empty thinking block prepended. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'text', text: 'Hello!' }, + ], + }); + }); + + 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: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ], + } 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: 'text', text: 'Hello!' }, + ], + }); + }); + + 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: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ], + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + // No thinking block injected for non-deepseek providers. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); + }); + 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..793138e2d60 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -39,6 +39,23 @@ import { const debugLogger = createDebugLogger('ANTHROPIC'); +/** + * DeepSeek's anthropic-compatible API rejects requests in thinking mode that + * omit a thinking block on prior assistant turns. Detect by base URL or model + * name so the converter can inject empty thinking blocks where missing. + * https://github.com/QwenLM/qwen-code/issues/3786 + */ +function isDeepSeekAnthropicProvider( + contentGeneratorConfig: ContentGeneratorConfig, +): boolean { + const baseUrl = (contentGeneratorConfig.baseUrl ?? '').toLowerCase(); + if (baseUrl.includes('api.deepseek.com')) { + return true; + } + const model = (contentGeneratorConfig.model ?? '').toLowerCase(); + return model.includes('deepseek'); +} + type StreamingBlockState = { type: string; id?: string; @@ -84,6 +101,7 @@ export class AnthropicContentGenerator implements ContentGenerator { contentGeneratorConfig.model, contentGeneratorConfig.schemaCompliance, contentGeneratorConfig.enableCacheControl, + isDeepSeekAnthropicProvider(contentGeneratorConfig), ); } diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 7f3eb305377..6125f503f4a 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -647,6 +647,185 @@ describe('AnthropicContentConverter', () => { }); }); + // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's + // anthropic-compatible API rejects subsequent requests when any prior + // assistant turn omits a thinking block while thinking mode is on. The + // converter must inject empty thinking blocks when missing. + describe('ensureAssistantThinking', () => { + let deepseekConverter: AnthropicContentConverter; + + beforeEach(() => { + deepseekConverter = new AnthropicContentConverter( + 'deepseek-v4-pro', + 'auto', + false, + true, + ); + }); + + it('injects an empty thinking block on assistant turns missing one', () => { + const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + ], + }); + + expect(messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: '', signature: '' }, + { type: 'text', text: 'Hello!' }, + ], + }, + ]); + }); + + it('injects an empty thinking block on tool-calling assistant turns missing one', () => { + const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'List files' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'glob', + args: { pattern: '**/*.md' }, + }, + }, + ], + }, + ], + }); + + 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 assistant turns', () => { + const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { text: 'Let me think', thought: true, thoughtSignature: 'sig' }, + { text: 'Hello!' }, + ], + }, + ], + }); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me think', signature: 'sig' }, + { type: 'text', text: 'Hello!' }, + ], + }); + }); + + it('does not modify user messages', () => { + const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [{ role: 'user', parts: [{ text: 'Hi' }] }], + }); + + expect(messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + ]); + }); + + it('does nothing when option is disabled (default)', () => { + const defaultConverter = new AnthropicContentConverter( + 'deepseek-v4-pro', + 'auto', + false, + ); + + const { messages } = defaultConverter.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 prior assistant turn in a multi-turn history', () => { + const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Q1' }] }, + { role: 'model', parts: [{ text: 'A1' }] }, + { role: 'user', parts: [{ text: 'Q2' }] }, + { role: 'model', parts: [{ text: 'A2' }] }, + { role: 'user', parts: [{ text: 'Q3' }] }, + ], + }); + + 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('treats existing redacted_thinking blocks as satisfying the requirement', () => { + // redacted_thinking blocks come back from the response converter as + // { text: '', thought: true } (no thoughtSignature). When converted to + // Anthropic format they become { type: 'thinking', thinking: '' }, which + // already counts as a thinking block — so we should not prepend another. + const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [{ text: '', thought: true }, { text: 'Hello!' }], + }, + ], + }); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'Hello!' }, + ], + }); + }); + }); + 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..ab4c610d87c 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -35,15 +35,18 @@ export class AnthropicContentConverter { private model: string; private schemaCompliance: SchemaComplianceMode; private enableCacheControl: boolean; + private ensureAssistantThinking: boolean; constructor( model: string, schemaCompliance: SchemaComplianceMode = 'auto', enableCacheControl: boolean = true, + ensureAssistantThinking: boolean = false, ) { this.model = model; this.schemaCompliance = schemaCompliance; this.enableCacheControl = enableCacheControl; + this.ensureAssistantThinking = ensureAssistantThinking; } convertGeminiRequestToAnthropic(request: GenerateContentParameters): { @@ -58,6 +61,10 @@ export class AnthropicContentConverter { this.processContents(request.contents, messages); + if (this.ensureAssistantThinking) { + this.applyEmptyThinkingToAssistantMessages(messages); + } + // Add cache_control to enable prompt caching (if enabled) const system = this.enableCacheControl ? this.buildSystemWithCacheControl(systemText) @@ -544,6 +551,44 @@ export class AnthropicContentConverter { ]; } + /** + * DeepSeek's anthropic-compatible API rejects follow-up requests when any + * prior assistant turn 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 turn + * without thinking content, so inject an empty thinking block whenever one + * is missing. https://github.com/QwenLM/qwen-code/issues/3786 + */ + private applyEmptyThinkingToAssistantMessages( + messages: AnthropicMessageParam[], + ): void { + for (const message of messages) { + if (message.role !== 'assistant') continue; + + const blocks: AnthropicContentBlockParam[] = + typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : Array.isArray(message.content) + ? message.content + : []; + + const hasThinking = blocks.some( + (block) => + (block as { type?: string }).type === 'thinking' || + (block as { type?: string }).type === 'redacted_thinking', + ); + + if (!hasThinking) { + 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. From 2212b1823af43b8f4c3ff7e5265ce3a7f00d72e1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 16:57:56 +0800 Subject: [PATCH 02/16] fix(core): gate DeepSeek thinking-block injection on thinking mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback: 1. (Critical) Gate empty-thinking injection on the same per-request condition that emits the top-level `thinking` parameter. The previous implementation injected unconditionally on DeepSeek providers, but `buildThinkingConfig()` may omit `thinking` when reasoning=false or `thinkingConfig.includeThoughts=false` — which is exactly what suggestionGenerator / ArenaManager / forkedAgent do. Shipping thinking blocks without enabling thinking mode is a protocol violation that DeepSeek may reject. Move the option from converter constructor to a per-request `convertGeminiRequestToAnthropic` parameter so the generator can compute the gate correctly. 2. (CodeQL) Replace `baseUrl.includes('api.deepseek.com')` with `new URL(baseUrl).hostname` exact-match. The substring check would accept spoofed hosts like `api.deepseek.com.evil.com`. 3. Document the empty-signature workaround inline. 4. Rename the misleading "redacted_thinking" test case. --- .../anthropicContentGenerator.test.ts | 136 +++++++++++++ .../anthropicContentGenerator.ts | 49 +++-- .../converter.test.ts | 189 ++++++++++-------- .../anthropicContentGenerator/converter.ts | 26 ++- 4 files changed, 296 insertions(+), 104 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 0e42cc16d5e..5f6050ae512 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -625,6 +625,142 @@ describe('AnthropicContentGenerator', () => { content: [{ type: 'text', text: 'Hello!' }], }); }); + + 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: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ], + } 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. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); + + it('does not inject when reasoning is explicitly disabled', async () => { + // Even on a confirmed-DeepSeek provider, 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: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ], + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + const messages = (anthropicRequest as { messages: unknown[] }).messages; + + // No `thinking` field in the request body, no injected blocks either. + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ thinking: expect.anything() }), + ); + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); + + 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. + 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: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] }, + ], + 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(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); + }); }); describe('countTokens', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 793138e2d60..c8d1058a2d2 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -41,16 +41,26 @@ const debugLogger = createDebugLogger('ANTHROPIC'); /** * DeepSeek's anthropic-compatible API rejects requests in thinking mode that - * omit a thinking block on prior assistant turns. Detect by base URL or model - * name so the converter can inject empty thinking blocks where missing. - * https://github.com/QwenLM/qwen-code/issues/3786 + * omit a thinking block on prior assistant turns. Detect by base URL hostname + * or model name so the converter can inject empty thinking blocks where + * missing. https://github.com/QwenLM/qwen-code/issues/3786 */ function isDeepSeekAnthropicProvider( contentGeneratorConfig: ContentGeneratorConfig, ): boolean { - const baseUrl = (contentGeneratorConfig.baseUrl ?? '').toLowerCase(); - if (baseUrl.includes('api.deepseek.com')) { - return true; + 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'); @@ -74,6 +84,7 @@ type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { export class AnthropicContentGenerator implements ContentGenerator { private client: Anthropic; private converter: AnthropicContentConverter; + private readonly isDeepSeekProvider: boolean; constructor( private contentGeneratorConfig: ContentGeneratorConfig, @@ -101,7 +112,9 @@ export class AnthropicContentGenerator implements ContentGenerator { contentGeneratorConfig.model, contentGeneratorConfig.schemaCompliance, contentGeneratorConfig.enableCacheControl, - isDeepSeekAnthropicProvider(contentGeneratorConfig), + ); + this.isDeepSeekProvider = isDeepSeekAnthropicProvider( + contentGeneratorConfig, ); } @@ -204,17 +217,27 @@ export class AnthropicContentGenerator implements ContentGenerator { 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(); + + // Only ask the converter to inject empty thinking blocks when both the + // provider needs them AND this request actually enables thinking mode. + // Otherwise we'd ship thinking blocks without the top-level `thinking` + // parameter — a protocol violation that DeepSeek rejects with a different + // 400. Matters for code paths that pass `includeThoughts: false` + // (suggestionGenerator / ArenaManager / forkedAgent). + const ensureAssistantThinking = this.isDeepSeekProvider && !!thinking; + + const { system, messages } = this.converter.convertGeminiRequestToAnthropic( + request, + { ensureAssistantThinking }, + ); 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, diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 6125f503f4a..382f1072dbf 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -650,30 +650,29 @@ describe('AnthropicContentConverter', () => { // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's // anthropic-compatible API rejects subsequent requests when any prior // assistant turn omits a thinking block while thinking mode is on. The - // converter must inject empty thinking blocks when missing. + // converter must inject empty thinking blocks when the caller asks. describe('ensureAssistantThinking', () => { - let deepseekConverter: AnthropicContentConverter; - - beforeEach(() => { - deepseekConverter = new AnthropicContentConverter( - 'deepseek-v4-pro', - 'auto', - false, - true, - ); - }); + const enableThinking = { ensureAssistantThinking: true }; it('injects an empty thinking block on assistant turns missing one', () => { - const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ - model: 'models/test', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - ], - }); + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'model', parts: [{ text: 'Hello!' }] }, + ], + }, + enableThinking, + ); expect(messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + { + role: 'user', + content: [ + { type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } }, + ], + }, { role: 'assistant', content: [ @@ -685,24 +684,27 @@ describe('AnthropicContentConverter', () => { }); it('injects an empty thinking block on tool-calling assistant turns missing one', () => { - const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ - model: 'models/test', - contents: [ - { role: 'user', parts: [{ text: 'List files' }] }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call-1', - name: 'glob', - args: { pattern: '**/*.md' }, + 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', @@ -719,19 +721,26 @@ describe('AnthropicContentConverter', () => { }); it('preserves existing thinking blocks on assistant turns', () => { - const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ - model: 'models/test', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { - role: 'model', - parts: [ - { text: 'Let me think', thought: true, thoughtSignature: 'sig' }, - { text: 'Hello!' }, - ], - }, - ], - }); + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [ + { + text: 'Let me think', + thought: true, + thoughtSignature: 'sig', + }, + { text: 'Hello!' }, + ], + }, + ], + }, + enableThinking, + ); expect(messages[1]).toEqual({ role: 'assistant', @@ -743,24 +752,26 @@ describe('AnthropicContentConverter', () => { }); it('does not modify user messages', () => { - const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ - model: 'models/test', - contents: [{ role: 'user', parts: [{ text: 'Hi' }] }], - }); + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [{ role: 'user', parts: [{ text: 'Hi' }] }], + }, + enableThinking, + ); expect(messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + { + role: 'user', + content: [ + { type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } }, + ], + }, ]); }); it('does nothing when option is disabled (default)', () => { - const defaultConverter = new AnthropicContentConverter( - 'deepseek-v4-pro', - 'auto', - false, - ); - - const { messages } = defaultConverter.convertGeminiRequestToAnthropic({ + const { messages } = converter.convertGeminiRequestToAnthropic({ model: 'models/test', contents: [ { role: 'user', parts: [{ text: 'Hi' }] }, @@ -775,16 +786,19 @@ describe('AnthropicContentConverter', () => { }); it('injects thinking blocks on every prior assistant turn in a multi-turn history', () => { - const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ - model: 'models/test', - contents: [ - { role: 'user', parts: [{ text: 'Q1' }] }, - { role: 'model', parts: [{ text: 'A1' }] }, - { role: 'user', parts: [{ text: 'Q2' }] }, - { role: 'model', parts: [{ text: 'A2' }] }, - { role: 'user', parts: [{ text: 'Q3' }] }, - ], - }); + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Q1' }] }, + { role: 'model', parts: [{ text: 'A1' }] }, + { role: 'user', parts: [{ text: 'Q2' }] }, + { role: 'model', parts: [{ text: 'A2' }] }, + { role: 'user', parts: [{ text: 'Q3' }] }, + ], + }, + enableThinking, + ); expect(messages[1]).toMatchObject({ role: 'assistant' }); expect(messages[3]).toMatchObject({ role: 'assistant' }); @@ -800,21 +814,24 @@ describe('AnthropicContentConverter', () => { }); }); - it('treats existing redacted_thinking blocks as satisfying the requirement', () => { - // redacted_thinking blocks come back from the response converter as - // { text: '', thought: true } (no thoughtSignature). When converted to - // Anthropic format they become { type: 'thinking', thinking: '' }, which - // already counts as a thinking block — so we should not prepend another. - const { messages } = deepseekConverter.convertGeminiRequestToAnthropic({ - model: 'models/test', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { - role: 'model', - parts: [{ text: '', thought: true }, { text: 'Hello!' }], - }, - ], - }); + it('does not inject a duplicate thinking block when one already exists (even without signature)', () => { + // A part `{ text: '', thought: true }` (e.g. from a redacted_thinking + // response or a turn whose thinking stream had no content) converts to + // a `{ type: 'thinking', thinking: '' }` block without a signature. The + // injector should leave it alone rather than prepend a second one. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [{ text: '', thought: true }, { text: 'Hello!' }], + }, + ], + }, + enableThinking, + ); expect(messages[1]).toEqual({ role: 'assistant', diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index ab4c610d87c..4d266cbd642 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -31,25 +31,36 @@ type AnthropicToolParam = Anthropic.Tool & { }; type AnthropicContentBlockParam = Anthropic.ContentBlockParam; +export interface ConvertGeminiRequestToAnthropicOptions { + /** + * Inject an empty thinking block on assistant turns missing one. Required by + * DeepSeek's anthropic-compatible API when thinking mode is enabled — must + * be gated on the same per-request condition that sends the top-level + * `thinking` config so disabled-thinking requests don't ship stray thinking + * blocks. https://github.com/QwenLM/qwen-code/issues/3786 + */ + ensureAssistantThinking?: boolean; +} + export class AnthropicContentConverter { private model: string; private schemaCompliance: SchemaComplianceMode; private enableCacheControl: boolean; - private ensureAssistantThinking: boolean; constructor( model: string, schemaCompliance: SchemaComplianceMode = 'auto', enableCacheControl: boolean = true, - ensureAssistantThinking: boolean = false, ) { this.model = model; this.schemaCompliance = schemaCompliance; this.enableCacheControl = enableCacheControl; - this.ensureAssistantThinking = ensureAssistantThinking; } - convertGeminiRequestToAnthropic(request: GenerateContentParameters): { + convertGeminiRequestToAnthropic( + request: GenerateContentParameters, + options: ConvertGeminiRequestToAnthropicOptions = {}, + ): { system?: Anthropic.TextBlockParam[] | string; messages: AnthropicMessageParam[]; } { @@ -61,7 +72,7 @@ export class AnthropicContentConverter { this.processContents(request.contents, messages); - if (this.ensureAssistantThinking) { + if (options.ensureAssistantThinking) { this.applyEmptyThinkingToAssistantMessages(messages); } @@ -579,6 +590,11 @@ export class AnthropicContentConverter { ); if (!hasThinking) { + // 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: '', From 8721b41db20c989c09f1c2cdb5fde2c72e2eb3df Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 17:52:39 +0800 Subject: [PATCH 03/16] fix(core): narrow DeepSeek thinking injection to tool_use turns + subdomain test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 2: 1. Narrow injection scope to assistant turns containing tool_use. Live verification against api.deepseek.com/anthropic showed plain-text assistant turns without thinking are accepted unchanged — only tool_use turns trigger the HTTP 400. Injecting on every assistant turn unnecessarily bloats replay history with synthetic blocks the API does not require. Existing thinking blocks on any turn are still preserved untouched. 2. Add test coverage for the subdomain hostname branch (us.api.deepseek.com → matches), addressing the gap noted in review. 3. Update existing negative-case tests (non-deepseek / spoofed / reasoning=false / includeThoughts=false) to use tool_use scenarios so they actually exercise the gating logic instead of trivially passing under the narrowed scope. --- .../anthropicContentGenerator.test.ts | 140 +++++++++++------- .../converter.test.ts | 60 ++++---- .../anthropicContentGenerator/converter.ts | 50 ++++--- 3 files changed, 146 insertions(+), 104 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 5f6050ae512..9b9587ea1f8 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -498,7 +498,29 @@ describe('AnthropicContentGenerator', () => { // anthropic-compatible API rejects subsequent requests when any prior // assistant turn omits a thinking block while thinking mode is on. describe('DeepSeek anthropic-compatible provider', () => { - it('injects empty thinking blocks on prior assistant turns when baseUrl points to api.deepseek.com', async () => { + // 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', @@ -521,23 +543,18 @@ describe('AnthropicContentGenerator', () => { await generator.generateContent({ model: 'models/ignored', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - { role: 'user', parts: [{ text: 'How are you?' }] }, - ], + contents: toolUseConversation, } as unknown as GenerateContentParameters); const [anthropicRequest] = anthropicState.lastCreateArgs as AnthropicCreateArgs; const messages = (anthropicRequest as { messages: unknown[] }).messages; - // Assistant turn should now have an empty thinking block prepended. expect(messages[1]).toEqual({ role: 'assistant', content: [ { type: 'thinking', thinking: '', signature: '' }, - { type: 'text', text: 'Hello!' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, ], }); }); @@ -565,11 +582,46 @@ describe('AnthropicContentGenerator', () => { await generator.generateContent({ model: 'models/ignored', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - { role: 'user', parts: [{ text: 'How are you?' }] }, + 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] = @@ -580,11 +632,16 @@ describe('AnthropicContentGenerator', () => { role: 'assistant', content: [ { type: 'thinking', thinking: '', signature: '' }, - { type: 'text', text: 'Hello!' }, + { 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({ @@ -608,22 +665,15 @@ describe('AnthropicContentGenerator', () => { await generator.generateContent({ model: 'models/ignored', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - { role: 'user', parts: [{ text: 'How are you?' }] }, - ], + contents: toolUseConversation, } as unknown as GenerateContentParameters); const [anthropicRequest] = anthropicState.lastCreateArgs as AnthropicCreateArgs; const messages = (anthropicRequest as { messages: unknown[] }).messages; - // No thinking block injected for non-deepseek providers. - expect(messages[1]).toEqual({ - role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], - }); + // 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 () => { @@ -649,28 +699,23 @@ describe('AnthropicContentGenerator', () => { await generator.generateContent({ model: 'models/ignored', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - { role: 'user', parts: [{ text: 'How are you?' }] }, - ], + 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. - expect(messages[1]).toEqual({ - role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], - }); + // 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, if the request omits the - // top-level `thinking` parameter (because reasoning=false), shipping - // synthetic thinking blocks would be a protocol violation. + // 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', @@ -694,25 +739,17 @@ describe('AnthropicContentGenerator', () => { await generator.generateContent({ model: 'models/ignored', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - { role: 'user', parts: [{ text: 'How are you?' }] }, - ], + contents: toolUseConversation, } as unknown as GenerateContentParameters); const [anthropicRequest] = anthropicState.lastCreateArgs as AnthropicCreateArgs; const messages = (anthropicRequest as { messages: unknown[] }).messages; - // No `thinking` field in the request body, no injected blocks either. expect(anthropicRequest).toEqual( expect.not.objectContaining({ thinking: expect.anything() }), ); - expect(messages[1]).toEqual({ - role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], - }); + expect(messages[1]).toEqual(toolOnlyAssistant); }); it('does not inject when request sets thinkingConfig.includeThoughts=false', async () => { @@ -741,11 +778,7 @@ describe('AnthropicContentGenerator', () => { await generator.generateContent({ model: 'models/ignored', - contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, - { role: 'model', parts: [{ text: 'Hello!' }] }, - { role: 'user', parts: [{ text: 'How are you?' }] }, - ], + contents: toolUseConversation, config: { thinkingConfig: { includeThoughts: false } }, } as unknown as GenerateContentParameters); @@ -756,10 +789,7 @@ describe('AnthropicContentGenerator', () => { expect(anthropicRequest).toEqual( expect.not.objectContaining({ thinking: expect.anything() }), ); - expect(messages[1]).toEqual({ - role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], - }); + expect(messages[1]).toEqual(toolOnlyAssistant); }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 382f1072dbf..7459d8e52f6 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -654,7 +654,10 @@ describe('AnthropicContentConverter', () => { describe('ensureAssistantThinking', () => { const enableThinking = { ensureAssistantThinking: true }; - it('injects an empty thinking block on assistant turns missing one', () => { + 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', @@ -666,21 +669,10 @@ describe('AnthropicContentConverter', () => { enableThinking, ); - expect(messages).toEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } }, - ], - }, - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: '', signature: '' }, - { type: 'text', text: 'Hello!' }, - ], - }, - ]); + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + }); }); it('injects an empty thinking block on tool-calling assistant turns missing one', () => { @@ -720,12 +712,12 @@ describe('AnthropicContentConverter', () => { }); }); - it('preserves existing thinking blocks on assistant turns', () => { + it('preserves existing thinking blocks on tool-use assistant turns', () => { const { messages } = converter.convertGeminiRequestToAnthropic( { model: 'models/test', contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'user', parts: [{ text: 'Run tool' }] }, { role: 'model', parts: [ @@ -734,7 +726,7 @@ describe('AnthropicContentConverter', () => { thought: true, thoughtSignature: 'sig', }, - { text: 'Hello!' }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, ], }, ], @@ -746,7 +738,7 @@ describe('AnthropicContentConverter', () => { role: 'assistant', content: [ { type: 'thinking', thinking: 'Let me think', signature: 'sig' }, - { type: 'text', text: 'Hello!' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, ], }); }); @@ -785,16 +777,23 @@ describe('AnthropicContentConverter', () => { }); }); - it('injects thinking blocks on every prior assistant turn in a multi-turn history', () => { + 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: [{ text: 'A1' }] }, - { role: 'user', parts: [{ text: 'Q2' }] }, - { role: 'model', parts: [{ text: 'A2' }] }, - { role: 'user', parts: [{ text: 'Q3' }] }, + { role: 'model', parts: [toolUse('t1')] }, + { role: 'user', parts: [toolResult('t1')] }, + { role: 'model', parts: [toolUse('t2')] }, + { role: 'user', parts: [toolResult('t2')] }, ], }, enableThinking, @@ -814,7 +813,7 @@ describe('AnthropicContentConverter', () => { }); }); - it('does not inject a duplicate thinking block when one already exists (even without signature)', () => { + it('does not inject a duplicate thinking block when one already exists on a tool-use turn', () => { // A part `{ text: '', thought: true }` (e.g. from a redacted_thinking // response or a turn whose thinking stream had no content) converts to // a `{ type: 'thinking', thinking: '' }` block without a signature. The @@ -823,10 +822,13 @@ describe('AnthropicContentConverter', () => { { model: 'models/test', contents: [ - { role: 'user', parts: [{ text: 'Hi' }] }, + { role: 'user', parts: [{ text: 'Run tool' }] }, { role: 'model', - parts: [{ text: '', thought: true }, { text: 'Hello!' }], + parts: [ + { text: '', thought: true }, + { functionCall: { id: 't1', name: 'tool', args: {} } }, + ], }, ], }, @@ -837,7 +839,7 @@ describe('AnthropicContentConverter', () => { role: 'assistant', content: [ { type: 'thinking', thinking: '' }, - { type: 'text', text: 'Hello!' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, ], }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 4d266cbd642..547dba4651d 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -563,12 +563,18 @@ export class AnthropicContentConverter { } /** - * DeepSeek's anthropic-compatible API rejects follow-up requests when any - * prior assistant turn 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 turn - * without thinking content, so inject an empty thinking block whenever one - * is missing. https://github.com/QwenLM/qwen-code/issues/3786 + * 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 inject an 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. https://github.com/QwenLM/qwen-code/issues/3786 */ private applyEmptyThinkingToAssistantMessages( messages: AnthropicMessageParam[], @@ -583,25 +589,29 @@ export class AnthropicContentConverter { ? message.content : []; + const hasToolUse = blocks.some( + (block) => (block as { type?: string }).type === 'tool_use', + ); + if (!hasToolUse) continue; + const hasThinking = blocks.some( (block) => (block as { type?: string }).type === 'thinking' || (block as { type?: string }).type === 'redacted_thinking', ); - - if (!hasThinking) { - // 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]; - } + 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]; } } From 487066a75f858d87b40c3df6171980af364c56f6 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 17:59:58 +0800 Subject: [PATCH 04/16] docs(core): align DeepSeek thinking-injection comments with narrowed scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 3 (copilot-pull-request-reviewer × 3): comments in three locations still described the constraint as applying to "any prior assistant turn", which was true before commit 8721b41 but no longer matches the implementation. Update the doc comment on isDeepSeekAnthropicProvider and the two test-suite header comments to state the actual narrower contract: the API rejects only tool-use turns that omit thinking blocks; plain-text assistant turns are accepted unchanged. Comment-only change; 58 tests still pass. --- .../anthropicContentGenerator.test.ts | 5 +++-- .../anthropicContentGenerator.ts | 10 ++++++---- .../core/anthropicContentGenerator/converter.test.ts | 8 +++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 9b9587ea1f8..492c23b2e50 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -495,8 +495,9 @@ describe('AnthropicContentGenerator', () => { }); // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's - // anthropic-compatible API rejects subsequent requests when any prior - // assistant turn omits a thinking block while thinking mode is on. + // 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. diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index c8d1058a2d2..2235b76c367 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -40,10 +40,12 @@ import { const debugLogger = createDebugLogger('ANTHROPIC'); /** - * DeepSeek's anthropic-compatible API rejects requests in thinking mode that - * omit a thinking block on prior assistant turns. Detect by base URL hostname - * or model name so the converter can inject empty thinking blocks where - * missing. 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. 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, diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 7459d8e52f6..599e989d05d 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -648,9 +648,11 @@ describe('AnthropicContentConverter', () => { }); // https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's - // anthropic-compatible API rejects subsequent requests when any prior - // assistant turn omits a thinking block while thinking mode is on. The - // converter must inject empty thinking blocks when the caller asks. + // 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('ensureAssistantThinking', () => { const enableThinking = { ensureAssistantThinking: true }; From ae1aae5fbf514fe7ae96b1e91dc46230a1039aed Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 18:11:36 +0800 Subject: [PATCH 05/16] fix(core): per-request DeepSeek detection + strip thinking when off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 4 (copilot-pull-request-reviewer × 2): 1. Stale provider-detection cache (HCia). The constructor cached isDeepSeekProvider once, but Config.setModel() mutates contentGeneratorConfig.model in place. After a runtime /model switch from a non-DeepSeek model to a DeepSeek one on the same auth config, buildRequest() would keep using the stale flag. Move the detection into buildRequest so each call sees the current model. The detector is cheap (URL parse + string compare). 2. Real thought parts leak through when thinking is disabled (HCib). The previous gate only blocked synthetic injection — but the converter still replayed any existing `thought: true` parts in request.contents as thinking blocks. Code paths that disable thinking against a session whose history was built with thinking on (suggestionGenerator / ArenaManager / forkedAgent) would still emit thinking blocks alongside an absent top-level `thinking` config — the same protocol mismatch the gate was meant to avoid. Add a `stripAssistantThinking` converter option, set in buildRequest to `isDeepSeek && !thinking`. The converter strips thinking and redacted_thinking blocks from assistant messages before message construction completes. Mirror behavior is already proven safe by live verification (DeepSeek currently tolerates either shape, but stripping makes the request body internally consistent and robust to future validation tightening). 3 new tests: - converter strips thinking from assistant turns when option set - generator strips real thought parts when reasoning=false - generator reflects runtime model changes (no stale cache) 61 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 108 ++++++++++++++++++ .../anthropicContentGenerator.ts | 31 +++-- .../converter.test.ts | 44 +++++++ .../anthropicContentGenerator/converter.ts | 43 ++++++- 4 files changed, 209 insertions(+), 17 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 492c23b2e50..54f94ddbf43 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -753,6 +753,114 @@ describe('AnthropicContentGenerator', () => { 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. diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 2235b76c367..6366e5f2bd1 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -86,7 +86,6 @@ type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { export class AnthropicContentGenerator implements ContentGenerator { private client: Anthropic; private converter: AnthropicContentConverter; - private readonly isDeepSeekProvider: boolean; constructor( private contentGeneratorConfig: ContentGeneratorConfig, @@ -115,9 +114,6 @@ export class AnthropicContentGenerator implements ContentGenerator { contentGeneratorConfig.schemaCompliance, contentGeneratorConfig.enableCacheControl, ); - this.isDeepSeekProvider = isDeepSeekAnthropicProvider( - contentGeneratorConfig, - ); } async generateContent( @@ -223,17 +219,28 @@ export class AnthropicContentGenerator implements ContentGenerator { const thinking = this.buildThinkingConfig(request); const outputConfig = this.buildOutputConfig(); - // Only ask the converter to inject empty thinking blocks when both the - // provider needs them AND this request actually enables thinking mode. - // Otherwise we'd ship thinking blocks without the top-level `thinking` - // parameter — a protocol violation that DeepSeek rejects with a different - // 400. Matters for code paths that pass `includeThoughts: false` - // (suggestionGenerator / ArenaManager / forkedAgent). - const ensureAssistantThinking = this.isDeepSeekProvider && !!thinking; + // 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 ensureAssistantThinking = isDeepSeek && !!thinking; + const stripAssistantThinking = isDeepSeek && !thinking; const { system, messages } = this.converter.convertGeminiRequestToAnthropic( request, - { ensureAssistantThinking }, + { ensureAssistantThinking, stripAssistantThinking }, ); const tools = request.config?.tools diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 599e989d05d..84fd2506f18 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -815,6 +815,50 @@ describe('AnthropicContentConverter', () => { }); }); + 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('does not inject a duplicate thinking block when one already exists on a tool-use turn', () => { // A part `{ text: '', thought: true }` (e.g. from a redacted_thinking // response or a turn whose thinking stream had no content) converts to diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 547dba4651d..1f40c52ec31 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -33,13 +33,20 @@ type AnthropicContentBlockParam = Anthropic.ContentBlockParam; export interface ConvertGeminiRequestToAnthropicOptions { /** - * Inject an empty thinking block on assistant turns missing one. Required by - * DeepSeek's anthropic-compatible API when thinking mode is enabled — must - * be gated on the same per-request condition that sends the top-level - * `thinking` config so disabled-thinking requests don't ship stray thinking - * blocks. https://github.com/QwenLM/qwen-code/issues/3786 + * Inject an empty thinking block on tool-use assistant turns missing one. + * Required by DeepSeek's anthropic-compatible API when thinking mode is + * enabled. 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 */ ensureAssistantThinking?: 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 { @@ -72,6 +79,9 @@ export class AnthropicContentConverter { this.processContents(request.contents, messages); + if (options.stripAssistantThinking) { + this.stripThinkingFromAssistantMessages(messages); + } if (options.ensureAssistantThinking) { this.applyEmptyThinkingToAssistantMessages(messages); } @@ -562,6 +572,29 @@ 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. + */ + 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 !== message.content.length) { + message.content = filtered; + } + } + } + /** * DeepSeek's anthropic-compatible API rejects follow-up requests when an * assistant turn carrying `tool_use` omits a thinking block while thinking From ec30c1d2e93baaa8f67123daf345d1922a7fe828 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 20:36:08 +0800 Subject: [PATCH 06/16] fix(core): preserve thinking-only assistant turns instead of emitting empty content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 5 (copilot-pull-request-reviewer × 2 — code + test): stripThinkingFromAssistantMessages previously replaced message.content with the filtered array unconditionally. For an assistant turn whose only blocks are thinking/redacted_thinking (e.g. a round cut off by max_tokens before any text or tool_use was emitted), this left `content: []` — which Anthropic API rejects. Dropping the message entirely was considered but would break the required user/assistant alternation. Instead, fall back to leaving the original blocks in place when stripping would empty the message. DeepSeek empirically tolerates the residual `thinking-block + no-thinking-config` shape (verified against api.deepseek.com/anthropic in the V2/X scenarios), so leaving the message untouched is the safer choice than emitting invalid structure. Add regression test for the thinking-only turn shape. 62 tests pass; lint + typecheck clean. --- .../converter.test.ts | 36 +++++++++++++++++++ .../anthropicContentGenerator/converter.ts | 10 ++++++ 2 files changed, 46 insertions(+) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 84fd2506f18..4bc9f1e6520 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -815,6 +815,42 @@ describe('AnthropicContentConverter', () => { }); }); + 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 diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 1f40c52ec31..7ee26014488 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -577,6 +577,15 @@ export class AnthropicContentConverter { * 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[], @@ -589,6 +598,7 @@ export class AnthropicContentConverter { 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; } From 51b59d6f8d7cbbc6bce055e65a3905dcfc1609d1 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 21:23:23 +0800 Subject: [PATCH 07/16] fix(core): validate thinking-block signature, rename option, gate output_config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 6 — five substantive items: 1. (Critical) Drop non-compliant thinking blocks lacking a `signature` field and replace them with a synthetic one. A `redacted_thinking` block round-tripped through Gemini Part format becomes `{ text: '', thought: true }` (no thoughtSignature) and converts back to `{ type: 'thinking', thinking: '' }` without `signature` — not spec-compliant. The previous `hasThinking` check accepted these as already-satisfying, leaving non-compliant blocks in the wire message. Tighten the check so they're filtered out and the synthetic injection runs. Live verification: DeepSeek currently tolerates both shapes (lenient), but normalizing is defensively correct against future tightening. 2. Rename converter option `ensureAssistantThinking` → `ensureThinkingOnToolUseTurns`. The new name reflects the actual contract (tool-use turns only, not every assistant turn). 3. Honor `thinkingConfig.includeThoughts: false` in `buildOutputConfig`. Previously a per-request opt-out dropped the top-level `thinking` parameter but still emitted `output_config.effort`, leaking a reasoning-shaped field into side queries that don't want it. 4. Add regression test for mixed text + tool_use assistant turns (common shape: model says something, then calls a tool). 5. Add explicit test for the signature-validation path: an existing compliant thinking block (with signature) is preserved untouched. 64 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.ts | 19 +++-- .../converter.test.ts | 84 +++++++++++++++++-- .../anthropicContentGenerator/converter.ts | 55 ++++++++---- 3 files changed, 127 insertions(+), 31 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 6366e5f2bd1..b3fe8fc817a 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -217,7 +217,7 @@ export class AnthropicContentGenerator implements ContentGenerator { ): Promise { const sampling = this.buildSamplingParameters(request); const thinking = this.buildThinkingConfig(request); - const outputConfig = this.buildOutputConfig(); + 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 @@ -235,12 +235,12 @@ export class AnthropicContentGenerator implements ContentGenerator { // against a session whose history already contains // `thought: true` parts (suggestionGenerator / // ArenaManager / forkedAgent). - const ensureAssistantThinking = isDeepSeek && !!thinking; + const ensureThinkingOnToolUseTurns = isDeepSeek && !!thinking; const stripAssistantThinking = isDeepSeek && !thinking; const { system, messages } = this.converter.convertGeminiRequestToAnthropic( request, - { ensureAssistantThinking, stripAssistantThinking }, + { ensureThinkingOnToolUseTurns, stripAssistantThinking }, ); const tools = request.config?.tools @@ -341,9 +341,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 4bc9f1e6520..82bf24febb2 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -653,8 +653,8 @@ describe('AnthropicContentConverter', () => { // 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('ensureAssistantThinking', () => { - const enableThinking = { ensureAssistantThinking: true }; + describe('ensureThinkingOnToolUseTurns', () => { + const enableThinking = { ensureThinkingOnToolUseTurns: true }; it('does not inject on plain-text assistant turns (DeepSeek tolerates them)', () => { // Verified against api.deepseek.com/anthropic: plain-text assistant @@ -895,11 +895,12 @@ describe('AnthropicContentConverter', () => { }); }); - it('does not inject a duplicate thinking block when one already exists on a tool-use turn', () => { - // A part `{ text: '', thought: true }` (e.g. from a redacted_thinking - // response or a turn whose thinking stream had no content) converts to - // a `{ type: 'thinking', thinking: '' }` block without a signature. The - // injector should leave it alone rather than prepend a second one. + it('replaces a non-compliant thinking block (no signature field) with a synthetic one', () => { + // 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. That shape is not spec-compliant, so the injector + // drops it and prepends a synthetic block with `signature: ''`. const { messages } = converter.convertGeminiRequestToAnthropic( { model: 'models/test', @@ -920,11 +921,78 @@ describe('AnthropicContentConverter', () => { expect(messages[1]).toEqual({ role: 'assistant', content: [ - { type: 'thinking', thinking: '' }, + { 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('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', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 7ee26014488..0ad76c106dd 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -33,13 +33,16 @@ type AnthropicContentBlockParam = Anthropic.ContentBlockParam; export interface ConvertGeminiRequestToAnthropicOptions { /** - * Inject an empty thinking block on tool-use assistant turns missing one. - * Required by DeepSeek's anthropic-compatible API when thinking mode is - * enabled. 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 + * On assistant turns containing `tool_use` but lacking a compliant thinking + * block (i.e. a `thinking` block with a `signature` field, or a + * `redacted_thinking` block), prepend a synthetic empty thinking block and + * drop any non-compliant `thinking` block already present. Required by + * DeepSeek's anthropic-compatible API when thinking mode is enabled. + * 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 */ - ensureAssistantThinking?: boolean; + ensureThinkingOnToolUseTurns?: boolean; /** * Strip thinking and redacted_thinking blocks from assistant messages. * Used to keep DeepSeek requests consistent when thinking mode is off but @@ -82,8 +85,8 @@ export class AnthropicContentConverter { if (options.stripAssistantThinking) { this.stripThinkingFromAssistantMessages(messages); } - if (options.ensureAssistantThinking) { - this.applyEmptyThinkingToAssistantMessages(messages); + if (options.ensureThinkingOnToolUseTurns) { + this.applyEmptyThinkingToToolUseTurns(messages); } // Add cache_control to enable prompt caching (if enabled) @@ -617,9 +620,17 @@ export class AnthropicContentConverter { * 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. https://github.com/QwenLM/qwen-code/issues/3786 + * already accepts. + * + * Edge case: a `thinking` block round-tripped through Gemini Part format + * may come back without its `signature` field (e.g. when the upstream + * block was `redacted_thinking`, whose `data` field doesn't survive the + * `{ thought: true }` representation). Such blocks are not spec-compliant, + * so we drop them here and let the synthetic injection take over rather + * than treat them as already-satisfying the requirement. + * https://github.com/QwenLM/qwen-code/issues/3786 */ - private applyEmptyThinkingToAssistantMessages( + private applyEmptyThinkingToToolUseTurns( messages: AnthropicMessageParam[], ): void { for (const message of messages) { @@ -637,12 +648,22 @@ export class AnthropicContentConverter { ); if (!hasToolUse) continue; - const hasThinking = blocks.some( - (block) => - (block as { type?: string }).type === 'thinking' || - (block as { type?: string }).type === 'redacted_thinking', - ); - if (hasThinking) continue; + // A redacted_thinking block satisfies the requirement on its own. + // A thinking block satisfies it only when it carries a signature + // field — otherwise it's a non-compliant artefact of the Gemini-Part + // round trip and should be dropped before we inject our synthetic. + const hasCompliantThinking = blocks.some((block) => { + const b = block as { type?: string; signature?: unknown }; + if (b.type === 'redacted_thinking') return true; + if (b.type === 'thinking') return typeof b.signature === 'string'; + return false; + }); + if (hasCompliantThinking) continue; + + const cleanedBlocks = blocks.filter((block) => { + const b = block as { type?: string; signature?: unknown }; + return !(b.type === 'thinking' && typeof b.signature !== 'string'); + }); // DeepSeek currently accepts an empty `signature` for synthetic // thinking blocks. The `signature` field is an opaque token in the @@ -654,7 +675,7 @@ export class AnthropicContentConverter { thinking: '', signature: '', } as unknown as AnthropicContentBlockParam; - message.content = [emptyThinking, ...blocks]; + message.content = [emptyThinking, ...cleanedBlocks]; } } From 106a27aad18b7f658e5599ed79c90b21cf9acdc7 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 22:03:09 +0800 Subject: [PATCH 08/16] fix(core): clean up non-compliant thinking blocks on plain-text turns + assert output_config gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 7 (copilot-pull-request-reviewer × 2): 1. (Hp-x) Round-tripped redacted_thinking blocks were left malformed on assistant turns lacking tool_use. The previous structure only ran the cleanup pass when a tool_use block was present (early return on `!hasToolUse`), so plain-text turns kept the non-compliant `{ type: 'thinking', thinking: '' }` shape. Restructure into two sequential steps: a. Drop non-compliant thinking blocks (no `signature`) on every assistant turn — same fallback that avoids `content: []` if the message is thinking-only. b. Inject the synthetic empty thinking block on tool_use turns that still lack a compliant thinking block after step (a). 2. (Hp-7) The includeThoughts=false test asserted that the top-level `thinking` field is suppressed but didn't cover `output_config`, leaving regressions in the new `buildOutputConfig` gate uncaught. Tighten the assertion to also verify `output_config` is absent. 3. New converter test: cleanup runs on plain-text assistant turns too. 65 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 8 +++- .../converter.test.ts | 27 +++++++++++++ .../anthropicContentGenerator/converter.ts | 40 ++++++++++++------- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 54f94ddbf43..77e1abdc91a 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -863,7 +863,10 @@ describe('AnthropicContentGenerator', () => { 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. + // 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', @@ -898,6 +901,9 @@ describe('AnthropicContentGenerator', () => { expect(anthropicRequest).toEqual( expect.not.objectContaining({ thinking: expect.anything() }), ); + expect(anthropicRequest).toEqual( + expect.not.objectContaining({ output_config: expect.anything() }), + ); expect(messages[1]).toEqual(toolOnlyAssistant); }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 82bf24febb2..e263c89820c 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -964,6 +964,33 @@ describe('AnthropicContentConverter', () => { }); }); + it('drops non-compliant thinking blocks from plain-text assistant turns even when no tool_use is present', () => { + // A `redacted_thinking` round-tripped through Gemini-Part comes back as + // `{ type: 'thinking', thinking: '' }` with no signature. On a + // plain-text turn there is nothing to inject (no tool_use), but the + // bad block is still non-compliant and should be cleaned up. + const { messages } = converter.convertGeminiRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Hi' }] }, + { + role: 'model', + parts: [{ text: '', thought: true }, { text: 'Hello!' }], + }, + ], + }, + enableThinking, + ); + + // Bad thinking block dropped; remaining text passes through. No + // synthetic prepended because the turn has no tool_use. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ 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. diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 0ad76c106dd..99d24e94058 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -643,28 +643,38 @@ export class AnthropicContentConverter { ? message.content : []; - const hasToolUse = blocks.some( + // Step 1: Drop non-compliant thinking blocks (no `signature` field) on + // every assistant turn, not just tool-use ones. Plain-text turns can + // carry these too — e.g. when `redacted_thinking` round-trips through + // the Gemini Part representation, losing its `data` payload along the + // way. Empirically DeepSeek tolerates the residual shape, but + // normalizing keeps the wire message spec-correct. + const cleanedBlocks = blocks.filter((block) => { + const b = block as { type?: string; signature?: unknown }; + return !(b.type === 'thinking' && typeof b.signature !== 'string'); + }); + + // Avoid emitting `content: []` (Anthropic API rejects). If cleanup + // would empty the message, leave the original blocks in place. + if (cleanedBlocks.length === 0) continue; + if (cleanedBlocks.length !== blocks.length) { + message.content = cleanedBlocks; + } + + // Step 2: On tool-use turns missing a compliant thinking block, + // prepend an empty synthetic. This is the issue #3786 trigger. + const hasToolUse = cleanedBlocks.some( (block) => (block as { type?: string }).type === 'tool_use', ); if (!hasToolUse) continue; - // A redacted_thinking block satisfies the requirement on its own. - // A thinking block satisfies it only when it carries a signature - // field — otherwise it's a non-compliant artefact of the Gemini-Part - // round trip and should be dropped before we inject our synthetic. - const hasCompliantThinking = blocks.some((block) => { - const b = block as { type?: string; signature?: unknown }; - if (b.type === 'redacted_thinking') return true; - if (b.type === 'thinking') return typeof b.signature === 'string'; - return false; + const hasCompliantThinking = cleanedBlocks.some((block) => { + const t = (block as { type?: string }).type; + // Any thinking block remaining here passed Step 1's signature check. + return t === 'thinking' || t === 'redacted_thinking'; }); if (hasCompliantThinking) continue; - const cleanedBlocks = blocks.filter((block) => { - const b = block as { type?: string; signature?: unknown }; - return !(b.type === 'thinking' && typeof b.signature !== 'string'); - }); - // 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 From 8f3932598214ca47cbb6e36ae64f7277ab75d51e Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 22:05:23 +0800 Subject: [PATCH 09/16] test(core): add explicit redacted_thinking injection-path coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 8 (#30 — copilot reviewer). The converter treats `redacted_thinking` as already satisfying the thinking-block requirement (no synthetic injected), distinguished from a signature-less `thinking` block which is non-compliant and gets dropped/replaced. Existing tests covered the latter path; this adds explicit coverage of the former. processContent doesn't synthesize redacted_thinking from Gemini parts, so the test reaches into the private helper directly. (#31 — subdomain hostname coverage — already exists at line 602.) 66 tests pass; lint + typecheck clean. --- .../converter.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index e263c89820c..70ddb2f90c0 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -895,6 +895,30 @@ describe('AnthropicContentConverter', () => { }); }); + 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).applyEmptyThinkingToToolUseTurns(messages); + + expect(messages[0].content).toEqual([ + { type: 'redacted_thinking', data: 'opaque' }, + { type: 'tool_use', id: 't1', name: 'tool', input: {} }, + ]); + }); + it('replaces a non-compliant thinking block (no signature field) with a synthetic one', () => { // A part `{ text: '', thought: true }` (e.g. round-tripped from a // `redacted_thinking` response that lost its `data` field via the From 45d394b5cedbd83bfa625d5663db41b619734059 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 22:14:05 +0800 Subject: [PATCH 10/16] fix(core): per-request anthropic-beta + normalize thinking-only turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 9 (copilot-pull-request-reviewer × 2): 1. (Hydz) thinking-only assistant turns (e.g. max_tokens cutoff or round-tripped redacted_thinking) hit the cleanup-empties fallback and kept the original non-compliant `{ type: 'thinking', thinking: '' }` block. The fallback now replaces the message with a synthetic empty thinking block (`signature: ''` included), which keeps the message non-empty AND spec-compliant. 2. (Hyd4) `anthropic-beta` was set once at construction from the global `reasoning` config, so requests with per-request `thinkingConfig.includeThoughts=false` still advertised interleaved-thinking / effort even though the body had dropped the matching fields. Move beta computation to a new `buildPerRequestHeaders` that derives the header from the actual `thinking` / `output_config` fields present in the request body, and pass it via `messages.create(..., { headers })`. The wire shape is now internally consistent. Test updates: - Drop the three constructor-time beta assertions; they no longer apply. - Add four per-request header tests covering: both betas present, only interleaved-thinking, reasoning=false (no betas), and per-request includeThoughts=false (no betas). 67 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 127 ++++++++++-------- .../anthropicContentGenerator.ts | 41 +++--- .../anthropicContentGenerator/converter.ts | 32 +++-- 3 files changed, 114 insertions(+), 86 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 77e1abdc91a..642a047d0f7 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -148,71 +148,86 @@ 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( - { - model: 'claude-test', - apiKey: 'test-key', - baseUrl: 'https://example.invalid', - timeout: 10_000, - maxRetries: 2, - samplingParams: {}, - schemaCompliance: 'auto', - reasoning: { effort: 'medium' }, - }, - mockConfig, - ); - - const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || - {}) as Record; - expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); - }); + // 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', + }; - it('does not add the effort beta header when reasoning.effort is not set', async () => { - const { AnthropicContentGenerator } = await importGenerator(); - void new AnthropicContentGenerator( - { + 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', - }, - mockConfig, - ); + 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; + } - const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || - {}) as Record; - expect(headers['anthropic-beta']).not.toContain('effort-2025-11-24'); - }); + it('sends interleaved-thinking + effort beta when both are present in the body', async () => { + const headers = await callOnce({ + ...baseConfig, + reasoning: { effort: 'medium' }, + }); + expect(headers['anthropic-beta']).toContain( + 'interleaved-thinking-2025-05-14', + ); + expect(headers['anthropic-beta']).toContain('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', - reasoning: false, - }, - mockConfig, - ); + 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'); + }); - const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || - {}) as Record; - expect(headers['anthropic-beta']).toBeUndefined(); + 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(); + }); + + 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(); + }); }); describe('generateContent', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index b3fe8fc817a..7da82201908 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -120,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); @@ -131,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 }; } = { @@ -142,6 +145,7 @@ export class AnthropicContentGenerator implements ContentGenerator { streamingRequest as MessageCreateParamsStreaming, { signal: request.config?.abortSignal, + ...(headers ? { headers } : {}), }, )) as AsyncIterable; @@ -184,32 +188,35 @@ 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. const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { customHeaders } = this.contentGeneratorConfig; - const betas: string[] = []; - const reasoning = this.contentGeneratorConfig.reasoning; + const headers: Record = { 'User-Agent': userAgent }; + return customHeaders ? { ...headers, ...customHeaders } : headers; + } - // Interleaved thinking is used when we send the `thinking` field. - if (reasoning !== false) { + /** + * 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. + */ + private buildPerRequestHeaders( + anthropicRequest: MessageCreateParamsWithThinking, + ): Record | undefined { + const betas: string[] = []; + if (anthropicRequest.thinking) { betas.push('interleaved-thinking-2025-05-14'); } - - // Effort (beta) is enabled when reasoning.effort is set. - if (reasoning !== false && reasoning?.effort !== undefined) { + if (anthropicRequest.output_config) { betas.push('effort-2025-11-24'); } - - const headers: Record = { - 'User-Agent': userAgent, - }; - - if (betas.length) { - headers['anthropic-beta'] = betas.join(','); - } - - return customHeaders ? { ...headers, ...customHeaders } : headers; + return betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : undefined; } private async buildRequest( diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 99d24e94058..e1c8e548423 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -654,9 +654,25 @@ export class AnthropicContentConverter { return !(b.type === 'thinking' && typeof b.signature !== 'string'); }); - // Avoid emitting `content: []` (Anthropic API rejects). If cleanup - // would empty the message, leave the original blocks in place. - if (cleanedBlocks.length === 0) 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; + + // If cleanup would leave `content: []` (Anthropic API rejects) — i.e. + // the whole turn was non-compliant thinking blocks — replace with a + // synthetic empty thinking block so the message stays non-empty AND + // spec-compliant. + if (cleanedBlocks.length === 0) { + message.content = [emptyThinking]; + continue; + } if (cleanedBlocks.length !== blocks.length) { message.content = cleanedBlocks; } @@ -675,16 +691,6 @@ export class AnthropicContentConverter { }); if (hasCompliantThinking) 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, ...cleanedBlocks]; } } From 07c14ee9585ca0c499e5a34a2fd50a3b4da4c153 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 22:25:03 +0800 Subject: [PATCH 11/16] fix(core): preserve thinking text by normalizing in place + merge user beta flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 10 (copilot-pull-request-reviewer × 2): 1. (H0oF) The previous cleanup filtered out every thinking block missing a `signature` field. But that shape is the normal output from OpenAI/Gemini/agent-runtime generators, which only set `thought: true` without a signature. Users switching providers mid-session would silently lose preserved thinking text on the first DeepSeek request. Change Step 1 to NORMALIZE in place: when a thinking block has no signature, set `signature: ''` rather than dropping the block. The original `thinking` text is preserved; DeepSeek empirically accepts empty signatures so the wire shape stays valid. 2. (H0oL) `buildPerRequestHeaders()` overwrote `customHeaders['anthropic-beta']` whenever the per-request override fired, regressing the customHeaders escape hatch for unrelated Anthropic beta features. Merge the user's flags into the computed list (deduped) so users can stack their own betas alongside interleaved-thinking / effort. Test changes: - Renamed and rewrote "drops non-compliant... plain-text" test to assert in-place normalization that preserves thinking text. - Updated "replaces a non-compliant thinking block" comment + name to describe the normalization (the assertion was already correct because the test happened to use empty thinking text). - The empty-content fallback in Step 1 is no longer reachable under the new logic, so the dedicated thinking-only-turn test now exercises only the strip path (where it remains relevant). - Added 3 customHeaders[anthropic-beta] tests: merge with computed, passthrough when no thinking/effort, dedupe. 70 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 43 +++++++++++ .../anthropicContentGenerator.ts | 19 ++++- .../converter.test.ts | 36 ++++++---- .../anthropicContentGenerator/converter.ts | 72 +++++++++---------- 4 files changed, 121 insertions(+), 49 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 642a047d0f7..d35ecf1c404 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -218,6 +218,49 @@ describe('AnthropicContentGenerator', () => { expect(headers['anthropic-beta']).toBeUndefined(); }); + 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('passes user-supplied customHeaders[anthropic-beta] through even when no thinking/effort is enabled', async () => { + const headers = await callOnce({ + ...baseConfig, + reasoning: false, + customHeaders: { 'anthropic-beta': 'experimental-x' }, + }); + expect(headers['anthropic-beta']).toBe('experimental-x'); + }); + + 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 diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 7da82201908..4ee9f29dc60 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -205,18 +205,35 @@ export class AnthropicContentGenerator implements ContentGenerator { * 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. */ private buildPerRequestHeaders( anthropicRequest: MessageCreateParamsWithThinking, ): Record | undefined { const betas: string[] = []; + + const userBeta = + this.contentGeneratorConfig.customHeaders?.['anthropic-beta']; + if (typeof userBeta === 'string' && userBeta) { + for (const flag of userBeta.split(',')) { + const trimmed = flag.trim(); + if (trimmed) betas.push(trimmed); + } + } + if (anthropicRequest.thinking) { betas.push('interleaved-thinking-2025-05-14'); } if (anthropicRequest.output_config) { betas.push('effort-2025-11-24'); } - return betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : undefined; + + if (betas.length === 0) return undefined; + const unique = Array.from(new Set(betas)); + return { 'anthropic-beta': unique.join(',') }; } private async buildRequest( diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 70ddb2f90c0..da32afa3a7c 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -919,12 +919,13 @@ describe('AnthropicContentConverter', () => { ]); }); - it('replaces a non-compliant thinking block (no signature field) with a synthetic one', () => { + 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. That shape is not spec-compliant, so the injector - // drops it and prepends a synthetic block with `signature: ''`. + // `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', @@ -988,11 +989,14 @@ describe('AnthropicContentConverter', () => { }); }); - it('drops non-compliant thinking blocks from plain-text assistant turns even when no tool_use is present', () => { - // A `redacted_thinking` round-tripped through Gemini-Part comes back as - // `{ type: 'thinking', thinking: '' }` with no signature. On a - // plain-text turn there is nothing to inject (no tool_use), but the - // bad block is still non-compliant and should be cleaned up. + 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', @@ -1000,18 +1004,26 @@ describe('AnthropicContentConverter', () => { { role: 'user', parts: [{ text: 'Hi' }] }, { role: 'model', - parts: [{ text: '', thought: true }, { text: 'Hello!' }], + parts: [ + { text: 'cross-provider thoughts', thought: true }, + { text: 'Hello!' }, + ], }, ], }, enableThinking, ); - // Bad thinking block dropped; remaining text passes through. No - // synthetic prepended because the turn has no tool_use. expect(messages[1]).toEqual({ role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], + content: [ + { + type: 'thinking', + thinking: 'cross-provider thoughts', + signature: '', + }, + { type: 'text', text: 'Hello!' }, + ], }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index e1c8e548423..2861c42a3b4 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -643,16 +643,43 @@ export class AnthropicContentConverter { ? message.content : []; - // Step 1: Drop non-compliant thinking blocks (no `signature` field) on - // every assistant turn, not just tool-use ones. Plain-text turns can - // carry these too — e.g. when `redacted_thinking` round-trips through - // the Gemini Part representation, losing its `data` payload along the - // way. Empirically DeepSeek tolerates the residual shape, but - // normalizing keeps the wire message spec-correct. - const cleanedBlocks = blocks.filter((block) => { + // Step 1: Normalize non-compliant `thinking` blocks (missing the + // required `signature` field) by setting `signature: ''` in place. This + // shape commonly arrives from other generators (OpenAI/Gemini/agent- + // runtime emit `{ thought: true }` parts with no signature) when users + // switch providers mid-session, or from `redacted_thinking` blocks that + // lost their `data` field through the Gemini-Part round trip. Adding an + // empty signature preserves the original thinking text instead of + // dropping the block — DeepSeek currently accepts empty signatures, so + // this keeps the wire shape spec-compliant without losing history. + let modified = false; + const normalizedBlocks = blocks.map((block) => { const b = block as { type?: string; signature?: unknown }; - return !(b.type === 'thinking' && typeof b.signature !== 'string'); + 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 = normalizedBlocks; + } + + // Step 2: On tool-use turns still lacking any thinking block, prepend + // a synthetic empty one (the issue #3786 trigger). + const hasToolUse = normalizedBlocks.some( + (block) => (block as { type?: string }).type === 'tool_use', + ); + if (!hasToolUse) continue; + + const hasThinking = normalizedBlocks.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 @@ -664,34 +691,7 @@ export class AnthropicContentConverter { thinking: '', signature: '', } as unknown as AnthropicContentBlockParam; - - // If cleanup would leave `content: []` (Anthropic API rejects) — i.e. - // the whole turn was non-compliant thinking blocks — replace with a - // synthetic empty thinking block so the message stays non-empty AND - // spec-compliant. - if (cleanedBlocks.length === 0) { - message.content = [emptyThinking]; - continue; - } - if (cleanedBlocks.length !== blocks.length) { - message.content = cleanedBlocks; - } - - // Step 2: On tool-use turns missing a compliant thinking block, - // prepend an empty synthetic. This is the issue #3786 trigger. - const hasToolUse = cleanedBlocks.some( - (block) => (block as { type?: string }).type === 'tool_use', - ); - if (!hasToolUse) continue; - - const hasCompliantThinking = cleanedBlocks.some((block) => { - const t = (block as { type?: string }).type; - // Any thinking block remaining here passed Step 1's signature check. - return t === 'thinking' || t === 'redacted_thinking'; - }); - if (hasCompliantThinking) continue; - - message.content = [emptyThinking, ...cleanedBlocks]; + message.content = [emptyThinking, ...normalizedBlocks]; } } From fdf2257a03396f99d01e436e8ff0fa109fc32187 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 22:35:49 +0800 Subject: [PATCH 12/16] docs(core): align thinking-injection comments with normalize semantics + add stream test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 11 (copilot-pull-request-reviewer × 3): 1. (H3Iw) Update the `ensureThinkingOnToolUseTurns` option docstring to describe in-place normalization (preserving thinking text by filling in `signature: ''`) instead of the old drop-and-replace semantics. 2. (H3I9) Same update on the `applyEmptyThinkingToToolUseTurns` helper JSDoc — clarify that signature-less thinking blocks are normalized in place (preserving original text), not dropped. Mention the common case of cross-provider history where non-Anthropic generators only set `thought: true`. 3. (H3I3) Add a streaming test asserting that `generateContentStream()` also attaches the per-request `anthropic-beta` header. The previous coverage only exercised `generateContent()`, leaving the streaming path's separate code path (line 144 in anthropicContentGenerator.ts) unverified. 71 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 33 +++++++++++++++++++ .../anthropicContentGenerator/converter.ts | 24 +++++++------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index d35ecf1c404..1a3f60386c1 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -271,6 +271,39 @@ describe('AnthropicContentGenerator', () => { ); expect(headers['anthropic-beta']).toBeUndefined(); }); + + 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', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 2861c42a3b4..e6a5aa467d1 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -33,14 +33,14 @@ type AnthropicContentBlockParam = Anthropic.ContentBlockParam; export interface ConvertGeminiRequestToAnthropicOptions { /** - * On assistant turns containing `tool_use` but lacking a compliant thinking - * block (i.e. a `thinking` block with a `signature` field, or a - * `redacted_thinking` block), prepend a synthetic empty thinking block and - * drop any non-compliant `thinking` block already present. Required by - * DeepSeek's anthropic-compatible API when thinking mode is enabled. - * 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 + * On every assistant turn, normalize any `thinking` block that lacks the + * required `signature` field by filling it with `signature: ''` (preserving + * the original `thinking` text). On assistant turns containing `tool_use` + * with no thinking block at all, prepend a synthetic empty thinking block. + * Required by DeepSeek's anthropic-compatible API when thinking mode is + * enabled. 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 */ ensureThinkingOnToolUseTurns?: boolean; /** @@ -625,9 +625,11 @@ export class AnthropicContentConverter { * Edge case: a `thinking` block round-tripped through Gemini Part format * may come back without its `signature` field (e.g. when the upstream * block was `redacted_thinking`, whose `data` field doesn't survive the - * `{ thought: true }` representation). Such blocks are not spec-compliant, - * so we drop them here and let the synthetic injection take over rather - * than treat them as already-satisfying the requirement. + * `{ thought: true }` representation, or when the conversation history + * was recorded by a non-Anthropic generator that only sets + * `thought: true`). Such blocks are not spec-compliant, so we normalize + * them in place by filling in `signature: ''` — preserving the original + * thinking text rather than discarding it. * https://github.com/QwenLM/qwen-code/issues/3786 */ private applyEmptyThinkingToToolUseTurns( From 4b81d674dfc116b94b1bb023a9078776266aed2b Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 22:56:27 +0800 Subject: [PATCH 13/16] refactor(core): split DeepSeek thinking option in two + add header coexistence test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 12 (copilot-pull-request-reviewer × 2): 1. (H6ws) The single `ensureThinkingOnToolUseTurns` option was misleadingly narrow: the implementation also rewrote non-tool-use turns by normalizing malformed thinking blocks. Future callers could enable it expecting only the tool-use behavior. Split into two precisely-named options: - normalizeAssistantThinkingSignature: fill missing `signature` on every assistant `thinking` block (cross-provider history compat). - injectThinkingOnToolUseTurns: prepend synthetic empty thinking on tool_use turns missing one (issue #3786 trigger). The generator wires both together for DeepSeek when thinking mode is on; either can be used independently if a future caller needs only one pass. 2. (H6w4) Add a test asserting that the per-request `headers` path coexists correctly with `customHeaders`: User-Agent and unrelated customHeaders entries stay in `defaultHeaders` while only the computed `anthropic-beta` rides on the per-request path. Defends against a future regression where header config might be routed through a code path that wipes the constructor defaults. 72 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 44 ++++++ .../anthropicContentGenerator.ts | 11 +- .../converter.test.ts | 11 +- .../anthropicContentGenerator/converter.ts | 125 ++++++++++-------- 4 files changed, 133 insertions(+), 58 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 1a3f60386c1..af95c5acfb5 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -272,6 +272,50 @@ describe('AnthropicContentGenerator', () => { 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 diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 4ee9f29dc60..e08c8809370 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -259,12 +259,19 @@ export class AnthropicContentGenerator implements ContentGenerator { // against a session whose history already contains // `thought: true` parts (suggestionGenerator / // ArenaManager / forkedAgent). - const ensureThinkingOnToolUseTurns = isDeepSeek && !!thinking; + const deepseekThinkingOn = isDeepSeek && !!thinking; const stripAssistantThinking = isDeepSeek && !thinking; const { system, messages } = this.converter.convertGeminiRequestToAnthropic( request, - { ensureThinkingOnToolUseTurns, stripAssistantThinking }, + { + // 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 diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index da32afa3a7c..7013381d463 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -653,8 +653,13 @@ describe('AnthropicContentConverter', () => { // 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('ensureThinkingOnToolUseTurns', () => { - const enableThinking = { ensureThinkingOnToolUseTurns: true }; + describe('thinking-mode injection + normalization (DeepSeek thinking on)', () => { + // 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 @@ -911,7 +916,7 @@ describe('AnthropicContentConverter', () => { }, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - (converter as any).applyEmptyThinkingToToolUseTurns(messages); + (converter as any).injectEmptyThinkingOnToolUseTurns(messages); expect(messages[0].content).toEqual([ { type: 'redacted_thinking', data: 'opaque' }, diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index e6a5aa467d1..937202d0e19 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -33,16 +33,30 @@ type AnthropicContentBlockParam = Anthropic.ContentBlockParam; export interface ConvertGeminiRequestToAnthropicOptions { /** - * On every assistant turn, normalize any `thinking` block that lacks the - * required `signature` field by filling it with `signature: ''` (preserving - * the original `thinking` text). On assistant turns containing `tool_use` - * with no thinking block at all, prepend a synthetic empty thinking block. - * Required by DeepSeek's anthropic-compatible API when thinking mode is - * enabled. Must be gated on the same per-request condition that emits the + * 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 signature-less + * blocks are seen as missing (and therefore replaced by the synthetic with + * a valid signature) rather than as already-satisfying. + * + * 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 */ - ensureThinkingOnToolUseTurns?: boolean; + injectThinkingOnToolUseTurns?: boolean; /** * Strip thinking and redacted_thinking blocks from assistant messages. * Used to keep DeepSeek requests consistent when thinking mode is off but @@ -85,8 +99,13 @@ export class AnthropicContentConverter { if (options.stripAssistantThinking) { this.stripThinkingFromAssistantMessages(messages); } - if (options.ensureThinkingOnToolUseTurns) { - this.applyEmptyThinkingToToolUseTurns(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) @@ -609,53 +628,26 @@ export class AnthropicContentConverter { } /** - * 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 inject an empty thinking - * block when one is missing. + * Fill in `signature: ''` on every assistant `thinking` block that lacks + * a `signature` field. Preserves the original thinking text. Common cases: * - * 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. + * - 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. * - * Edge case: a `thinking` block round-tripped through Gemini Part format - * may come back without its `signature` field (e.g. when the upstream - * block was `redacted_thinking`, whose `data` field doesn't survive the - * `{ thought: true }` representation, or when the conversation history - * was recorded by a non-Anthropic generator that only sets - * `thought: true`). Such blocks are not spec-compliant, so we normalize - * them in place by filling in `signature: ''` — preserving the original - * thinking text rather than discarding it. - * https://github.com/QwenLM/qwen-code/issues/3786 + * DeepSeek empirically accepts empty signatures, so this keeps the wire + * shape spec-compliant without discarding any preserved thinking text. */ - private applyEmptyThinkingToToolUseTurns( + private fillMissingThinkingSignatures( messages: AnthropicMessageParam[], ): void { for (const message of messages) { if (message.role !== 'assistant') continue; + if (!Array.isArray(message.content)) continue; - const blocks: AnthropicContentBlockParam[] = - typeof message.content === 'string' - ? [{ type: 'text', text: message.content }] - : Array.isArray(message.content) - ? message.content - : []; - - // Step 1: Normalize non-compliant `thinking` blocks (missing the - // required `signature` field) by setting `signature: ''` in place. This - // shape commonly arrives from other generators (OpenAI/Gemini/agent- - // runtime emit `{ thought: true }` parts with no signature) when users - // switch providers mid-session, or from `redacted_thinking` blocks that - // lost their `data` field through the Gemini-Part round trip. Adding an - // empty signature preserves the original thinking text instead of - // dropping the block — DeepSeek currently accepts empty signatures, so - // this keeps the wire shape spec-compliant without losing history. let modified = false; - const normalizedBlocks = blocks.map((block) => { + const normalized = message.content.map((block) => { const b = block as { type?: string; signature?: unknown }; if (b.type === 'thinking' && typeof b.signature !== 'string') { modified = true; @@ -667,17 +659,44 @@ export class AnthropicContentConverter { return block; }); if (modified) { - message.content = normalizedBlocks; + 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` so that + * round-tripped non-compliant `thinking` blocks aren't mistaken for + * 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; - // Step 2: On tool-use turns still lacking any thinking block, prepend - // a synthetic empty one (the issue #3786 trigger). - const hasToolUse = normalizedBlocks.some( + const hasToolUse = blocks.some( (block) => (block as { type?: string }).type === 'tool_use', ); if (!hasToolUse) continue; - const hasThinking = normalizedBlocks.some((block) => { + const hasThinking = blocks.some((block) => { const t = (block as { type?: string }).type; return t === 'thinking' || t === 'redacted_thinking'; }); @@ -693,7 +712,7 @@ export class AnthropicContentConverter { thinking: '', signature: '', } as unknown as AnthropicContentBlockParam; - message.content = [emptyThinking, ...normalizedBlocks]; + message.content = [emptyThinking, ...blocks]; } } From 0d8b5de18a13a629878f7ad95f35d4623a9e5502 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 23:02:05 +0800 Subject: [PATCH 14/16] fix(core): case-insensitive customHeaders[anthropic-beta] merge Address yiliang114 review feedback (#3788). HTTP header names are case-insensitive by spec, and the Anthropic SDK lower-cases them during merge. Previously buildPerRequestHeaders only read the lower-case `anthropic-beta` key from customHeaders, so a user-configured `Anthropic-Beta` or `ANTHROPIC-BETA` would be silently overwritten by the per-request computed value. Replace the direct dict lookup with collectCustomBetaFlags() which walks all customHeaders entries and matches the key case-insensitively. Multiple matching entries (unlikely but possible) are concatenated; the existing dedupe pass handles any duplicates. Add a regression test for both `Anthropic-Beta` and `ANTHROPIC-BETA` key shapes. 73 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 26 ++++++++++++++ .../anthropicContentGenerator.ts | 35 ++++++++++++++----- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index af95c5acfb5..83fc1a8e9aa 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -246,6 +246,32 @@ describe('AnthropicContentGenerator', () => { expect(headers['anthropic-beta']).toBe('experimental-x'); }); + 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, diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index e08c8809370..1e3b498b92a 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -208,20 +208,17 @@ export class AnthropicContentGenerator implements ContentGenerator { * * 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. + * 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 userBeta = - this.contentGeneratorConfig.customHeaders?.['anthropic-beta']; - if (typeof userBeta === 'string' && userBeta) { - for (const flag of userBeta.split(',')) { - const trimmed = flag.trim(); - if (trimmed) betas.push(trimmed); - } + for (const flag of this.collectCustomBetaFlags()) { + betas.push(flag); } if (anthropicRequest.thinking) { @@ -236,6 +233,28 @@ export class AnthropicContentGenerator implements ContentGenerator { return { 'anthropic-beta': unique.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 flags; + } + private async buildRequest( request: GenerateContentParameters, ): Promise { From f139e1a2c394ca480cb248fcd84d80a271aeb0b4 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 23:14:58 +0800 Subject: [PATCH 15/16] docs(core): align thinking-injection docs with normalize-in-place semantics + redacted_thinking strip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 14 (copilot-pull-request-reviewer × 4): 1. (IAl4) PR description still described "dropped here so synthetic injection takes over" but the implementation now normalizes signature-less thinking blocks in place (preserving text). PR description rewritten to describe the two-pass model: normalize-in-place + injection-when-truly-missing. 2. (IAl7) `injectThinkingOnToolUseTurns` option docstring claimed signature-less blocks would be "seen as missing" so the synthetic replaces them. Updated to describe the actual flow: the normalization pass runs first, blocks become compliant in place, the injector then sees them as already-satisfying and prepends nothing. Helper JSDoc on `injectEmptyThinkingOnToolUseTurns` fixed the same way. 3. (IAl8) Strip-path coverage missed `redacted_thinking` blocks. Added regression test that verifies both thinking and redacted_thinking blocks are removed when `stripAssistantThinking` is set. 4. (IAl-) Renamed the converter test suite from "thinking-mode injection + normalization (DeepSeek thinking on)" to "DeepSeek thinking-mode normalization, injection, and stripping" so the title accurately covers all behavior the block exercises (including `stripAssistantThinking` cases later in the same describe). 74 tests pass; lint + typecheck clean. --- .../converter.test.ts | 22 ++++++++++++++++++- .../anthropicContentGenerator/converter.ts | 14 +++++++----- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 7013381d463..c7d4158d9ee 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -653,7 +653,7 @@ describe('AnthropicContentConverter', () => { // 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('thinking-mode injection + normalization (DeepSeek thinking on)', () => { + 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 = { @@ -900,6 +900,26 @@ describe('AnthropicContentConverter', () => { }); }); + 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 diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 937202d0e19..81f6e908d4c 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -48,9 +48,12 @@ export interface ConvertGeminiRequestToAnthropicOptions { * 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 signature-less - * blocks are seen as missing (and therefore replaced by the synthetic with - * a valid signature) rather than as already-satisfying. + * 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 @@ -678,8 +681,9 @@ export class AnthropicContentConverter { * to avoid bloating replay history with synthetic blocks for turns the * API already accepts. * - * Should be paired with `fillMissingThinkingSignatures` so that - * round-tripped non-compliant `thinking` blocks aren't mistaken for + * 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( From f17fcfc868b3a501e2d64f533854bf9e2f5024e4 Mon Sep 17 00:00:00 2001 From: wenshao Date: Sat, 2 May 2026 23:32:23 +0800 Subject: [PATCH 16/16] fix(core): exclude anthropic-beta variants from defaultHeaders to avoid wire duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review round 15 (copilot-pull-request-reviewer #1). `buildHeaders()` previously spread the entire `customHeaders` map into the SDK's `defaultHeaders`. After moving anthropic-beta computation to the per-request path, a user-configured mixed-case `Anthropic-Beta` key would survive in defaultHeaders verbatim, while the per-request override added a lowercase `anthropic-beta`. The wire then carried two physical headers for the same logical name — SDK behavior on duplicate headers with different casings is undefined. `buildPerRequestHeaders()` already merges those user flags case-insensitively (commit 0d8b5de), so dropping the entry from defaultHeaders is the right boundary: the per-request path owns the header end-to-end. Other customHeaders entries continue to pass through. Add a regression test asserting no `Anthropic-Beta` (any casing) lands in defaultHeaders while unrelated customHeaders are kept. 75 tests pass; lint + typecheck clean. --- .../anthropicContentGenerator.test.ts | 28 +++++++++++++++++++ .../anthropicContentGenerator.ts | 13 +++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 83fc1a8e9aa..78f333d5763 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -246,6 +246,34 @@ describe('AnthropicContentGenerator', () => { expect(headers['anthropic-beta']).toBe('experimental-x'); }); + 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 diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 1e3b498b92a..d7d4b79bf0b 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -191,13 +191,22 @@ export class AnthropicContentGenerator implements ContentGenerator { // 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. + // 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 }; - return customHeaders ? { ...headers, ...customHeaders } : headers; + if (customHeaders) { + for (const [key, value] of Object.entries(customHeaders)) { + if (key.toLowerCase() === 'anthropic-beta') continue; + headers[key] = value; + } + } + return headers; } /**