Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions docs/design/2026-09-03-outbound-session-id-header.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,12 @@ Non-LLM traffic, other domains, MCP requests, tool fetches, subprocesses, `trace
## Verification

Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation and precedence of combined `Request` and init headers, empty values, session rotation, and the shared runtime-fetch wrapper. Provider tests verify the OpenAI-compatible construction paths install a working correlation layer, and Gemini tests cover constructor destinations, generation, embedding, and successive requests observing a changed session ID.

## Follow-up: user-configurable `${session_id}` in `customHeaders`

A later change (for #10995) lets a `modelProviders[].generationConfig.customHeaders` value contain the placeholder `${session_id}`, expanded per request from the same `Config.getSessionId()`. The built-in header above is unchanged and keeps precedence over customHeaders entries on every path. The follow-up answers the threat-model questions §12.7 of the outbound-propagation design asked for:

- **Consent.** The placeholder is inert until the global `outboundCorrelation.allowDynamicHeaderValues` switch is on (default off). A gate-off, empty, or unresolvable value drops the header — the literal `${session_id}` is never sent.
- **Recipient set.** Whichever hosts the user attached the header to. Scope comes from the provider entry rather than a separate allowlist: the user already chose the endpoint when writing `baseUrl`, and providers that must not receive the value simply do not carry the header.
- **De-anonymization window.** One session: the value is stable for the life of a conversation (that stability is the feature — gateways key on it) and rotates on `/new` and `/resume`. A recipient can group the requests of one conversation, never across conversations.
- **Per-request UUID companion.** Deliberately not provided: a per-request value would defeat the session affinity the gateway requires. The placeholder set is closed at one entry and should not grow without the same review this seam received.
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 controls only whether `${session_id}` may be expanded. It does not
identify which settings source supplied the 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 `${session_id}` may be expanded from live session state and does not identify which settings source supplied the header.',
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/settingsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export const WORKSPACE_RESTRICTED_SETTINGS = [
{ section: 'security', key: 'allowPrivateNetworkHooks' },
{ section: 'security', key: 'allowedInsecureVoiceBaseUrls' },
{ section: 'goals', key: 'modelProposed' },
{ section: 'outboundCorrelation', key: 'allowDynamicHeaderValues' },
] as const satisfies ReadonlyArray<{
readonly section: keyof Settings;
readonly key: string;
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9410,6 +9410,22 @@ describe('Server Config (config.ts)', () => {
);
},
);

it('only enables dynamic header values for boolean true', () => {
for (const value of [undefined, false, 'false', 1, {}, []]) {
const outboundCorrelation = {
allowDynamicHeaderValues: value,
} as unknown as ConfigParameters['outboundCorrelation'];
const config = new Config({ ...baseParams, outboundCorrelation });
expect(config.getOutboundAllowDynamicHeaderValues()).toBe(false);
}

const config = new Config({
...baseParams,
outboundCorrelation: { allowDynamicHeaderValues: true },
});
expect(config.getOutboundAllowDynamicHeaderValues()).toBe(true);
});
});

describe('UseRipgrep Configuration', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,19 @@ 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. It controls only `${session_id}` expansion and
* cannot recover a header's provenance after settings are merged.
*/
allowDynamicHeaderValues?: boolean;
}

export interface OutputSettings {
Expand Down Expand Up @@ -2617,6 +2630,8 @@ export class Config {
this.outboundCorrelationSettings = {
propagateTraceContext:
params.outboundCorrelation?.propagateTraceContext ?? false,
allowDynamicHeaderValues:
params.outboundCorrelation?.allowDynamicHeaderValues === true,
};
this.gitCoAuthor = {
...normalizeGitCoAuthor(params.gitCoAuthor),
Expand Down Expand Up @@ -7510,6 +7525,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 @@ -118,6 +118,7 @@ describe('LlmContentGenerator', () => {
const getSessionId = vi.fn().mockReturnValue('session-1');
const cliConfig = {
getSessionId,
getOutboundAllowDynamicHeaderValues: () => true,
} as unknown as Config;
const sessionGenerator = new LlmContentGenerator(
{
Expand All @@ -129,6 +130,7 @@ describe('LlmContentGenerator', () => {
{
model: 'gemini-1.5-flash',
baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex',
customHeaders: { session_id: 'custom-${session_id}' },
},
cliConfig,
);
Expand All @@ -155,6 +157,53 @@ describe('LlmContentGenerator', () => {
).toEqual({ session_id: 'session-2' });
});

it('warns when Gemini dynamic headers are disabled', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
void new LlmContentGenerator(
{ apiKey: 'test-api-key' },
{
model: 'gemini-1.5-flash',
customHeaders: { 'X-Gemini-Session': '${session_id}' },
},
{
getOutboundAllowDynamicHeaderValues: () => false,
} as unknown as Config,
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('X-Gemini-Session'),
);
} finally {
warn.mockRestore();
}
});

it('expands Gemini dynamic headers without a base URL', async () => {
const sessionGenerator = new LlmContentGenerator(
{ apiKey: 'test-api-key' },
{
model: 'gemini-1.5-flash',
customHeaders: { 'X-Gemini-Session': '${session_id}' },
},
{
getSessionId: () => 'session-1',
getOutboundAllowDynamicHeaderValues: () => true,
} as unknown as Config,
);
const googleGenAI = vi.mocked(GoogleGenAI).mock.results.at(-1)?.value;
googleGenAI.models.generateContent.mockResolvedValue({});

await sessionGenerator.generateContent(
{ model: 'gemini-1.5-flash', contents: [] },
'prompt-1',
);

expect(
googleGenAI.models.generateContent.mock.calls[0][0].config.httpOptions
.headers,
).toEqual({ 'X-Gemini-Session': 'session-1' });
});

it('uses the constructor base URL for Gemini session ID injection', async () => {
const cliConfig = {
getSessionId: vi.fn().mockReturnValue('session-1'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import {
} from '../../telemetry/gen-ai-request.js';
import type { Config } from '../../config/config.js';
import { buildSessionIdHeaders } from '../outbound-session-id.js';
import {
expandDynamicHeaders,
hasDynamicPlaceholder,
warnIfDynamicHeadersDisabled,
} from '../outbound-dynamic-headers.js';

const debugLogger = createDebugLogger('GEMINI');

Expand Down Expand Up @@ -79,7 +84,19 @@ 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
if (cliConfig) warnIfDynamicHeadersDisabled(allCustomHeaders, cliConfig);
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 All @@ -105,15 +122,30 @@ export class LlmContentGenerator implements ContentGenerator {
}

private buildHttpOptions(httpOptions?: HttpOptions): HttpOptions | undefined {
if (!this.cliConfig) 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 destination = httpOptions?.baseUrl ?? this.clientBaseUrl;
if (!this.cliConfig || !destination) return httpOptions;

const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination);
if (Object.keys(sessionHeaders).length === 0) return httpOptions;
const sessionHeaders = destination
? buildSessionIdHeaders(this.cliConfig, destination)
: {};
if (
Object.keys(sessionHeaders).length === 0 &&
Object.keys(dynamicHeaders).length === 0
) {
return httpOptions;
}
return {
...httpOptions,
headers: {
...httpOptions?.headers,
...dynamicHeaders,
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
...sessionHeaders,
},
};
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