diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 483edac1a..cef3d0242 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -10,6 +10,7 @@ import type { GenerateContentParameters, } from '@google/genai'; import { FinishReason, GenerateContentResponse } from '@google/genai'; +import type { ContentGeneratorConfig } from '../contentGenerator.js'; // Mock the request tokenizer module BEFORE importing the class that uses it. const mockTokenizer = { @@ -127,6 +128,32 @@ describe('AnthropicContentGenerator', () => { ); }); + it('merges customHeaders into defaultHeaders (does not replace defaults)', 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' }, + customHeaders: { + 'X-Custom': '1', + }, + } as unknown as Record as ContentGeneratorConfig, + mockConfig, + ); + + const headers = (anthropicState.constructorOptions?.['defaultHeaders'] || + {}) as Record; + expect(headers['User-Agent']).toContain('QwenCode/1.2.3'); + expect(headers['anthropic-beta']).toContain('effort-2025-11-24'); + expect(headers['X-Custom']).toBe('1'); + }); + it('adds the effort beta header when reasoning.effort is set', async () => { const { AnthropicContentGenerator } = await importGenerator(); void new AnthropicContentGenerator( diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index a5d714f3a..281c5d9ae 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -143,11 +143,6 @@ export class AnthropicContentGenerator implements ContentGenerator { const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { customHeaders } = this.contentGeneratorConfig; - // If customHeaders is provided, use it directly; otherwise build default headers - if (customHeaders) { - return customHeaders as Record; - } - const betas: string[] = []; const reasoning = this.contentGeneratorConfig.reasoning; @@ -169,7 +164,7 @@ export class AnthropicContentGenerator implements ContentGenerator { headers['anthropic-beta'] = betas.join(','); } - return headers; + return customHeaders ? { ...headers, ...customHeaders } : headers; } private async buildRequest( diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts index 82f3b186e..bdf9bfb99 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.test.ts @@ -39,6 +39,41 @@ describe('GeminiContentGenerator', () => { mockGoogleGenAI = vi.mocked(GoogleGenAI).mock.results[0].value; }); + it('should merge customHeaders into existing httpOptions.headers', async () => { + vi.mocked(GoogleGenAI).mockClear(); + + void new GeminiContentGenerator( + { + apiKey: 'test-api-key', + httpOptions: { + headers: { + 'X-Base': 'base', + 'X-Override': 'base', + }, + }, + }, + { + customHeaders: { + 'X-Custom': 'custom', + 'X-Override': 'custom', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + expect(vi.mocked(GoogleGenAI)).toHaveBeenCalledTimes(1); + expect(vi.mocked(GoogleGenAI)).toHaveBeenCalledWith({ + apiKey: 'test-api-key', + httpOptions: { + headers: { + 'X-Base': 'base', + 'X-Custom': 'custom', + 'X-Override': 'custom', + }, + }, + }); + }); + it('should call generateContent on the underlying model', async () => { const request = { model: 'gemini-1.5-flash', contents: [] }; const expectedResponse = { responseId: 'test-id' }; diff --git a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts index 530c456a4..33819cd7f 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -35,15 +35,23 @@ export class GeminiContentGenerator implements ContentGenerator { }, contentGeneratorConfig?: ContentGeneratorConfig, ) { - // If customHeaders is provided, use it directly; otherwise use options.httpOptions.headers const customHeaders = contentGeneratorConfig?.customHeaders; const finalOptions = customHeaders - ? { - ...options, - httpOptions: { - headers: customHeaders as Record, - }, - } + ? (() => { + const baseHttpOptions = options.httpOptions; + const baseHeaders = baseHttpOptions?.headers ?? {}; + + return { + ...options, + httpOptions: { + ...(baseHttpOptions ?? {}), + headers: { + ...baseHeaders, + ...customHeaders, + }, + }, + }; + })() : options; this.googleGenAI = new GoogleGenAI(finalOptions); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 1dabaf8ab..e7c951fd9 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -142,6 +142,27 @@ describe('DashScopeOpenAICompatibleProvider', () => { }); }); + it('should merge custom headers with DashScope defaults', () => { + const providerWithCustomHeaders = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + customHeaders: { + 'X-Custom': '1', + 'X-DashScope-CacheControl': 'disable', + }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + + const headers = providerWithCustomHeaders.buildHeaders(); + + expect(headers['User-Agent']).toContain('QwenCode/1.0.0'); + expect(headers['X-DashScope-UserAgent']).toContain('QwenCode/1.0.0'); + expect(headers['X-DashScope-AuthType']).toBe(AuthType.QWEN_OAUTH); + expect(headers['X-Custom']).toBe('1'); + expect(headers['X-DashScope-CacheControl']).toBe('disable'); + }); + it('should handle unknown CLI version', () => { ( mockCliConfig.getCliVersion as MockedFunction< diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 176ce6b6d..45b0568a0 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -48,18 +48,16 @@ export class DashScopeOpenAICompatibleProvider const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { authType, customHeaders } = this.contentGeneratorConfig; - - // If customHeaders is provided, use it directly; otherwise use default headers - if (customHeaders) { - return customHeaders; - } - - return { + const defaultHeaders = { 'User-Agent': userAgent, 'X-DashScope-CacheControl': 'enable', 'X-DashScope-UserAgent': userAgent, 'X-DashScope-AuthType': authType, }; + + return customHeaders + ? { ...defaultHeaders, ...customHeaders } + : defaultHeaders; } buildClient(): OpenAI { diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index 3855d2ccb..23a6887dc 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -73,6 +73,26 @@ describe('DefaultOpenAICompatibleProvider', () => { }); }); + it('should merge customHeaders with defaults (and allow overrides)', () => { + const providerWithCustomHeaders = new DefaultOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + customHeaders: { + 'X-Custom': '1', + 'User-Agent': 'custom-agent', + }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + + const headers = providerWithCustomHeaders.buildHeaders(); + + expect(headers).toEqual({ + 'User-Agent': 'custom-agent', + 'X-Custom': '1', + }); + }); + it('should handle unknown CLI version', () => { ( mockCliConfig.getCliVersion as MockedFunction< diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 4cda72feb..6f449badd 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -26,15 +26,13 @@ export class DefaultOpenAICompatibleProvider const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const { customHeaders } = this.contentGeneratorConfig; - - // If customHeaders is provided, use it directly; otherwise use default headers - if (customHeaders) { - return customHeaders; - } - - return { + const defaultHeaders = { 'User-Agent': userAgent, }; + + return customHeaders + ? { ...defaultHeaders, ...customHeaders } + : defaultHeaders; } buildClient(): OpenAI {