Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ddaedeb
fix(core): inject thinking blocks for DeepSeek anthropic-compatible p…
wenshao May 2, 2026
2212b18
fix(core): gate DeepSeek thinking-block injection on thinking mode
wenshao May 2, 2026
8721b41
fix(core): narrow DeepSeek thinking injection to tool_use turns + sub…
wenshao May 2, 2026
487066a
docs(core): align DeepSeek thinking-injection comments with narrowed …
wenshao May 2, 2026
ae1aae5
fix(core): per-request DeepSeek detection + strip thinking when off
wenshao May 2, 2026
ec30c1d
fix(core): preserve thinking-only assistant turns instead of emitting…
wenshao May 2, 2026
51b59d6
fix(core): validate thinking-block signature, rename option, gate out…
wenshao May 2, 2026
106a27a
fix(core): clean up non-compliant thinking blocks on plain-text turns…
wenshao May 2, 2026
8f39325
test(core): add explicit redacted_thinking injection-path coverage
wenshao May 2, 2026
45d394b
fix(core): per-request anthropic-beta + normalize thinking-only turns
wenshao May 2, 2026
07c14ee
fix(core): preserve thinking text by normalizing in place + merge use…
wenshao May 2, 2026
fdf2257
docs(core): align thinking-injection comments with normalize semantic…
wenshao May 2, 2026
4b81d67
refactor(core): split DeepSeek thinking option in two + add header co…
wenshao May 2, 2026
0d8b5de
fix(core): case-insensitive customHeaders[anthropic-beta] merge
wenshao May 2, 2026
f139e1a
docs(core): align thinking-injection docs with normalize-in-place sem…
wenshao May 2, 2026
f17fcfc
fix(core): exclude anthropic-beta variants from defaultHeaders to avo…
wenshao May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,305 @@ 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.
Comment thread
wenshao marked this conversation as resolved.
Outdated
describe('DeepSeek anthropic-compatible provider', () => {
Comment thread
wenshao marked this conversation as resolved.
// Helper: tool-use assistant turn missing thinking — the only shape that
// actually triggers DeepSeek's HTTP 400.
const toolUseConversation = [
{ role: 'user' as const, parts: [{ text: 'Run tool' }] },
{
role: 'model' as const,
parts: [{ functionCall: { id: 't1', name: 'tool', args: {} } }],
},
{
role: 'user' as const,
parts: [
{
functionResponse: {
id: 't1',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
];

it('injects empty thinking blocks on tool-use assistant turns when baseUrl is api.deepseek.com', async () => {
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'deepseek-v4-pro',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'deepseek-v4-pro',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.com/anthropic',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: toolUseConversation,
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

expect(messages[1]).toEqual({
role: 'assistant',
content: [
{ type: 'thinking', thinking: '', signature: '' },
{ type: 'tool_use', id: 't1', name: 'tool', input: {} },
],
});
});

it('detects deepseek by model name even when baseUrl is different', async () => {
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'deepseek-v4-pro',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'deepseek-v4-pro',
apiKey: 'test-key',
baseUrl: 'https://my-proxy.example.com/anthropic',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: toolUseConversation,
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

expect(messages[1]).toEqual({
role: 'assistant',
content: [
{ type: 'thinking', thinking: '', signature: '' },
{ type: 'tool_use', id: 't1', name: 'tool', input: {} },
],
});
});

it('matches regional DeepSeek subdomains (e.g. us.api.deepseek.com)', async () => {
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'unrelated-model',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'unrelated-model',
apiKey: 'test-key',
baseUrl: 'https://us.api.deepseek.com/anthropic',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: toolUseConversation,
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

expect(messages[1]).toEqual({
role: 'assistant',
content: [
{ type: 'thinking', thinking: '', signature: '' },
{ type: 'tool_use', id: 't1', name: 'tool', input: {} },
],
});
});

const toolOnlyAssistant = {
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'tool', input: {} }],
};

it('does not inject empty thinking blocks for non-deepseek providers', async () => {
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'claude-test',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: toolUseConversation,
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

// Non-deepseek provider: even tool_use turns get no injection.
expect(messages[1]).toEqual(toolOnlyAssistant);
});

it('does not match spoofed hostnames like api.deepseek.com.evil.com', async () => {
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'claude-test',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.com.evil.com/anthropic',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: toolUseConversation,
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

// Hostname differs from api.deepseek.com — must not inject even on
// tool_use turns.
expect(messages[1]).toEqual(toolOnlyAssistant);
});

it('does not inject when reasoning is explicitly disabled', async () => {
// Even on a confirmed-DeepSeek provider with a tool-use turn, if the
// request omits the top-level `thinking` parameter (because
// reasoning=false), shipping synthetic thinking blocks would be a
// protocol violation.
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'deepseek-v4-pro',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'deepseek-v4-pro',
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.com/anthropic',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
reasoning: false,
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: toolUseConversation,
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

expect(anthropicRequest).toEqual(
expect.not.objectContaining({ thinking: expect.anything() }),
);
expect(messages[1]).toEqual(toolOnlyAssistant);
});

it('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: toolUseConversation,
config: { thinkingConfig: { includeThoughts: false } },
} as unknown as GenerateContentParameters);

const [anthropicRequest] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const messages = (anthropicRequest as { messages: unknown[] }).messages;

expect(anthropicRequest).toEqual(
expect.not.objectContaining({ thinking: expect.anything() }),
);
expect(messages[1]).toEqual(toolOnlyAssistant);
});
});

describe('countTokens', () => {
it('counts tokens using the request tokenizer', async () => {
const { AnthropicContentGenerator } = await importGenerator();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@ 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
Comment thread
wenshao marked this conversation as resolved.
Outdated
*/
function isDeepSeekAnthropicProvider(
contentGeneratorConfig: ContentGeneratorConfig,
): boolean {
const baseUrl = contentGeneratorConfig.baseUrl ?? '';
if (baseUrl) {
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
Comment thread
wenshao marked this conversation as resolved.
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');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] model.includes('deepseek') 匹配范围过宽——任何模型名包含 "deepseek" 都会触发 thinking 块注入,包括指向非 DeepSeek 后端的模型(如 my-deepseek-clone),可能导致非目标 API 返回 400。反之,如果 DeepSeek 发布不含 "deepseek" 前缀的模型,检测会静默失败。

建议使用更精确的匹配(如 model.startsWith('deepseek'))、维护已知前缀集合,或提供显式配置覆盖项(如 forceAssistantThinking)。

— deepseek-v4-pro via Qwen Code /review

Comment thread
wenshao marked this conversation as resolved.
Comment on lines +46 to +68
}
Comment thread
wenshao marked this conversation as resolved.

type StreamingBlockState = {
type: string;
id?: string;
Expand All @@ -57,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,
Expand Down Expand Up @@ -85,6 +113,9 @@ export class AnthropicContentGenerator implements ContentGenerator {
contentGeneratorConfig.schemaCompliance,
contentGeneratorConfig.enableCacheControl,
);
this.isDeepSeekProvider = isDeepSeekAnthropicProvider(
contentGeneratorConfig,
);
Comment thread
wenshao marked this conversation as resolved.
Outdated
}

async generateContent(
Expand Down Expand Up @@ -186,17 +217,27 @@ export class AnthropicContentGenerator implements ContentGenerator {
private async buildRequest(
request: GenerateContentParameters,
): Promise<MessageCreateParamsWithThinking> {
const { system, messages } =
this.converter.convertGeminiRequestToAnthropic(request);
const sampling = this.buildSamplingParameters(request);
const thinking = this.buildThinkingConfig(request);
const outputConfig = this.buildOutputConfig();
Comment thread
wenshao marked this conversation as resolved.
Outdated

// 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;
Comment thread
wenshao marked this conversation as resolved.
Outdated

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,
Expand Down
Loading
Loading