diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 3b3c54533..97ee91598 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -104,7 +104,7 @@ Settings are organized into categories. All settings should be placed within the | `model.name` | string | The Qwen model to use for conversations. | `undefined` | | `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | | `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` | -| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | +| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `disableCacheControl`, and `customHeaders` (custom HTTP headers for API requests), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | | `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | | `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` | @@ -114,12 +114,16 @@ Settings are organized into categories. All settings should be placed within the **Example model.generationConfig:** -``` +```json { "model": { "generationConfig": { "timeout": 60000, "disableCacheControl": false, + "customHeaders": { + "X-Request-ID": "req-123", + "X-User-ID": "user-456" + }, "samplingParams": { "temperature": 0.2, "top_p": 0.8, @@ -130,6 +134,8 @@ Settings are organized into categories. All settings should be placed within the } ``` +The `customHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels. + **model.openAILoggingDir examples:** - `"~/qwen-logs"` - Logs to `~/qwen-logs` directory @@ -154,6 +160,10 @@ Use `modelProviders` to declare curated model lists per auth type that the `/mod "generationConfig": { "timeout": 60000, "maxRetries": 3, + "customHeaders": { + "X-Model-Version": "v1.0", + "X-Request-Priority": "high" + }, "samplingParams": { "temperature": 0.2 } } } @@ -215,7 +225,7 @@ Per-field precedence for `generationConfig`: 3. `settings.model.generationConfig` 4. Content-generator defaults (`getDefaultGenerationConfig` for OpenAI, `getParameterValue` for Gemini, etc.) -`samplingParams` is treated atomically; provider values replace the entire object. Defaults from the content generator apply last so each provider retains its tuned baseline. +`samplingParams` and `customHeaders` are both treated atomically; provider values replace the entire object. If `modelProviders[].generationConfig` defines these fields, they are used directly; otherwise, values from `model.generationConfig` are used. No merging occurs between provider and global configuration levels. Defaults from the content generator apply last so each provider retains its tuned baseline. ##### Selection persistence and recommendations diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 9a0ad8978..4a025ed1f 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -10,6 +10,7 @@ import { type ContentGeneratorConfigSources, resolveModelConfig, type ModelConfigSourcesInput, + type ProviderModelConfig, } from '@qwen-code/qwen-code-core'; import type { Settings } from '../config/settings.js'; @@ -81,6 +82,21 @@ export function resolveCliGenerationConfig( const authType = selectedAuthType; + // Find modelProvider from settings.modelProviders based on authType and model + let modelProvider: ProviderModelConfig | undefined; + if (authType && settings.modelProviders) { + const providers = settings.modelProviders[authType]; + if (providers && Array.isArray(providers)) { + // Try to find by requested model (from CLI or settings) + const requestedModel = argv.model || settings.model?.name; + if (requestedModel) { + modelProvider = providers.find((p) => p.id === requestedModel) as + | ProviderModelConfig + | undefined; + } + } + } + const configSources: ModelConfigSourcesInput = { authType, cli: { @@ -96,6 +112,7 @@ export function resolveCliGenerationConfig( | Partial | undefined, }, + modelProvider, env, }; 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 228f93853..281c5d9ae 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -141,6 +141,7 @@ export class AnthropicContentGenerator implements ContentGenerator { private buildHeaders(): Record { 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; @@ -163,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/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index fc36fda3c..476776cb6 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -91,6 +91,8 @@ export type ContentGeneratorConfig = { userAgent?: string; // Schema compliance mode for tool definitions schemaCompliance?: 'auto' | 'openapi_30'; + // Custom HTTP headers to be sent with requests + customHeaders?: Record; }; // Keep the public ContentGeneratorConfigSources API, but reuse the generic 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 0008b8eb5..33819cd7f 100644 --- a/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts +++ b/packages/core/src/core/geminiContentGenerator/geminiContentGenerator.ts @@ -35,7 +35,26 @@ export class GeminiContentGenerator implements ContentGenerator { }, contentGeneratorConfig?: ContentGeneratorConfig, ) { - this.googleGenAI = new GoogleGenAI(options); + const customHeaders = contentGeneratorConfig?.customHeaders; + const finalOptions = customHeaders + ? (() => { + const baseHttpOptions = options.httpOptions; + const baseHeaders = baseHttpOptions?.headers ?? {}; + + return { + ...options, + httpOptions: { + ...(baseHttpOptions ?? {}), + headers: { + ...baseHeaders, + ...customHeaders, + }, + }, + }; + })() + : options; + + this.googleGenAI = new GoogleGenAI(finalOptions); this.contentGeneratorConfig = contentGeneratorConfig; } 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 5658eee47..45b0568a0 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -47,13 +47,17 @@ export class DashScopeOpenAICompatibleProvider buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - const { authType } = this.contentGeneratorConfig; - return { + const { authType, customHeaders } = this.contentGeneratorConfig; + 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 521a6768c..6f449badd 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -25,9 +25,14 @@ export class DefaultOpenAICompatibleProvider buildHeaders(): Record { const version = this.cliConfig.getCliVersion() || 'unknown'; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - return { + const { customHeaders } = this.contentGeneratorConfig; + const defaultHeaders = { 'User-Agent': userAgent, }; + + return customHeaders + ? { ...defaultHeaders, ...customHeaders } + : defaultHeaders; } buildClient(): OpenAI { diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9dd69620c..fcb1be985 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -25,6 +25,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [ 'disableCacheControl', 'schemaCompliance', 'reasoning', + 'customHeaders', ] as const satisfies ReadonlyArray; /** diff --git a/packages/core/src/models/modelConfigResolver.test.ts b/packages/core/src/models/modelConfigResolver.test.ts index b7aa8c29b..a69ca678e 100644 --- a/packages/core/src/models/modelConfigResolver.test.ts +++ b/packages/core/src/models/modelConfigResolver.test.ts @@ -112,11 +112,9 @@ describe('modelConfigResolver', () => { modelProvider: { id: 'provider-model', name: 'Provider Model', - authType: AuthType.USE_OPENAI, envKey: 'MY_CUSTOM_KEY', baseUrl: 'https://provider.example.com', generationConfig: {}, - capabilities: {}, }, }); @@ -249,13 +247,11 @@ describe('modelConfigResolver', () => { modelProvider: { id: 'model', name: 'Model', - authType: AuthType.USE_OPENAI, envKey: 'MY_KEY', baseUrl: 'https://api.example.com', generationConfig: { timeout: 60000, }, - capabilities: {}, }, }); diff --git a/packages/core/src/models/modelConfigResolver.ts b/packages/core/src/models/modelConfigResolver.ts index a6c734f72..1afad58eb 100644 --- a/packages/core/src/models/modelConfigResolver.ts +++ b/packages/core/src/models/modelConfigResolver.ts @@ -41,7 +41,7 @@ import { QWEN_OAUTH_ALLOWED_MODELS, MODEL_GENERATION_CONFIG_FIELDS, } from './constants.js'; -import type { ResolvedModelConfig } from './types.js'; +import type { ModelConfig as ModelProviderConfig } from './types.js'; export { validateModelConfig, type ModelConfigValidationResult, @@ -86,8 +86,8 @@ export interface ModelConfigSourcesInput { /** Environment variables (injected for testability) */ env: Record; - /** Resolved model from ModelProviders (explicit selection, highest priority) */ - modelProvider?: ResolvedModelConfig; + /** Model from ModelProviders (explicit selection, highest priority) */ + modelProvider?: ModelProviderConfig; /** Proxy URL (computed from Config) */ proxy?: string; @@ -277,7 +277,7 @@ function resolveQwenOAuthConfig( input: ModelConfigSourcesInput, warnings: string[], ): ModelConfigResolutionResult { - const { cli, settings, proxy } = input; + const { cli, settings, proxy, modelProvider } = input; const sources: ConfigSources = {}; // Qwen OAuth only allows specific models @@ -311,10 +311,10 @@ function resolveQwenOAuthConfig( sources['proxy'] = computedSource('Config.getProxy()'); } - // Resolve generation config from settings + // Resolve generation config from settings and modelProvider const generationConfig = resolveGenerationConfig( settings?.generationConfig, - undefined, + modelProvider?.generationConfig, AuthType.QWEN_OAUTH, resolvedModel, sources, @@ -344,7 +344,7 @@ function resolveGenerationConfig( const result: Partial = {}; for (const field of MODEL_GENERATION_CONFIG_FIELDS) { - // ModelProvider config takes priority + // ModelProvider config takes priority over settings config if (authType && modelProviderConfig && field in modelProviderConfig) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (result as any)[field] = modelProviderConfig[field]; diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index b5ce56efa..c8360e158 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -31,6 +31,7 @@ export type ModelGenerationConfig = Pick< | 'disableCacheControl' | 'schemaCompliance' | 'reasoning' + | 'customHeaders' >; /**