Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
48 changes: 47 additions & 1 deletion docs/users/configuration/model-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,12 +568,58 @@ The configuration resolution follows a strict layering model with one crucial ru
| 3 | `settings.model.generationConfig` | Only used for **Runtime Models** (when no provider model is selected) |
| 4 | Content-generator defaults | Provider-specific defaults (e.g., OpenAI vs Gemini) - only for Runtime Models |

### Dynamic values in `customHeaders`

A `customHeaders` value may contain the placeholder `${session_id}`, which is
expanded per request with the current Qwen Code session ID. Use it for gateways
that require a stable per-conversation identifier — OpenCode Go, for example,
rejects requests without `x-opencode-session`:

```json
{
"generationConfig": {
"customHeaders": {
"x-opencode-session": "${session_id}"
}
}
}
```

Because the value is resolved per request rather than baked into the SDK client,
`/new` and `/resume` rotate it without a restart.

⚠️ **Two steps are required.** The provider entry above is only half of it — a
placeholder is inert until you also switch on
[`outboundCorrelation.allowDynamicHeaderValues`](settings.md#outboundcorrelation):

```json
{
"outboundCorrelation": {
"allowDynamicHeaderValues": true
}
}
```

Until you do, a value containing a placeholder is **dropped** rather than sent,
and Qwen Code prints a warning at startup naming the header and this setting.
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
The header is never sent with a literal `${session_id}` in it.

The switch is global because it is a consent decision, separate from _where_ the
value goes: an expanded value carries live session state to whoever receives it,
and the switch also stops a provider preset or an extension from quietly turning
a `customHeaders` entry it ships into an identity header.

**Privacy note:** the session ID is a stable identifier for the life of a
conversation, so any host you send it to can group every request of that
conversation. Which hosts those are is decided by which provider entries carry
the header — there is no separate host list to keep in sync with your `baseUrl`.

### Atomic field treatment

The following fields are treated as atomic objects - provider values completely replace the entire object, no merging occurs:

- `samplingParams` - Temperature, top_p, max_tokens, etc.
- `customHeaders` - Custom HTTP headers
- `customHeaders` - Custom HTTP headers (may contain `${session_id}`; see [Dynamic values](#dynamic-values-in-customheaders))
- `extra_body` - Extra request body parameters

### Example
Expand Down
32 changes: 32 additions & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,38 @@ Configures logging and metrics collection for Qwen Code. For more information, s
| `telemetry.sensitiveSpanAttributeMaxLength` | number | Maximum JavaScript string length for each sensitive native OTel span attribute content payload. Must be between `1` and `104857600` (100 MiB). Set lower if your collector or backend rejects large attributes. | `1048576` |
| `telemetry.outfile` | string | Path to write telemetry to a file. When set, overrides OTLP export. | |

#### outboundCorrelation

⚠️ **Security-relevant.** Controls what client-side correlation data Qwen Code writes into outbound LLM API requests — a separate consent decision from `telemetry.*`, which governs data flowing into your OWN observability backend. All values default to off.

| Setting | Type | Description | Default |
| ---------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `outboundCorrelation.propagateTraceContext` | boolean | Inject W3C `traceparent` on outbound `fetch` requests and as a `TRACEPARENT` env var in shell child processes. Requires `telemetry.enabled: true`. | `false` |
| `outboundCorrelation.allowDynamicHeaderValues` | boolean | Allow `customHeaders` values to contain runtime placeholders such as `${session_id}`, expanded per request. When off, such a value is dropped rather than sent. | `false` |

```json
{
"outboundCorrelation": {
"allowDynamicHeaderValues": true
}
}
```

`allowDynamicHeaderValues` is only the consent switch. _Which_ hosts receive the
value and _what_ the header is called are decided where the header lives —
`modelProviders[].generationConfig.customHeaders`, see
[Dynamic values in `customHeaders`](model-providers.md#dynamic-values-in-customheaders).
That scoping is why there is no host allowlist here: you already chose the
endpoint when you wrote the provider's `baseUrl`, and providers that should not
send the header simply do not carry it.

If a provider entry has a placeholder while this is off, the header is dropped
and a warning naming both is printed at startup — so a gateway rejecting your
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
requests should never be a silent mystery.

**Privacy note:** an expanded value is a stable per-conversation identifier.
Only put one on a provider you already send your prompt content to.

### Example `settings.json`

Here is an example of a `settings.json` file with the nested structure, new as of v0.3.0:
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,12 @@ const SETTINGS_SCHEMA = {
type: 'boolean',
default: false,
},
allowDynamicHeaderValues: {
description:
'SECURITY-RELEVANT. Allow `modelProviders[].generationConfig.customHeaders` values to contain runtime placeholders — currently `${session_id}` — expanded per request instead of frozen at client construction. Default false: a value containing a placeholder is dropped rather than sent. Enable when a gateway requires a stable per-conversation identifier (e.g. OpenCode Go requires `x-opencode-session`). Which hosts receive the value and what the header is called are decided by the provider entry you attach the header to; this switch only decides whether live session state may be interpolated at all, and prevents a preset or extension from silently turning a shipped `customHeaders` entry into an identity header.',
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,22 @@ export interface OutboundCorrelationSettings {
* Tracing + DashScope — for cross-process trace stitching.
*/
propagateTraceContext?: boolean;
/**
* Allow `modelProviders[].generationConfig.customHeaders` values to
* carry runtime placeholders such as `${session_id}`, expanded per
* request. Default: disabled — a value with a placeholder is dropped
* rather than sent.
*
* This is the consent decision, and it is the only part of the feature
* that is global: *which* hosts may receive the value, and *what* the
* header is called, are already answered by the provider entry the
* header is attached to. The gate also means a preset or extension
* that ships a `customHeaders` entry cannot quietly turn it into an
* identity header — provenance is lost once presets and user settings
* are merged, so the switch is what separates "I typed this" from
* "something shipped this".
*/
allowDynamicHeaderValues?: boolean;
}

export interface OutputSettings {
Expand Down Expand Up @@ -2617,6 +2633,8 @@ export class Config {
this.outboundCorrelationSettings = {
propagateTraceContext:
params.outboundCorrelation?.propagateTraceContext ?? false,
allowDynamicHeaderValues:
params.outboundCorrelation?.allowDynamicHeaderValues ?? false,
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
};
this.gitCoAuthor = {
...normalizeGitCoAuthor(params.gitCoAuthor),
Expand Down Expand Up @@ -7510,6 +7528,15 @@ export class Config {
return this.outboundCorrelationSettings.propagateTraceContext ?? false;
}

/**
* Whether `customHeaders` values may carry runtime placeholders. See
* {@link OutboundCorrelationSettings.allowDynamicHeaderValues}; consumed by
* `core/outbound-dynamic-headers.ts`.
*/
getOutboundAllowDynamicHeaderValues(): boolean {
return this.outboundCorrelationSettings.allowDynamicHeaderValues ?? false;
}

getTelemetryOutfile(): string | undefined {
return this.telemetrySettings.outfile;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
fetch: buildSessionAwareFetch(
runtimeOptions.fetch,
this.cliConfig,
this.contentGeneratorConfig.customHeaders,
) as unknown as AnthropicFetch,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
} from '../../telemetry/gen-ai-request.js';
import type { Config } from '../../config/config.js';
import { buildSessionIdHeaders } from '../outbound-session-id.js';
import {
expandDynamicHeaders,
hasDynamicPlaceholder,
} from '../outbound-dynamic-headers.js';

const debugLogger = createDebugLogger('GEMINI');

Expand Down Expand Up @@ -79,7 +83,18 @@ export class LlmContentGenerator implements ContentGenerator {
contentGeneratorConfig?: ContentGeneratorConfig,
cliConfig?: Config,
) {
const customHeaders = contentGeneratorConfig?.customHeaders;
// Only placeholder-free entries may be baked into the client: a
// placeholder value put here would be frozen at whatever the session
// was when the client was built, and overriding it later would rely
// on the SDK letting request-level headers win over client-level
// ones. `buildHttpOptions` supplies the placeholder-bearing entries
// per request instead, so the literal never reaches the client.
const allCustomHeaders = contentGeneratorConfig?.customHeaders;
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
const staticEntries = Object.entries(allCustomHeaders ?? {}).filter(
([, value]) => typeof value !== 'string' || !hasDynamicPlaceholder(value),
);
const customHeaders =
staticEntries.length > 0 ? Object.fromEntries(staticEntries) : undefined;
const finalOptions = customHeaders
? (() => {
const baseHttpOptions = options.httpOptions;
Expand Down Expand Up @@ -108,13 +123,29 @@ export class LlmContentGenerator implements ContentGenerator {
const destination = httpOptions?.baseUrl ?? this.clientBaseUrl;
if (!this.cliConfig || !destination) return httpOptions;

// The placeholder-bearing entries were deliberately kept out of the
// client options (see the constructor), so this is the only place
// they are supplied — resolved fresh for each request.
const dynamicHeaders = expandDynamicHeaders(
this.contentGeneratorConfig?.customHeaders,
this.cliConfig,
);
const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination);
if (Object.keys(sessionHeaders).length === 0) return httpOptions;
if (
Object.keys(sessionHeaders).length === 0 &&
Object.keys(dynamicHeaders).length === 0
) {
return httpOptions;
}
return {
...httpOptions,
headers: {
...httpOptions?.headers,
// Existing merge order for this path is
// customHeaders > correlation; keep it.
...sessionHeaders,
...dynamicHeaders,
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
// (dynamicHeaders last: they are customHeaders entries.)
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,11 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
maxRetries,
defaultHeaders,
...(runtimeOptions || {}),
fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig),
fetch: buildSessionAwareFetch(
runtimeOptions?.fetch,
this.cliConfig,
this.contentGeneratorConfig.customHeaders,
),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export class DefaultOpenAICompatibleProvider
maxRetries,
defaultHeaders,
...(runtimeOptions || {}),
fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig),
fetch: buildSessionAwareFetch(
runtimeOptions?.fetch,
this.cliConfig,
this.contentGeneratorConfig.customHeaders,
),
});
}

Expand Down
Loading
Loading