Qwen Code 出站 `session_id` 请求头:Routify 网关会话亲和标记的注入机制、安全边界与配置指南
2026/9/15 10:43:07 网站建设 项目流程

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.comRoutify 端点
routify-online.alibaba-inc.comRoutify 在线端点
routify-pub.alibaba-inc.comRoutify 公开端点

这一固定集合定义在源码 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.comroutify-preview.alibaba-inc.comapi.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 optionsllm-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-closednot 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出站头的设计体现了一个清晰的分层思路:

  1. 内置层(built-in,不可配置):仅对三个精确匹配的 Routify 端点、仅 HTTPS、fail-closed,由 fetch 包装器在每次请求前注入,保证网关会话亲和标记永远正确;
  2. 扩展层(用户配置,需显式同意)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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询