From c5014591bbb2699c6aa92df20b378489105d5de5 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:33:37 +0800 Subject: [PATCH 1/5] feat(core): add Routify session affinity header --- .../2026-09-03-outbound-session-id-header.md | 36 ++++++ docs/developers/development/telemetry.md | 26 +++-- .../anthropicContentGenerator.test.ts | 3 + .../anthropicContentGenerator.ts | 10 +- .../core/llm-content-generator/index.test.ts | 2 + .../src/core/llm-content-generator/index.ts | 1 + .../llm-content-generator.test.ts | 37 ++++++ .../llm-content-generator.ts | 32 ++++- .../provider/dashscope.test.ts | 1 + .../provider/dashscope.ts | 4 + .../provider/default.test.ts | 1 + .../provider/default.ts | 4 + .../core/src/core/outbound-session-id.test.ts | 110 ++++++++++++++++++ packages/core/src/core/outbound-session-id.ts | 77 ++++++++++++ 14 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 docs/design/2026-09-03-outbound-session-id-header.md create mode 100644 packages/core/src/core/outbound-session-id.test.ts create mode 100644 packages/core/src/core/outbound-session-id.ts diff --git a/docs/design/2026-09-03-outbound-session-id-header.md b/docs/design/2026-09-03-outbound-session-id-header.md new file mode 100644 index 00000000000..ffcbcdf3f2b --- /dev/null +++ b/docs/design/2026-09-03-outbound-session-id-header.md @@ -0,0 +1,36 @@ +# Routify `session_id` Header + +## Summary + +Qwen Code attaches its current session ID as the `session_id` HTTP header only when the outbound LLM request hostname is one of the three Routify endpoints documented by ModelRouter: `routify.alibaba-inc.com`, `routify-online.alibaba-inc.com`, or `routify-pub.alibaba-inc.com`. + +The behavior is intentionally not configurable. Subdomains, other `alibaba-inc.com` hosts, and every non-Routify provider remain unchanged. + +## Motivation + +Routify's ModelRouter accepts `session_id` as a session-affinity and traffic-marking value. Qwen Code already maintains a session ID, but it is currently local metadata and never reaches the ModelRouter request. Reusing it gives Routify one stable affinity value per CLI session without creating another identifier. + +## Security boundary + +A session ID is a stable cross-request identifier. The implementation therefore requires HTTPS and compares the parsed request hostname to a fixed set of three ModelRouter hostnames. It does not use suffix matching, wildcards, path matching, or a user-configurable allowlist. Invalid URLs fail closed. + +The Qwen Code session ID replaces any custom `session_id` value on an eligible request so the affinity marker cannot disagree with the active session. All other existing headers, including authorization, are preserved. + +## Request lifecycle + +OpenAI-compatible and Anthropic clients receive a fetch wrapper. The wrapper reads `Config.getSessionId()` immediately before each HTTP request. This matters because `/clear` starts a new session without rebuilding the SDK client. + +Gemini requests use the SDK's request-level `httpOptions.headers`. The header is rebuilt for generate, streaming generate, and embedding requests. Gemini injection requires an explicit Routify `baseUrl`; implicit SDK endpoints remain unchanged. + +## Provider coverage + +- The default OpenAI-compatible provider covers Routify's OpenAI protocol and subclasses that inherit its client construction. +- DashScope has a separate client constructor and is integrated explicitly. +- Anthropic uses the same per-request fetch wrapper. +- Gemini and Vertex use request-level HTTP options when the base URL points to Routify. + +Non-LLM traffic, other domains, MCP requests, tool fetches, subprocesses, `traceparent`, request IDs, and body metadata are out of scope. + +## Verification + +Unit tests cover exact-host and HTTPS matching, rejection of lookalike hosts, invalid URLs, preservation of existing headers, `Request` inputs, custom-header precedence, empty values, and session rotation. Provider tests verify every SDK client construction path installs the correlation layer, and Gemini tests verify that successive requests observe a changed session ID. diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index d0cfaed2e76..500896eaea8 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -393,15 +393,23 @@ Verify both flags when wiring an ARMS+DashScope correlation setup: } ``` -### Other outbound correlation headers - -`X-Qwen-Code-Session-Id` and `X-Qwen-Code-Request-Id` are **not part of -this PR**. They will be designed and proposed in their own follow-up -PR(s) under the same `outboundCorrelation.*` namespace, each with its -own threat model and operator-consent flow. PR #4390 review (LaZzyMan) -established the principle: "telemetry's scope of work doesn't include -sending identifiers to LLM providers"; correlation-header work moves to -its own design discussion rather than landing under telemetry. +### Routify session affinity + +HTTPS requests to `routify.alibaba-inc.com`, +`routify-online.alibaba-inc.com`, and `routify-pub.alibaba-inc.com` include the +current Qwen Code session ID in the `session_id` header. Routify's ModelRouter +uses this value for session affinity and traffic marking. This behavior is not +controlled by `telemetry.enabled` or `outboundCorrelation.*`. + +The match is deliberately narrow: subdomains, other `alibaba-inc.com` hosts, +and all other LLM endpoints do not receive the header. + +The session ID is read for every request, so a new session created by +`/clear` gets a new affinity value without rebuilding the SDK client. Gemini +requires an explicit Routify `baseUrl` so Qwen Code can verify the +destination. + +`X-Qwen-Code-Request-Id` is not implemented. ## Inbound correlation (daemon HTTP API) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index f768976b633..8af068e306d 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -151,6 +151,9 @@ describe('AnthropicContentGenerator', () => { expect(headers['x-app']).toBe('cli'); expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key'); expect(anthropicState.constructorOptions?.['apiKey']).toBeNull(); + expect(anthropicState.constructorOptions?.['fetch']).toEqual( + expect.any(Function), + ); }); it('uses QwenCode identity + apiKey auth when baseURL is api.anthropic.com', async () => { diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 57865f12dcf..047a0a852fe 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import Anthropic from '@anthropic-ai/sdk'; +import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk'; import type { EmbedContentParameters, EmbedContentResponse, @@ -27,6 +27,7 @@ type MessageCreateParamsNonStreaming = Anthropic.MessageCreateParamsNonStreaming; type MessageCreateParamsStreaming = Anthropic.MessageCreateParamsStreaming; type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent; +type AnthropicFetch = NonNullable; import { AnthropicContentConverter } from './converter.js'; import { buildAnthropicUsageMetadata } from './usage.js'; import { @@ -53,6 +54,7 @@ import { setToolCallPreparations } from '../tool-call-preparation.js'; import { InvalidStreamError } from '../invalid-stream-error.js'; import { parseToolCallArguments } from '../tool-call-arguments.js'; import { classifyRetryError } from '../../utils/retryErrorClassification.js'; +import { wrapFetchWithSessionId } from '../outbound-session-id.js'; import { isRetryableStreamTransportError } from '../stream-transport-retry.js'; import { reportAnthropicEvent, @@ -332,6 +334,8 @@ export class AnthropicContentGenerator implements ContentGenerator { 'anthropic', this.cliConfig.getProxy(), ); + const baseFetch = + (runtimeOptions.fetch as typeof fetch | undefined) ?? globalThis.fetch; // IdeaLab-style Anthropic proxies expect `Authorization: Bearer ` // instead of the SDK-default `x-api-key` header. Use the SDK's @@ -360,6 +364,10 @@ export class AnthropicContentGenerator implements ContentGenerator { maxRetries: contentGeneratorConfig.maxRetries, defaultHeaders, ...runtimeOptions, + fetch: wrapFetchWithSessionId( + baseFetch, + this.cliConfig, + ) as unknown as AnthropicFetch, }); this.converter = new AnthropicContentConverter( diff --git a/packages/core/src/core/llm-content-generator/index.test.ts b/packages/core/src/core/llm-content-generator/index.test.ts index 3733eb8c977..3d5684f5213 100644 --- a/packages/core/src/core/llm-content-generator/index.test.ts +++ b/packages/core/src/core/llm-content-generator/index.test.ts @@ -61,6 +61,7 @@ describe('createLlmContentGenerator', () => { }), }), config, + mockConfig, ); }); @@ -82,6 +83,7 @@ describe('createLlmContentGenerator', () => { }), }), config, + mockConfig, ); expect(vi.mocked(LlmContentGenerator).mock.calls[0]?.[0]).not.toEqual( expect.objectContaining({ diff --git a/packages/core/src/core/llm-content-generator/index.ts b/packages/core/src/core/llm-content-generator/index.ts index 229217f47c4..0abb1717a7f 100644 --- a/packages/core/src/core/llm-content-generator/index.ts +++ b/packages/core/src/core/llm-content-generator/index.ts @@ -61,6 +61,7 @@ export function createLlmContentGenerator( httpOptions, }, config, + gcConfig, ); return llmContentGenerator; diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 50d0ca09860..f7d4dd7a4cc 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { LlmContentGenerator } from './llm-content-generator.js'; import { GoogleGenAI } from '@google/genai'; +import type { Config } from '../../config/config.js'; const mockReportLlmRequest = vi.hoisted(() => vi.fn()); const mockReportLlmResponse = vi.hoisted(() => vi.fn()); @@ -113,6 +114,42 @@ describe('LlmContentGenerator', () => { expect(response).toBe(expectedResponse); }); + it('adds the current session ID to Routify Gemini requests', async () => { + const getSessionId = vi.fn().mockReturnValue('session-1'); + const cliConfig = { + getSessionId, + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { apiKey: 'test-api-key' }, + { + model: 'gemini-1.5-flash', + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + cliConfig, + ); + 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', + ); + getSessionId.mockReturnValue('session-2'); + await sessionGenerator.generateContent( + { model: 'gemini-1.5-flash', contents: [] }, + 'prompt-2', + ); + + expect( + googleGenAI.models.generateContent.mock.calls[0][0].config.httpOptions + .headers, + ).toEqual({ session_id: 'session-1' }); + expect( + googleGenAI.models.generateContent.mock.calls[1][0].config.httpOptions + .headers, + ).toEqual({ session_id: 'session-2' }); + }); + it('passes ordered multi-part startup reminder content through unchanged', async () => { const request = { model: 'gemini-1.5-flash', diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index c23f72cc355..9d51ad2d395 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -13,6 +13,7 @@ import type { ThinkingLevel, Content, Part, + HttpOptions, } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import type { @@ -26,6 +27,8 @@ import { reportLlmResponse, type GenAiAttemptHandle, } from '../../telemetry/gen-ai-request.js'; +import type { Config } from '../../config/config.js'; +import { buildSessionIdHeaders } from '../outbound-session-id.js'; const debugLogger = createDebugLogger('GEMINI'); @@ -61,6 +64,7 @@ function observeLlmStream( export class LlmContentGenerator implements ContentGenerator { private readonly googleGenAI: GoogleGenAI; private readonly contentGeneratorConfig?: ContentGeneratorConfig; + private readonly cliConfig?: Config; // Latch so the effort-clamp warning fires once per generator lifetime // instead of on every request that needs the downgrade. private effortClampWarned = false; @@ -69,9 +73,10 @@ export class LlmContentGenerator implements ContentGenerator { options: { apiKey?: string; vertexai?: boolean; - httpOptions?: { headers: Record }; + httpOptions?: HttpOptions; }, contentGeneratorConfig?: ContentGeneratorConfig, + cliConfig?: Config, ) { const customHeaders = contentGeneratorConfig?.customHeaders; const finalOptions = customHeaders @@ -94,6 +99,23 @@ export class LlmContentGenerator implements ContentGenerator { this.googleGenAI = new GoogleGenAI(finalOptions); this.contentGeneratorConfig = contentGeneratorConfig; + this.cliConfig = cliConfig; + } + + private buildHttpOptions(httpOptions?: HttpOptions): HttpOptions | undefined { + const destination = + httpOptions?.baseUrl ?? this.contentGeneratorConfig?.baseUrl; + if (!this.cliConfig || !destination) return httpOptions; + + const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination); + if (Object.keys(sessionHeaders).length === 0) return httpOptions; + return { + ...httpOptions, + headers: { + ...httpOptions?.headers, + ...sessionHeaders, + }, + }; } private buildGenerateContentConfig( @@ -101,6 +123,7 @@ export class LlmContentGenerator implements ContentGenerator { ): GenerateContentConfig { const configSamplingParams = this.contentGeneratorConfig?.samplingParams; const requestConfig = request.config || {}; + const httpOptions = this.buildHttpOptions(requestConfig.httpOptions); // Helper function to get parameter value with priority: config > request > default const getParameterValue = ( @@ -117,6 +140,7 @@ export class LlmContentGenerator implements ContentGenerator { return { ...requestConfig, + ...(httpOptions ? { httpOptions } : {}), temperature: getParameterValue( configSamplingParams?.temperature, 'temperature', @@ -367,6 +391,10 @@ export class LlmContentGenerator implements ContentGenerator { async embedContent( request: EmbedContentParameters, ): Promise { - return this.googleGenAI.models.embedContent(request); + const httpOptions = this.buildHttpOptions(request.config?.httpOptions); + return this.googleGenAI.models.embedContent({ + ...request, + ...(httpOptions ? { config: { ...request.config, httpOptions } } : {}), + }); } } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 8c24da48900..0245438649a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -582,6 +582,7 @@ describe('DashScopeOpenAICompatibleProvider', () => { 'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`, 'X-DashScope-AuthType': AuthType.QWEN_OAUTH, }, + fetch: expect.any(Function), }), ); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index a3da0cd4e9a..04915180aa6 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -24,6 +24,7 @@ import { import type { ReasoningEffort } from '../../reasoning-effort.js'; import { clampReasoningEffort } from '../../reasoning-effort.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; +import { wrapFetchWithSessionId } from '../../outbound-session-id.js'; const debugLogger = createDebugLogger('DashScopeOpenAICompatibleProvider'); @@ -314,6 +315,8 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr 'openai', this.cliConfig.getProxy(), ); + const baseFetch = + (runtimeOptions?.fetch as typeof fetch | undefined) ?? globalThis.fetch; return new OpenAI({ apiKey, baseURL: baseUrl, @@ -321,6 +324,7 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr maxRetries, defaultHeaders, ...(runtimeOptions || {}), + fetch: wrapFetchWithSessionId(baseFetch, this.cliConfig), }); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index 8e375b0dac7..b56a618eded 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -175,6 +175,7 @@ describe('DefaultOpenAICompatibleProvider', () => { defaultHeaders: { 'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`, }, + fetch: expect.any(Function), }), ); diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 1d49046beaa..5ebb20fc15d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -18,6 +18,7 @@ import { clampReasoningEffort, } from '../../reasoning-effort.js'; import { createDebugLogger } from '../../../utils/debugLogger.js'; +import { wrapFetchWithSessionId } from '../../outbound-session-id.js'; const debugLogger = createDebugLogger('DefaultOpenAICompatibleProvider'); @@ -120,6 +121,8 @@ export class DefaultOpenAICompatibleProvider 'openai', this.cliConfig.getProxy(), ); + const baseFetch = + (runtimeOptions?.fetch as typeof fetch | undefined) ?? globalThis.fetch; return new OpenAI({ apiKey, baseURL: baseUrl, @@ -127,6 +130,7 @@ export class DefaultOpenAICompatibleProvider maxRetries, defaultHeaders, ...(runtimeOptions || {}), + fetch: wrapFetchWithSessionId(baseFetch, this.cliConfig), }); } diff --git a/packages/core/src/core/outbound-session-id.test.ts b/packages/core/src/core/outbound-session-id.test.ts new file mode 100644 index 00000000000..46f000b1516 --- /dev/null +++ b/packages/core/src/core/outbound-session-id.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { + buildSessionIdHeaders, + SESSION_ID_HEADER, + wrapFetchWithSessionId, +} from './outbound-session-id.js'; + +function config(sessionId = 'session-1'): Config { + return { + getSessionId: vi.fn().mockReturnValue(sessionId), + } as unknown as Config; +} + +describe('outbound session ID', () => { + it.each([ + 'routify.alibaba-inc.com', + 'routify-online.alibaba-inc.com', + 'routify-pub.alibaba-inc.com', + ])('matches the documented Routify host %s', (host) => { + expect( + buildSessionIdHeaders(config(), `https://${host}/protocol/openai/v1`), + ).toEqual({ [SESSION_ID_HEADER]: 'session-1' }); + }); + + it('uses the current session ID for Routify requests', async () => { + const cliConfig = config(); + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrappedFetch = wrapFetchWithSessionId(baseFetch, cliConfig); + + await wrappedFetch( + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + { + headers: { + Authorization: 'Bearer token', + session_id: 'custom-value', + }, + }, + ); + vi.mocked(cliConfig.getSessionId).mockReturnValue('session-2'); + await wrappedFetch( + 'https://routify-pub.alibaba-inc.com/protocol/anthropic/v1', + ); + + const firstHeaders = new Headers(baseFetch.mock.calls[0][1]?.headers); + const secondHeaders = new Headers(baseFetch.mock.calls[1][1]?.headers); + expect(firstHeaders.get('authorization')).toBe('Bearer token'); + expect(firstHeaders.get(SESSION_ID_HEADER)).toBe('session-1'); + expect(secondHeaders.get(SESSION_ID_HEADER)).toBe('session-2'); + }); + + it.each([ + 'https://sub.routify-pub.alibaba-inc.com/protocol/openai/v1', + 'https://routify-preview.alibaba-inc.com/protocol/openai/v1', + 'https://api.openai.com/v1', + 'http://routify.alibaba-inc.com/protocol/openai/v1', + 'not a URL', + ])('does not inject the header into %s', async (url) => { + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrappedFetch = wrapFetchWithSessionId(baseFetch, config()); + + await wrappedFetch(url, { + headers: { 'X-Existing': 'value' }, + }); + + expect(baseFetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ headers: { 'X-Existing': 'value' } }), + ); + }); + + it('does not send an empty session ID', () => { + expect( + buildSessionIdHeaders( + config(''), + 'https://routify.alibaba-inc.com/protocol/openai/v1', + ), + ).toEqual({}); + }); + + it('preserves headers carried by a Request object', async () => { + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrappedFetch = wrapFetchWithSessionId(baseFetch, config()); + + await wrappedFetch( + new Request('https://routify-pub.alibaba-inc.com/protocol/openai/v1', { + headers: { Authorization: 'Bearer token' }, + }), + ); + + const headers = new Headers(baseFetch.mock.calls[0][1]?.headers); + expect(headers.get('authorization')).toBe('Bearer token'); + expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); + }); +}); diff --git a/packages/core/src/core/outbound-session-id.ts b/packages/core/src/core/outbound-session-id.ts new file mode 100644 index 00000000000..6190c8cebf5 --- /dev/null +++ b/packages/core/src/core/outbound-session-id.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('OUTBOUND_CORRELATION'); + +export const SESSION_ID_HEADER = 'session_id'; +export const SESSION_ID_HEADER_HOSTS: readonly string[] = [ + 'routify.alibaba-inc.com', + 'routify-online.alibaba-inc.com', + 'routify-pub.alibaba-inc.com', +]; + +type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +function requestUrl(input: string | URL | Request): URL | undefined { + try { + if (typeof input === 'string') return new URL(input); + if (input instanceof URL) return input; + return new URL(input.url); + } catch { + return undefined; + } +} + +export function buildSessionIdHeaders( + config: Config, + destination: string | URL | Request, +): Record { + try { + const url = requestUrl(destination); + if ( + url?.protocol !== 'https:' || + !SESSION_ID_HEADER_HOSTS.includes(url.hostname.toLowerCase()) + ) { + return {}; + } + const sessionId = config.getSessionId(); + return sessionId ? { [SESSION_ID_HEADER]: sessionId } : {}; + } catch (error) { + debugLogger.warn( + `Unable to add ${SESSION_ID_HEADER} to outbound request: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return {}; + } +} + +export function wrapFetchWithSessionId( + baseFetch: TFetch, + config: Config, +): TFetch { + const fetchLike = baseFetch as FetchLike; + const wrapped: FetchLike = async (input, init) => { + const sessionHeaders = buildSessionIdHeaders(config, input); + const sessionId = sessionHeaders[SESSION_ID_HEADER]; + if (!sessionId) return fetchLike(input, init); + + const headers = new Headers(init?.headers); + if (init?.headers === undefined && input instanceof Request) { + input.headers.forEach((value, key) => headers.set(key, value)); + } + headers.set(SESSION_ID_HEADER, sessionId); + return fetchLike(input, { ...init, headers }); + }; + + return wrapped as TFetch; +} From 6936a4d0c2edd48272fa577ac9d2e7e48d31bdb4 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:24:19 +0800 Subject: [PATCH 2/5] fix(core): address session header review feedback --- .../2026-09-03-outbound-session-id-header.md | 4 +- docs/developers/development/telemetry.md | 20 +++-- .../anthropicContentGenerator.ts | 9 +- .../llm-content-generator.test.ts | 82 +++++++++++++++++++ .../llm-content-generator.ts | 6 +- .../provider/dashscope.test.ts | 20 +++++ .../provider/dashscope.ts | 6 +- .../provider/default.test.ts | 25 ++++++ .../provider/default.ts | 6 +- .../core/src/core/outbound-session-id.test.ts | 40 +++++++++ packages/core/src/core/outbound-session-id.ts | 17 +++- 11 files changed, 206 insertions(+), 29 deletions(-) diff --git a/docs/design/2026-09-03-outbound-session-id-header.md b/docs/design/2026-09-03-outbound-session-id-header.md index ffcbcdf3f2b..baf869903d8 100644 --- a/docs/design/2026-09-03-outbound-session-id-header.md +++ b/docs/design/2026-09-03-outbound-session-id-header.md @@ -4,7 +4,7 @@ Qwen Code attaches its current session ID as the `session_id` HTTP header only when the outbound LLM request hostname is one of the three Routify endpoints documented by ModelRouter: `routify.alibaba-inc.com`, `routify-online.alibaba-inc.com`, or `routify-pub.alibaba-inc.com`. -The behavior is intentionally not configurable. Subdomains, other `alibaba-inc.com` hosts, and every non-Routify provider remain unchanged. +The behavior is intentionally not configurable. Qwen Code does not attach the header to subdomains, other `alibaba-inc.com` hosts, or non-Routify providers. Standard fetch redirect behavior applies after the initial destination check, so a Routify response can forward the header by redirecting the request. ## Motivation @@ -33,4 +33,4 @@ 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 of existing headers, `Request` inputs, custom-header precedence, empty values, and session rotation. Provider tests verify every SDK client construction path installs the correlation layer, and Gemini tests verify that successive requests observe a changed session ID. +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. diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index 500896eaea8..699b97e2df3 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -395,14 +395,18 @@ Verify both flags when wiring an ARMS+DashScope correlation setup: ### Routify session affinity -HTTPS requests to `routify.alibaba-inc.com`, -`routify-online.alibaba-inc.com`, and `routify-pub.alibaba-inc.com` include the -current Qwen Code session ID in the `session_id` header. Routify's ModelRouter -uses this value for session affinity and traffic marking. This behavior is not -controlled by `telemetry.enabled` or `outboundCorrelation.*`. - -The match is deliberately narrow: subdomains, other `alibaba-inc.com` hosts, -and all other LLM endpoints do not receive the header. +Qwen Code's LLM requests through the OpenAI-compatible, DashScope, Anthropic, +Gemini, and Vertex provider paths include the current Qwen Code session ID in +the `session_id` header when addressed directly to `routify.alibaba-inc.com`, +`routify-online.alibaba-inc.com`, or `routify-pub.alibaba-inc.com`. Routify's +ModelRouter uses this value for session affinity and traffic marking. This +behavior is not controlled by `telemetry.enabled` or +`outboundCorrelation.*`. + +The initial destination match is deliberately narrow: Qwen Code does not +attach the header to subdomains, other `alibaba-inc.com` hosts, or other LLM +endpoints. The standard fetch redirect behavior still applies after that +match, so a Routify response can forward the header by redirecting the request. The session ID is read for every request, so a new session created by `/clear` gets a new affinity value without rebuilding the SDK client. Gemini diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index 047a0a852fe..07c13f957d1 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -54,7 +54,7 @@ import { setToolCallPreparations } from '../tool-call-preparation.js'; import { InvalidStreamError } from '../invalid-stream-error.js'; import { parseToolCallArguments } from '../tool-call-arguments.js'; import { classifyRetryError } from '../../utils/retryErrorClassification.js'; -import { wrapFetchWithSessionId } from '../outbound-session-id.js'; +import { buildSessionAwareFetch } from '../outbound-session-id.js'; import { isRetryableStreamTransportError } from '../stream-transport-retry.js'; import { reportAnthropicEvent, @@ -334,9 +334,6 @@ export class AnthropicContentGenerator implements ContentGenerator { 'anthropic', this.cliConfig.getProxy(), ); - const baseFetch = - (runtimeOptions.fetch as typeof fetch | undefined) ?? globalThis.fetch; - // IdeaLab-style Anthropic proxies expect `Authorization: Bearer ` // instead of the SDK-default `x-api-key` header. Use the SDK's // `authToken` parameter (sends `Authorization: Bearer` natively) only @@ -364,8 +361,8 @@ export class AnthropicContentGenerator implements ContentGenerator { maxRetries: contentGeneratorConfig.maxRetries, defaultHeaders, ...runtimeOptions, - fetch: wrapFetchWithSessionId( - baseFetch, + fetch: buildSessionAwareFetch( + runtimeOptions.fetch, this.cliConfig, ) as unknown as AnthropicFetch, }); diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index f7d4dd7a4cc..3c01bda9b61 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -150,6 +150,62 @@ describe('LlmContentGenerator', () => { ).toEqual({ session_id: 'session-2' }); }); + it('uses the constructor base URL for Gemini session ID injection', async () => { + const cliConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { + apiKey: 'test-api-key', + httpOptions: { + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + }, + undefined, + cliConfig, + ); + 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({ session_id: 'session-1' }); + }); + + it('does not use a fallback base URL over the constructor destination', async () => { + const cliConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { + apiKey: 'test-api-key', + httpOptions: { baseUrl: 'https://generativelanguage.googleapis.com' }, + }, + { + model: 'gemini-1.5-flash', + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + cliConfig, + ); + 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, + ).toBeUndefined(); + }); + it('passes ordered multi-part startup reminder content through unchanged', async () => { const request = { model: 'gemini-1.5-flash', @@ -257,6 +313,32 @@ describe('LlmContentGenerator', () => { expect(response).toBe(expectedResponse); }); + it('adds the current session ID to Routify embedding requests', async () => { + const cliConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { apiKey: 'test-api-key' }, + { + model: 'embedding-model', + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + cliConfig, + ); + const googleGenAI = vi.mocked(GoogleGenAI).mock.results.at(-1)?.value; + googleGenAI.models.embedContent.mockResolvedValue({ embeddings: [] }); + + await sessionGenerator.embedContent({ + model: 'embedding-model', + contents: [], + }); + + expect( + googleGenAI.models.embedContent.mock.calls[0][0].config.httpOptions + .headers, + ).toEqual({ session_id: 'session-1' }); + }); + it('should prioritize contentGeneratorConfig samplingParams over request config', async () => { const generatorWithParams = new LlmContentGenerator({ apiKey: 'test' }, { model: 'gemini-1.5-flash', diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 9d51ad2d395..5aff9a9ee35 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -63,6 +63,7 @@ function observeLlmStream( */ export class LlmContentGenerator implements ContentGenerator { private readonly googleGenAI: GoogleGenAI; + private readonly clientBaseUrl?: string; private readonly contentGeneratorConfig?: ContentGeneratorConfig; private readonly cliConfig?: Config; // Latch so the effort-clamp warning fires once per generator lifetime @@ -98,13 +99,16 @@ export class LlmContentGenerator implements ContentGenerator { : options; this.googleGenAI = new GoogleGenAI(finalOptions); + this.clientBaseUrl = finalOptions.httpOptions?.baseUrl; this.contentGeneratorConfig = contentGeneratorConfig; this.cliConfig = cliConfig; } private buildHttpOptions(httpOptions?: HttpOptions): HttpOptions | undefined { const destination = - httpOptions?.baseUrl ?? this.contentGeneratorConfig?.baseUrl; + httpOptions?.baseUrl ?? + this.clientBaseUrl ?? + this.contentGeneratorConfig?.baseUrl; if (!this.cliConfig || !destination) return httpOptions; const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 0245438649a..36dab45eb58 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -589,6 +589,26 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(client).toBeDefined(); }); + it('installs session ID injection on the runtime fetch', async () => { + const runtimeFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + vi.mocked(buildRuntimeFetchOptions).mockReturnValue({ + fetch: runtimeFetch, + }); + + const client = provider.buildClient() as unknown as { + config: { fetch: typeof fetch }; + }; + await client.config.fetch( + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ); + + const headers = new Headers(runtimeFetch.mock.calls[0][1]?.headers); + expect(headers.get('session_id')).toBe('test-session-id'); + }); + it('should use default timeout and maxRetries when not provided', () => { mockContentGeneratorConfig.timeout = undefined; mockContentGeneratorConfig.maxRetries = undefined; diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 04915180aa6..d6886165495 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -24,7 +24,7 @@ import { import type { ReasoningEffort } from '../../reasoning-effort.js'; import { clampReasoningEffort } from '../../reasoning-effort.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; -import { wrapFetchWithSessionId } from '../../outbound-session-id.js'; +import { buildSessionAwareFetch } from '../../outbound-session-id.js'; const debugLogger = createDebugLogger('DashScopeOpenAICompatibleProvider'); @@ -315,8 +315,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr 'openai', this.cliConfig.getProxy(), ); - const baseFetch = - (runtimeOptions?.fetch as typeof fetch | undefined) ?? globalThis.fetch; return new OpenAI({ apiKey, baseURL: baseUrl, @@ -324,7 +322,7 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr maxRetries, defaultHeaders, ...(runtimeOptions || {}), - fetch: wrapFetchWithSessionId(baseFetch, this.cliConfig), + fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig), }); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index b56a618eded..361608aa8a2 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -81,6 +81,7 @@ describe('DefaultOpenAICompatibleProvider', () => { mockCliConfig = { getCliVersion: vi.fn().mockReturnValue('1.0.0'), getProxy: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('session-1'), } as unknown as Config; provider = new DefaultOpenAICompatibleProvider( @@ -182,6 +183,30 @@ describe('DefaultOpenAICompatibleProvider', () => { expect(client).toBeDefined(); }); + it('installs session ID injection on the runtime fetch', async () => { + const runtimeFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + vi.mocked(buildRuntimeFetchOptions).mockReturnValue({ + fetch: runtimeFetch, + }); + + const client = provider.buildClient() as unknown as { + config: { fetch: typeof fetch }; + }; + await client.config.fetch( + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ); + await client.config.fetch('https://api.openai.com/v1'); + + const routifyHeaders = new Headers( + runtimeFetch.mock.calls[0][1]?.headers, + ); + expect(routifyHeaders.get('session_id')).toBe('session-1'); + expect(runtimeFetch.mock.calls[1][1]).toBeUndefined(); + }); + it('should use default timeout and maxRetries when not provided', () => { mockContentGeneratorConfig.timeout = undefined; mockContentGeneratorConfig.maxRetries = undefined; diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 5ebb20fc15d..33b95fa6edc 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -18,7 +18,7 @@ import { clampReasoningEffort, } from '../../reasoning-effort.js'; import { createDebugLogger } from '../../../utils/debugLogger.js'; -import { wrapFetchWithSessionId } from '../../outbound-session-id.js'; +import { buildSessionAwareFetch } from '../../outbound-session-id.js'; const debugLogger = createDebugLogger('DefaultOpenAICompatibleProvider'); @@ -121,8 +121,6 @@ export class DefaultOpenAICompatibleProvider 'openai', this.cliConfig.getProxy(), ); - const baseFetch = - (runtimeOptions?.fetch as typeof fetch | undefined) ?? globalThis.fetch; return new OpenAI({ apiKey, baseURL: baseUrl, @@ -130,7 +128,7 @@ export class DefaultOpenAICompatibleProvider maxRetries, defaultHeaders, ...(runtimeOptions || {}), - fetch: wrapFetchWithSessionId(baseFetch, this.cliConfig), + fetch: buildSessionAwareFetch(runtimeOptions?.fetch, this.cliConfig), }); } diff --git a/packages/core/src/core/outbound-session-id.test.ts b/packages/core/src/core/outbound-session-id.test.ts index 46f000b1516..9e79dc7bcd1 100644 --- a/packages/core/src/core/outbound-session-id.test.ts +++ b/packages/core/src/core/outbound-session-id.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { + buildSessionAwareFetch, buildSessionIdHeaders, SESSION_ID_HEADER, wrapFetchWithSessionId, @@ -107,4 +108,43 @@ describe('outbound session ID', () => { expect(headers.get('authorization')).toBe('Bearer token'); expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); }); + + it('merges Request and init headers before adding the session ID', async () => { + const baseFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const wrappedFetch = wrapFetchWithSessionId(baseFetch, config()); + + await wrappedFetch( + new Request('https://routify-pub.alibaba-inc.com/protocol/openai/v1', { + headers: { + Authorization: 'Bearer token', + 'X-Shared': 'request', + }, + }), + { headers: { 'X-Extra': 'value', 'X-Shared': 'init' } }, + ); + + const headers = new Headers(baseFetch.mock.calls[0][1]?.headers); + expect(headers.get('authorization')).toBe('Bearer token'); + expect(headers.get('x-extra')).toBe('value'); + expect(headers.get('x-shared')).toBe('init'); + expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); + }); + + it('wraps a supplied runtime fetch with session ID injection', async () => { + const runtimeFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + const sessionAwareFetch = buildSessionAwareFetch(runtimeFetch, config()); + + await sessionAwareFetch( + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ); + + const headers = new Headers(runtimeFetch.mock.calls[0][1]?.headers); + expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); + }); }); diff --git a/packages/core/src/core/outbound-session-id.ts b/packages/core/src/core/outbound-session-id.ts index 6190c8cebf5..60d554d6ea8 100644 --- a/packages/core/src/core/outbound-session-id.ts +++ b/packages/core/src/core/outbound-session-id.ts @@ -65,13 +65,22 @@ export function wrapFetchWithSessionId( const sessionId = sessionHeaders[SESSION_ID_HEADER]; if (!sessionId) return fetchLike(input, init); - const headers = new Headers(init?.headers); - if (init?.headers === undefined && input instanceof Request) { - input.headers.forEach((value, key) => headers.set(key, value)); - } + const headers = new Headers( + input instanceof Request ? input.headers : undefined, + ); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); headers.set(SESSION_ID_HEADER, sessionId); return fetchLike(input, { ...init, headers }); }; return wrapped as TFetch; } + +export function buildSessionAwareFetch( + runtimeFetch: unknown, + config: Config, +): typeof globalThis.fetch { + const baseFetch = + (runtimeFetch as typeof globalThis.fetch | undefined) ?? globalThis.fetch; + return wrapFetchWithSessionId(baseFetch, config); +} From 325c0bf7696ac907cd1d506c0a28732331ecbf48 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:25:53 +0800 Subject: [PATCH 3/5] test(core): verify Anthropic session fetch --- .../anthropicContentGenerator.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index 8af068e306d..93461cc0450 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -156,6 +156,51 @@ describe('AnthropicContentGenerator', () => { ); }); + it('installs session ID injection on the runtime fetch', async () => { + const runtimeFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + vi.doMock('../../utils/runtimeFetchOptions.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../utils/runtimeFetchOptions.js') + >(); + return { + ...actual, + buildRuntimeFetchOptions: vi.fn(() => ({ fetch: runtimeFetch })), + }; + }); + + try { + const { AnthropicContentGenerator } = await importGenerator(); + void new AnthropicContentGenerator( + { + model: 'claude-test', + apiKey: 'test-key', + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/anthropic', + timeout: 10_000, + maxRetries: 2, + samplingParams: {}, + schemaCompliance: 'auto', + }, + mockConfig, + ); + + const sessionAwareFetch = anthropicState.constructorOptions?.[ + 'fetch' + ] as typeof fetch; + await sessionAwareFetch( + 'https://routify-pub.alibaba-inc.com/protocol/anthropic/v1', + ); + + const headers = new Headers(runtimeFetch.mock.calls[0][1]?.headers); + expect(headers.get('session_id')).toBe('test-session'); + } finally { + vi.doUnmock('../../utils/runtimeFetchOptions.js'); + } + }); + it('uses QwenCode identity + apiKey auth when baseURL is api.anthropic.com', async () => { // Anthropic-native baseURL: keep the SDK-default `x-api-key` auth and // a truthful `QwenCode` User-Agent (no `x-app` header) so usage isn't From 95e15df9f59c67ec694b21db5876709ac86bf466 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:26:42 +0800 Subject: [PATCH 4/5] fix(core): align Gemini session destination --- docs/developers/development/telemetry.md | 2 +- .../llm-content-generator.test.ts | 39 ++++++++++++++++++- .../llm-content-generator.ts | 5 +-- .../core/src/core/outbound-session-id.test.ts | 22 ++++++++++- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index 699b97e2df3..1a706c72d19 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -397,7 +397,7 @@ Verify both flags when wiring an ARMS+DashScope correlation setup: Qwen Code's LLM requests through the OpenAI-compatible, DashScope, Anthropic, Gemini, and Vertex provider paths include the current Qwen Code session ID in -the `session_id` header when addressed directly to `routify.alibaba-inc.com`, +the `session_id` header when addressed over HTTPS directly to `routify.alibaba-inc.com`, `routify-online.alibaba-inc.com`, or `routify-pub.alibaba-inc.com`. Routify's ModelRouter uses this value for session affinity and traffic marking. This behavior is not controlled by `telemetry.enabled` or diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 3c01bda9b61..4241edd02d9 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -120,7 +120,12 @@ describe('LlmContentGenerator', () => { getSessionId, } as unknown as Config; const sessionGenerator = new LlmContentGenerator( - { apiKey: 'test-api-key' }, + { + apiKey: 'test-api-key', + httpOptions: { + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + }, { model: 'gemini-1.5-flash', baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', @@ -206,6 +211,31 @@ describe('LlmContentGenerator', () => { ).toBeUndefined(); }); + it('does not infer the SDK destination from content generator config', async () => { + const cliConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { apiKey: 'test-api-key' }, + { + model: 'gemini-1.5-flash', + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + cliConfig, + ); + 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, + ).toBeUndefined(); + }); + it('passes ordered multi-part startup reminder content through unchanged', async () => { const request = { model: 'gemini-1.5-flash', @@ -318,7 +348,12 @@ describe('LlmContentGenerator', () => { getSessionId: vi.fn().mockReturnValue('session-1'), } as unknown as Config; const sessionGenerator = new LlmContentGenerator( - { apiKey: 'test-api-key' }, + { + apiKey: 'test-api-key', + httpOptions: { + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + }, { model: 'embedding-model', baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index 5aff9a9ee35..65390d13f4e 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -105,10 +105,7 @@ export class LlmContentGenerator implements ContentGenerator { } private buildHttpOptions(httpOptions?: HttpOptions): HttpOptions | undefined { - const destination = - httpOptions?.baseUrl ?? - this.clientBaseUrl ?? - this.contentGeneratorConfig?.baseUrl; + const destination = httpOptions?.baseUrl ?? this.clientBaseUrl; if (!this.cliConfig || !destination) return httpOptions; const sessionHeaders = buildSessionIdHeaders(this.cliConfig, destination); diff --git a/packages/core/src/core/outbound-session-id.test.ts b/packages/core/src/core/outbound-session-id.test.ts index 9e79dc7bcd1..6082546e6af 100644 --- a/packages/core/src/core/outbound-session-id.test.ts +++ b/packages/core/src/core/outbound-session-id.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { buildSessionAwareFetch, @@ -20,6 +20,10 @@ function config(sessionId = 'session-1'): Config { } describe('outbound session ID', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it.each([ 'routify.alibaba-inc.com', 'routify-online.alibaba-inc.com', @@ -147,4 +151,20 @@ describe('outbound session ID', () => { const headers = new Headers(runtimeFetch.mock.calls[0][1]?.headers); expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); }); + + it('falls back to globalThis.fetch when no runtime fetch exists', async () => { + const fetchStub = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(), + ); + vi.stubGlobal('fetch', fetchStub); + const sessionAwareFetch = buildSessionAwareFetch(undefined, config()); + + await sessionAwareFetch( + 'https://routify-pub.alibaba-inc.com/protocol/openai/v1', + ); + + const headers = new Headers(fetchStub.mock.calls[0][1]?.headers); + expect(headers.get(SESSION_ID_HEADER)).toBe('session-1'); + }); }); From 82494329cfd3871e08864ff178010852c0676587 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:00:44 +0800 Subject: [PATCH 5/5] test(core): cover request-level Gemini HTTP options --- .../llm-content-generator.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 4241edd02d9..e04066f71e0 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -211,6 +211,85 @@ describe('LlmContentGenerator', () => { ).toBeUndefined(); }); + it('uses a non-Routify request destination over a Routify constructor destination', async () => { + const cliConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { + apiKey: 'test-api-key', + httpOptions: { + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + }, + }, + undefined, + cliConfig, + ); + const googleGenAI = vi.mocked(GoogleGenAI).mock.results.at(-1)?.value; + googleGenAI.models.generateContent.mockResolvedValue({}); + + await sessionGenerator.generateContent( + { + model: 'gemini-1.5-flash', + contents: [], + config: { + httpOptions: { + baseUrl: 'https://generativelanguage.googleapis.com', + headers: { 'X-Request': 'request-value' }, + }, + }, + }, + 'prompt-1', + ); + + expect( + googleGenAI.models.generateContent.mock.calls[0][0].config.httpOptions, + ).toEqual({ + baseUrl: 'https://generativelanguage.googleapis.com', + headers: { 'X-Request': 'request-value' }, + }); + }); + + it('injects alongside request headers for a request-level Routify destination', async () => { + const cliConfig = { + getSessionId: vi.fn().mockReturnValue('session-1'), + } as unknown as Config; + const sessionGenerator = new LlmContentGenerator( + { + apiKey: 'test-api-key', + httpOptions: { baseUrl: 'https://generativelanguage.googleapis.com' }, + }, + undefined, + cliConfig, + ); + const googleGenAI = vi.mocked(GoogleGenAI).mock.results.at(-1)?.value; + googleGenAI.models.generateContent.mockResolvedValue({}); + + await sessionGenerator.generateContent( + { + model: 'gemini-1.5-flash', + contents: [], + config: { + httpOptions: { + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + headers: { 'X-Request': 'request-value' }, + }, + }, + }, + 'prompt-1', + ); + + expect( + googleGenAI.models.generateContent.mock.calls[0][0].config.httpOptions, + ).toEqual({ + baseUrl: 'https://routify-pub.alibaba-inc.com/protocol/vertex', + headers: { + 'X-Request': 'request-value', + session_id: 'session-1', + }, + }); + }); + it('does not infer the SDK destination from content generator config', async () => { const cliConfig = { getSessionId: vi.fn().mockReturnValue('session-1'),