Qwen Code 出站session_id请求头:Routify 网关会话亲和标记的注入机制、安全边界与配置指南
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读
Qwen Code(本仓库开源项目,运行于终端中的 AI 编码智能体)在向特定 ModelRouter 网关端点发起 LLM 出站请求时,会自动附加当前 CLI 会话的session_idHTTP 头,用于会话亲和(session affinity)与流量标记。本文基于 docs/design/2026-09-03-outbound-session-id-header.md 设计文档,结合 packages/core/src/core/outbound-session-id.ts 及对应测试的实现细节,系统讲解该头的注入条件、精确主机匹配的安全边界、各模型 Provider 的接入方式,以及后续演进中允许用户在customHeaders中通过${session_id}占位符自行注入会话标识的两步启用方案。
背景与动机:复用现有 session ID 而非再造标识符
Routify 的 ModelRouter 接受session_id作为会话亲和(session-affinity)与流量标记(traffic-marking)值。Qwen Code 本身已经维护着一个稳定的会话 ID——它由 Config.getSessionId() 持有,但在本功能落地之前,这个 ID 只是本地元数据,从未随 LLM 请求到达 ModelRouter。
设计文档给出的动机非常明确:复用这个已有的会话 ID,让 Routify 在每个 CLI 会话生命周期内获得一个稳定的亲和值,而无需再创造另一个标识符。这避免了多套标识体系带来的关联困难与维护成本——网关按会话粘滞路由、按会话统计流量,而客户端侧无需新增任何状态。
核心机制:仅对三个 Routify 端点的精确主机匹配注入
内置注入的触发条件
Qwen Code 只有在出站 LLM 请求的主机名(hostname)是 ModelRouter 文档记载的三个 Routify 端点之一时,才把当前会话 ID 作为session_idHTTP 头附加:
| 主机名 | 说明 |
|---|---|
routify.alibaba-inc.com | Routify 端点 |
routify-online.alibaba-inc.com | Routify 在线端点 |
routify-pub.alibaba-inc.com | Routify 公开端点 |
这一固定集合定义在源码 outbound-session-id.ts 的SESSION_ID_HEADER_HOSTS常量中。核心判定函数buildSessionIdHeaders的逻辑(见 outbound-session-id.ts):
export function buildSessionIdHeaders( config: Config, destination: string | URL | Request, ): Record<string, string> { 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) { // 解析失败即返回空对象,fail closed return {}; } }安全边界:fail-closed 的精确匹配
设计文档强调,会话 ID 是跨请求稳定的标识符,因此实现上严格遵循以下约束:
- 强制 HTTPS:非
https:协议一律不注入; - 精确主机匹配:将解析后的请求主机名与上述三个主机做完全相等比较(
includes+toLowerCase),不使用后缀匹配(suffix matching)、通配符(wildcards)、路径匹配(path matching),也没有用户可配置的 allowlist; - 解析失败即关闭(fail closed):URL 解析异常(如
not a URL)时,requestUrl返回undefined,函数直接返回空对象,绝不误注入; - 主机名大小写归一:比较前对 hostname 做
toLowerCase(),避免因大小写变体绕过精确匹配。
这意味着sub.routify-pub.alibaba-inc.com、routify-preview.alibaba-inc.com、api.openai.com等“长得像”的主机都不会收到该头,测试用例 outbound-session-id.test.ts 对上述拒绝场景逐一做了验证。
内置头优先级:会话 ID 不容异议
在符合条件的请求上,Qwen Code 的会话 ID会替换请求中任何自定义的session_id值,确保亲和标记不会与当前活动会话相矛盾。其余所有已有请求头(包括授权头)都会被保留。这一“correlation > customHeaders”的优先级在wrapFetchWithSessionId中被刻意实现:先展开用户动态头,再写入内置的session_id(见 outbound-session-id.ts 及注释)。
重定向语义
初始目标检查之后,遵循标准 fetch 重定向行为:Routify 响应可以通过重定向请求把该头继续转发出去,内置机制不做额外干预。
请求生命周期:fetch 包装器与逐请求读取
为什么必须在每次请求前读取
OpenAI 兼容客户端与 Anthropic 客户端都会获得一个 fetch 包装器。包装器在每个 HTTP 请求即将发出之前调用Config.getSessionId()读取当前会话 ID(见 wrapFetchWithSessionId)。
这一点至关重要:/clear会开启一个新会话,但不会重建 SDK 客户端。如果会话 ID 在客户端构造时就被“烘焙”进默认头,那么/clear之后发出的请求仍会携带旧会话 ID,网关侧就会把新会话的请求错误地归入旧会话。逐请求读取则天然规避了这个问题——测试 outbound-session-id.test.ts 专门验证了会话从session-1轮换到session-2后,第二次请求头携带的是新值。
包装器的请求头合并语义
包装器在处理请求时会先合并两处请求头来源:
const headers = new Headers( input instanceof Request ? input.headers : undefined, ); new Headers(init?.headers).forEach((value, key) => headers.set(key, value));Request对象自带头 +init.headers中显式指定的头会先合并(init同名头覆盖 Request 头);- 之后才写入
session_id(以及展开的动态占位符头); - 最终以
fetchLike(input, { ...init, headers })发出。
对应测试覆盖了“保留 Request 对象携带的 Authorization 头”“合并 Request 与 init 头后注入”“空会话 ID 不发送”等场景(见 outbound-session-id.test.ts)。
运行时 fetch 的兜底
buildSessionAwareFetch支持两种形态:优先包装调用方传入的runtimeFetch(例如经buildRuntimeFetchOptions生成的代理感知 fetch),若未提供则回退到globalThis.fetch(见 outbound-session-id.ts)。测试同时覆盖了这两种路径。
Gemini 路径:请求级 httpOptions
与 OpenAI/Anthropic 不同,Gemini 请求走的是 SDK 的请求级httpOptions.headers。会话头会在 generate、流式 generate 与 embedding 请求上分别重建——每次请求调用 buildHttpOptions,动态展开占位符头与buildSessionIdHeaders的结果后合并进本次请求的 headers。
需要特别注意的是:Gemini 注入要求显式配置指向 Routify 的baseUrl;使用 SDK 隐式默认端点时,请求目标不是 Routify,因此不注入该头(llm-content-generator.ts 以httpOptions?.baseUrl ?? this.clientBaseUrl作为判定目标)。
Provider 覆盖矩阵
设计文档明确列出各 Provider 的接入方式,源码中也能逐一对应:
| Provider | 接入方式 | 源码位置 |
|---|---|---|
| 默认 OpenAI 兼容 Provider | 覆盖 Routify 的 OpenAI 协议;子类继承其客户端构造路径,同样生效 | default.ts 中fetch: buildSessionAwareFetch(...) |
| DashScope | 拥有独立的客户端构造函数,被显式集成 | dashscope.ts 中fetch: buildSessionAwareFetch(...) |
| Anthropic | 使用与 OpenAI 相同的逐请求 fetch 包装器 | anthropicContentGenerator.ts 中fetch: buildSessionAwareFetch(...) |
| Gemini / Vertex | 当 base URL 指向 Routify 时,使用请求级 HTTP options | llm-content-generator.ts |
从源码结构还可以看到,该包装器同样被复用在非 LLM 的 DashScope 工具调用上,例如 web 搜索工具 web-search-dashscope.ts 就使用了buildSessionAwareFetch——这说明会话关联层是共享的、可复用的基础设施。
明确不在范围内:非 LLM 流量、其他域名、MCP 请求、工具 fetch、子进程、traceparent、请求 ID 以及 body 元数据,均不参与本机制。
源码级验证:测试覆盖清单
设计文档的 Verification 章节描述的测试场景在 outbound-session-id.test.ts 中均有对应实现:
- 精确主机与 HTTPS 匹配:三个合法 Routify 主机命中(L27-L35),
http://明文与非法 URL 拒绝(L66-L87); - 相似主机拒绝:
sub.routify-pub.alibaba-inc.com(子域)、routify-preview.alibaba-inc.com(近似名)、api.openai.com(无关第三方)均不注入; - 无效 URL fail-closed:
not a URL直接透传原请求; - Request 与 init 头合并的保留与优先级:Authorization 保留、同名头 init 覆盖 Request、
session_id最后写入(L98-L138); - 空值处理:会话 ID 为空字符串时不发送该头(L89-L96);
- 会话轮换:同一包装器下第二次请求携带新会话 ID(L37-L64);
- 共享运行时 fetch 包装器:显式传入 runtimeFetch 与回退
globalThis.fetch两条路径(L140-L169)。
此外,Provider 层测试验证了 OpenAI 兼容构造路径确实安装了一个可工作的关联层;Gemini 测试覆盖了构造函数目标、生成、embedding 以及连续请求观察到会话 ID 变化等场景。
后续演进:customHeaders中的${session_id}占位符
设计文档将其标记为后续变更(对应 issue #10995),当前仓库中该能力已落地,并在用户文档 docs/users/configuration/model-providers.md 中完整记载,实现位于 outbound-dynamic-headers.ts。
能力描述
modelProviders[].generationConfig.customHeaders的值中可以包含占位符${session_id},它会在每个请求时用相同的Config.getSessionId()展开。适用于需要稳定按会话标识符的网关——例如 OpenCode Go 会拒绝缺少x-opencode-session的请求:
{ "modelProviders": { "openai": [ { "baseUrl": "https://your-gateway.example.com/v1", "generationConfig": { "customHeaders": { "x-opencode-session": "${session_id}" } } } ] } }由于值在请求时动态解析而非在 SDK 客户端构造时烘焙,/new与/resume切换会话后无需重启即可自动轮换。
两步启用与失败关闭语义
⚠️ 仅配置 provider 条目是不够的——占位符在全局开关outboundCorrelation.allowDynamicHeaderValues打开之前是惰性的(默认关闭):
{ "outboundCorrelation": { "allowDynamicHeaderValues": true } }在开关关闭、值为空或无法解析时,包含占位符的头会被丢弃而不是发出,字面量${session_id}永远不会出现在线路上。若用户配置了占位符但开关未开,Qwen Code 会在启动时打印一条警告,明确指出受影响的头名称与该设置项(见 warnIfDynamicHeadersDisabled)。resolveDynamicHeaderValue以三种方式 fail-closed(outbound-dynamic-headers.ts):开关关闭、占位符解析为空、Config无法应答。
该开关是全局的,因为它本质是一个同意(consent)决策,与“值发往哪里”分离:展开后的值会把实时会话状态带给接收方,因此开关只控制${session_id}是否允许展开,而不负责识别头来自哪个配置来源。
占位符的威胁模型回答
设计文档指出,这一后续演进正面回答了出站传播设计(见 docs/design/telemetry-outbound-propagation-design.md)第 12.7 节提出的威胁模型问题:
- 同意(Consent):占位符在全局开关
outboundCorrelation.allowDynamicHeaderValues打开前是惰性的(默认关闭);关闭、空值或无法解析的值都会丢弃该头,字面量${session_id}永不发送; - 接收方集合(Recipient set):由用户把该头附加到哪些 Provider 决定。作用域来自 provider 条目本身而非独立 allowlist——用户在书写
baseUrl时就已经选定了端点;不应接收该值的 Provider 只要不携带该头即可; - 去匿名化窗口(De-anonymization window):仅一个会话。该值在一次对话生命周期内保持稳定(这种稳定性正是网关粘滞路由所依赖的特性),并在
/new与/resume时轮换。接收方最多能把同一次对话的请求归组,而无法跨会话关联; - 逐请求 UUID 伴生值(Per-request UUID companion):刻意不提供。逐请求值会破坏网关所需的会话亲和;占位符集合被刻意封闭为一项,未经同等级评审不应扩充。
从实现上看,占位符集合确实被定义为封闭列表:PLACEHOLDERS目前仅含{ token: '${session_id}', resolve: (config) => config.getSessionId() }一项,且SESSION_ID_PLACEHOLDER_PATTERN会归一化$session_id、${session_id}、$QWEN_CODE_SESSION_ID、${QWEN_CODE_SESSION_ID}等等价拼写,防止设置插值把一个未受保护的写法转成静态值绕过闸门(outbound-dynamic-headers.ts)。
总结:内置关联层与用户扩展层的分工
session_id出站头的设计体现了一个清晰的分层思路:
- 内置层(built-in,不可配置):仅对三个精确匹配的 Routify 端点、仅 HTTPS、fail-closed,由 fetch 包装器在每次请求前注入,保证网关会话亲和标记永远正确;
- 扩展层(用户配置,需显式同意):
customHeaders中的${session_id}占位符,把同一会话标识符按用户意愿附加到任意自选 Provider,默认关闭,失败即丢弃,绝不泄漏字面量。
两层共享同一个Config.getSessionId()数据源、同一套逐请求解析语义、同一组轮换规则,且内置层在所有路径上保持对 customHeaders 条目的优先级。对于希望深入源码的读者,建议从 outbound-session-id.ts(核心注入逻辑)、outbound-dynamic-headers.ts(占位符展开与闸门)以及 outbound-session-id.test.ts(完整测试矩阵)三处入手,即可完整掌握该机制的实现全貌。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考