mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
refactor(telemetry): split outbound correlation out of telemetry scope (R4)
Address LaZzyMan round-8 follow-up review on PR #4390: even though R3's host allowlist made the default behavior safe, the meta-architectural concern remains: telemetry's namespace and consent flow shouldn't quietly extend to wire-level behavior aimed at third-party LLM provider request streams. The recipient sets differ; the consent decisions differ; they deserve separate namespaces, separate threat models, separate PRs. This commit (called R4 in the design doc) collapses the PR scope so it lands ONLY telemetry observability work: REMOVED from this PR: - packages/core/src/telemetry/llm-correlation-fetch.ts(.test.ts) - packages/core/src/telemetry/trusted-llm-hosts.ts(.test.ts) - telemetry.sessionIdHeaderHosts setting + Config getter + resolveTelemetrySettings wiring + settingsSchema entry - wrapFetchWithCorrelation usage from four provider construction points (default.ts, dashscope.ts, anthropicContentGenerator.ts, geminiContentGenerator/index.ts) - All session-id provider tests across the four providers + the contentGenerator.test.ts mock stub - "Session correlation header" section in telemetry.md ADDED: - OutboundCorrelationSettings interface in packages/core/src/config/config.ts, standalone top-level namespace separate from TelemetrySettings — SECURITY-RELEVANT label, all defaults off - Config.getOutboundCorrelationPropagateTraceContext() getter - outboundCorrelation top-level entry in settingsSchema.ts with propagateTraceContext: { default: false } and explicit SECURITY-RELEVANT framing in the description - CLI config-load pipeline passes settings.outboundCorrelation into ConfigParameters - NOOP_PROPAGATOR (TextMapPropagator no-op) in sdk.ts, conditionally installed on NodeSDK when propagateTraceContext is false (default). When true, omits textMapPropagator from NodeSDK options so the SDK keeps its default W3C composite propagator - 2 new sdk.test.ts cases covering the propagator gate behavior UNCHANGED: - UndiciInstrumentation registration + OTLP feedback-loop guard + HttpInstrumentation OTLP guard from R2/R3 stay intact — they are pure telemetry (client HTTP spans into the operator's own OTLP collector), no wire-level data egress - Documentation rewrites telemetry.md to split "client-side HTTP span on outbound fetch" (telemetry) from a new "Outbound correlation (SECURITY-RELEVANT)" top-level section - design doc gets R4 revision row + new §12 "R4 Scope Conflation Split" capturing the rationale and follow-up PR outline The session-id apparatus (R3 code) lives in git history at commits1c8528a56/cb162e716/7a1b4f8d0/40e1efc1f/106598ca2; the follow-up PR can cherry-pick or restore those files under the new outboundCorrelation.* namespace as LazzyMan suggested. Vscode-ide-companion settings.schema.json regenerated. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
parent
7a1b4f8d00
commit
9bdd3bd6f9
22 changed files with 334 additions and 1564 deletions
|
|
@ -7,13 +7,14 @@
|
|||
|
||||
## 修订历史
|
||||
|
||||
| 修订 | 日期 | 触发 | 摘要 |
|
||||
| ---- | ---------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| R1 | 2026-05-21 | 初稿 | 全广播:所有出站 LLM 请求都带 `X-Qwen-Code-Session-Id` + `traceparent` |
|
||||
| R2 | 2026-05-22 | wenshao R2/R3 review | 边界安全:URL normalize、port matching、quote 对齐、staticCorrelationHeaders try/catch、host:port fallback strip |
|
||||
| R3 | 2026-05-23 | LaZzyMan REQUEST_CHANGES | **重大语义改动**:`X-Qwen-Code-Session-Id` 默认作用域收窄到 first-party(Alibaba/DashScope)host 白名单。详见 §11 |
|
||||
| 修订 | 日期 | 触发 | 摘要 |
|
||||
| ---- | ---------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| R1 | 2026-05-21 | 初稿 | 全广播:所有出站 LLM 请求都带 `X-Qwen-Code-Session-Id` + `traceparent` |
|
||||
| R2 | 2026-05-22 | wenshao R2/R3 review | 边界安全:URL normalize、port matching、quote 对齐、staticCorrelationHeaders try/catch、host:port fallback strip |
|
||||
| R3 | 2026-05-23 | LaZzyMan REQUEST_CHANGES | **重大语义改动**:`X-Qwen-Code-Session-Id` 默认作用域收窄到 first-party(Alibaba/DashScope)host 白名单。详见 §11 |
|
||||
| R4 | 2026-05-25 | LaZzyMan round-8 follow-up (scope conflation) | **PR scope 大幅收窄**:本 PR 仅保留 client HTTP span + OTLP loop guard;`traceparent` 默认 off(NoopTextMapPropagator);新增 `outboundCorrelation.*` 顶级 namespace 放安全相关 toggle;R3 落地的整套 `X-Qwen-Code-Session-Id` 机器**移除本 PR**,搬到独立 follow-up PR。详见 §12 |
|
||||
|
||||
**特别提示**:阅读 §3.1(目标)/ §3.2(非目标)/ §4.3(Part B 设计)/ §4.4(配置 schema 影响)/ §5(文件改动清单)/ §9(与 claude-code 对比)/ §10(未来工作)时,请同时参考 §11 —— **R3 修订让上述章节中关于"统一向所有 LLM provider 发送 session id 不区分第一方/第三方"的论断不再成立**。原文保留作为决策路径记录。
|
||||
**特别提示**:阅读 §3.1(目标)/ §3.2(非目标)/ §4.3(Part B 设计)/ §4.4(配置 schema 影响)/ §5(文件改动清单)/ §9(与 claude-code 对比)/ §10(未来工作)/ §11(R3 host-allowlist scoping)时,请同时参考 §12 —— **R4 修订让 R1-R3 关于"本 PR 同时落地 traceparent + session id header"的论断不再成立**:本 PR 现仅为 telemetry observability + 独立的 outbound trace-context toggle,所有 outbound correlation header 工作(包括 R3 的 host allowlist)整体搬到独立 follow-up PR。R3 工作代码本身没浪费,挪到 follow-up PR 即可复用。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
|
|
@ -764,3 +765,102 @@ Gemini SDK 有两个不可见 default endpoint(`generativelanguage.googleapis.
|
|||
- **Per-request random UUID** (`X-Qwen-Code-Request-Id`) — LazzyMan 提的替代方案,列入 §10
|
||||
- **Gemini staleness lazy-invalidate** (§8.6 选项 A) — 与 R3 解耦,独立 sub-issue
|
||||
- **`matchesTrustedHost` IPv6 支持** — 当前 IPv6 destination 永不在 allowlist 上(`URL.hostname` 返回 `[::1]` 带方括号,pattern 语法无对应形式)。当前满足"命名 first-party endpoint"用例。若将来有 raw IP allowlist 需求再扩展。
|
||||
|
||||
## 12. R4 修订 — Scope Conflation Split
|
||||
|
||||
> 触发:[LaZzyMan round-8 follow-up review on PR #4390](https://github.com/QwenLM/qwen-code/pull/4390)
|
||||
> 落地:本 PR 收窄;R3 落地的 session-id 整套挪到独立 follow-up PR
|
||||
|
||||
### 12.1 触发与论证
|
||||
|
||||
R3 化解了 LaZzyMan 第一轮 review 的「广播稳定指纹给第三方 provider」担忧(severity: high)。但在 round-8 follow-up 中他升级到更深的架构原则反对:
|
||||
|
||||
> "Telemetry is not a container for adjacent features. The `traceparent` cross-process propagation and the `X-Qwen-Code-Session-Id` header injection are **not telemetry**. They are outbound-identity / outbound-correlation work that uses some OTel APIs internally as an implementation detail."
|
||||
|
||||
他的核心元论点:
|
||||
|
||||
- **"telemetry" namespace 暗示 recipient = 用户自己的 OTLP collector**
|
||||
- 但 `traceparent` 和 `X-Qwen-Code-Session-Id` 的 recipient = **第三方 LLM provider**
|
||||
- 两类不同 recipient 应该有两类不同的同意决策树
|
||||
- 即使默认行为安全(R3 已实现),把 wire-level 行为放在 `telemetry.*` 下**设了坏先例**:未来 telemetry PR 可以继续偷渡 wire 行为给第三方
|
||||
- "If we accept that principle, the split is mechanical. If we don't, this PR is the wrong place to debate it because the technical fixes are already in."
|
||||
|
||||
### 12.2 解法概要("方案 C" hybrid split)
|
||||
|
||||
经过几轮内部讨论(含 yiliang 提出的 customHeader 模板替代方案,最终判定 customHeader 不能携带 runtime-dynamic 值),决定走 **方案 C**:
|
||||
|
||||
**本 PR 留下**:
|
||||
|
||||
- `UndiciInstrumentation` 注册(产 client HTTP span → 用户自家 OTLP collector)
|
||||
- OTLP feedback-loop guard(前者的必要副作用)
|
||||
- **`NoopTextMapPropagator` 默认安装** → `propagation.inject()` 是 no-op → outbound `fetch` 上**不再有 `traceparent`**
|
||||
- **新增 `outboundCorrelation.propagateTraceContext: bool` (默认 false)** 作为独立 namespace 顶级设置;设 true 时安装默认 W3C composite propagator
|
||||
- 整套 `R3 session-id` 代码(`llm-correlation-fetch.ts` / `trusted-llm-hosts.ts` / `telemetry.sessionIdHeaderHosts` setting / 4 个 provider 集成点 / 所有相关测试)**全部移除**
|
||||
|
||||
**搬到 follow-up PR**:
|
||||
|
||||
- `X-Qwen-Code-Session-Id` header 整套机器(R3 实现复用)
|
||||
- 进入新 `outboundCorrelation.*` namespace(具体 setting key TBD,但**不会**叫 `telemetry.*`)
|
||||
- Follow-up PR 自带:threat model section、独立 review、security-relevant 标注的 docs
|
||||
- `X-Qwen-Code-Request-Id` per-request UUID(LazzyMan 在 R3 round 提出的替代设计)也归入此 follow-up 的考虑范围
|
||||
|
||||
### 12.3 与 R3 R1 论点的映射
|
||||
|
||||
| R1/R3 论点 | R4 后状态 |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| §3.1 "所有出站 LLM 请求带 traceparent" | ❌ **R4 默认 off**;需 `outboundCorrelation.propagateTraceContext: true` 才开 |
|
||||
| §3.1 "所有出站 LLM 请求带 `X-Qwen-Code-Session-Id`" | ❌ **R4 整套移出本 PR**,搬到 follow-up PR |
|
||||
| §4.3 fetch wrapper 注入 session id | ❌ 整段代码不在本 PR;复用到 follow-up PR |
|
||||
| §11 host allowlist (R3 设计) | ❌ 同上;整体迁移 follow-up PR |
|
||||
| §4.4 不引入新 setting | ❌ **本 PR 新增 `outboundCorrelation.propagateTraceContext`** 一个 boolean;session id 相关 setting 在 follow-up PR |
|
||||
| §10 future work "`X-Qwen-Code-Request-Id`" | ✅ 仍是 future work;与 session-id follow-up 一起设计 |
|
||||
|
||||
### 12.4 新 namespace 设计意图
|
||||
|
||||
`outboundCorrelation.*` 顶级 namespace 在本 PR 只有一个 boolean (`propagateTraceContext`),看起来过度结构化。但这是**精心选择的**:
|
||||
|
||||
- **建立命名空间作为承诺**:让后续 session-id / request-id / etc. 自然进入这个 namespace
|
||||
- **标注为 security-relevant**:`settingsSchema.ts` description 显式写 "SECURITY-RELEVANT",文档化为"安全设置"而非"observability 设置"
|
||||
- **defaults 全部 off**:符合 LazzyMan 提出的"open-source 客户端不应未经显式同意向第三方发稳定 id"原则
|
||||
- **与 telemetry.\* 解耦**:用户读 settings.json 看到 `outboundCorrelation.*` 立刻能识别这是出站 wire 行为,不是 observability
|
||||
|
||||
### 12.5 实施
|
||||
|
||||
| 文件 | 改动 |
|
||||
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/core/src/telemetry/llm-correlation-fetch.ts` | **删除** |
|
||||
| `packages/core/src/telemetry/llm-correlation-fetch.test.ts` | **删除** |
|
||||
| `packages/core/src/telemetry/trusted-llm-hosts.ts` | **删除** |
|
||||
| `packages/core/src/telemetry/trusted-llm-hosts.test.ts` | **删除** |
|
||||
| `packages/core/src/telemetry/sdk.ts` | + `NoopTextMapPropagator`;按 `getOutboundCorrelationPropagateTraceContext()` 决定 SDK textMapPropagator |
|
||||
| `packages/core/src/core/openaiContentGenerator/provider/default.ts` | 移除 `wrapFetchWithCorrelation` 引用 |
|
||||
| `packages/core/src/core/openaiContentGenerator/provider/dashscope.ts` | 同上 |
|
||||
| `packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts` | 同上 |
|
||||
| `packages/core/src/core/geminiContentGenerator/index.ts` | 移除 `staticCorrelationHeaders` 引用 |
|
||||
| 上述 4 个 provider 的 `*.test.ts` | 删 session-id 相关测试 case |
|
||||
| `packages/core/src/config/config.ts` | 删 `TelemetrySettings.sessionIdHeaderHosts`、`getTelemetrySessionIdHeaderHosts`;**新增 `OutboundCorrelationSettings` 接口 + `outboundCorrelationSettings` 字段 + `getOutboundCorrelationPropagateTraceContext()` getter** |
|
||||
| `packages/core/src/telemetry/config.ts` | 删 `resolveTelemetrySettings` 中 sessionIdHeaderHosts 透传 |
|
||||
| `packages/cli/src/config/settingsSchema.ts` | 删 `sessionIdHeaderHosts` schema;**新增 `outboundCorrelation` 顶级 schema 项** |
|
||||
| `packages/cli/src/config/config.ts` | 透传 `outboundCorrelation: settings.outboundCorrelation` 进 `ConfigParameters` |
|
||||
| `packages/vscode-ide-companion/schemas/settings.schema.json` | `npm run generate:settings-schema` 重新生成 |
|
||||
| `docs/developers/development/telemetry.md` | 重写 "Trace context propagation" → "Client-side HTTP span on outbound fetch";删 "Session correlation header" 整节;新增 "Outbound correlation (SECURITY-RELEVANT)" 顶级 section |
|
||||
| `docs/design/telemetry-outbound-propagation-design.md` | 本节 + R4 表头 + 修订指针 |
|
||||
|
||||
### 12.6 对 LazzyMan 元论点的回应
|
||||
|
||||
| 论点 | R4 后状态 |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| "Telemetry namespace 暗示自家 collector 接收方" | ✅ wire 行为已搬出 `telemetry.*`;新 `outboundCorrelation.*` namespace 显式标识"出站第三方"语义 |
|
||||
| "默认行为不应未经显式同意向第三方发标识符" | ✅ `propagateTraceContext` 默认 false;session-id 整套 follow-up PR 也将默认 off |
|
||||
| "telemetry PR 不应偷渡 wire-level 行为" | ✅ 本 PR 不再添加任何"telemetry 控制 wire 行为"的代码路径;wire 行为统一由 `outboundCorrelation.*` 管 |
|
||||
| "split is mechanical, work isn't wasted" | ✅ R3 落地代码物理删除自本 branch,留在 git history 里给 follow-up PR 复用(或 cherry-pick) |
|
||||
|
||||
### 12.7 follow-up PR 大纲(信息性,不在本 PR 范围)
|
||||
|
||||
未来 follow-up PR 应包含:
|
||||
|
||||
- `outboundCorrelation.sessionIdHeader: { enabled, trustedHosts }` 或类似 setting
|
||||
- 复用 R3 已实现的 `wrapFetchWithCorrelation` / `matchesTrustedHost` / `DEFAULT_SESSION_ID_HEADER_HOSTS` 代码骨架
|
||||
- threat model 一节,明确:recipient 集合、稳定 id 的去匿名化窗口、可选 per-request UUID 配套
|
||||
- **默认 off**(无 default allowlist —— 比 R3 更严,符合 LazzyMan 的开源 CLI 原则)
|
||||
- security-relevant 标注 + docs/users/configuration/settings.md 收录
|
||||
|
|
|
|||
|
|
@ -262,25 +262,23 @@ and logs still carry `session.id`, and trace / log backends (Jaeger, Tempo,
|
|||
Loki, Aliyun SLS / ARMS Tracing) handle per-session slicing natively without
|
||||
cardinality pressure.
|
||||
|
||||
### Trace context propagation
|
||||
### Client-side HTTP span on outbound fetch
|
||||
|
||||
When telemetry is enabled, Qwen Code instruments outgoing `fetch()` requests
|
||||
(used by `openai`, `@google/genai`, and `@anthropic-ai/sdk`) and automatically
|
||||
injects the standard W3C `traceparent` header on every LLM service call:
|
||||
When telemetry is enabled, Qwen Code registers `UndiciInstrumentation`
|
||||
which creates a client-side HTTP span for every outbound `fetch()`
|
||||
request originated by the process — including the LLM SDKs (`openai`,
|
||||
`@google/genai`, `@anthropic-ai/sdk`), the MCP StreamableHTTP client, the
|
||||
`WebFetch` tool, and any IDE-extension out-of-process calls. The span
|
||||
lets you see network latency (TTFB / response body transfer) separately
|
||||
from upstream model processing time, which the existing
|
||||
`api.generateContent` span alone can't distinguish.
|
||||
|
||||
```
|
||||
traceparent: 00-<32-hex traceId>-<16-hex parentSpanId>-<01-sampled | 00-not-sampled>
|
||||
```
|
||||
|
||||
The traceId is the qwen-code interaction trace id; the parent span is the
|
||||
active `api.generateContent` span. Any OTel-instrumented LLM service (e.g.
|
||||
DashScope serving from an ARMS Tracing backend) that reads this header will
|
||||
make its server-side spans children of qwen-code's trace, giving you a single
|
||||
end-to-end trace tree across the process boundary.
|
||||
|
||||
You also get a free client-side HTTP span per LLM call — useful for separating
|
||||
network latency (TTFB / response body transfer) from upstream model processing
|
||||
time, which the existing `api.generateContent` span alone can't distinguish.
|
||||
These spans go to your **own** OTLP collector (or file outfile) just like
|
||||
the rest of the telemetry — they do not affect what is written onto the
|
||||
outbound HTTP request itself. Whether the W3C `traceparent` header is
|
||||
also written into the outgoing request stream is controlled by a
|
||||
**separate, security-relevant setting** documented in
|
||||
[outbound correlation](#outbound-correlation-security-relevant) below.
|
||||
|
||||
**Feedback-loop avoidance.** OTel SDK uses `fetch` internally to upload OTLP
|
||||
data. Without protection, instrumenting `fetch` would trace those uploads,
|
||||
|
|
@ -291,98 +289,53 @@ URLs matching the configured `telemetry.otlpEndpoint` /
|
|||
`telemetry.otlpMetricsEndpoint` prefixes. In file-outfile mode there are no
|
||||
outbound HTTP uploads, so the hook is a no-op.
|
||||
|
||||
**Scope: all `fetch()` calls, not just LLM.** The undici instrumentation
|
||||
patches `globalThis.fetch` for the entire process, so a client span +
|
||||
`traceparent` injection happens on every outbound `fetch` — including the
|
||||
`WebFetch` tool, MCP StreamableHTTP clients, and any IDE-extension
|
||||
out-of-process calls — not only LLM SDK traffic. Two consequences worth
|
||||
knowing:
|
||||
## Outbound correlation (SECURITY-RELEVANT)
|
||||
|
||||
- **Trace ID leakage.** The W3C `traceparent` header carries the trace ID
|
||||
to every destination, including third-party URLs supplied at runtime
|
||||
(e.g. a `WebFetch` to an arbitrary domain). The trace ID is not a
|
||||
secret per the W3C spec — the value is `sha256(sessionId).slice(0, 32)`
|
||||
(a one-way hash, not the session id itself). Operators with stricter
|
||||
requirements who don't want even the hashed trace ID reaching
|
||||
third-party endpoints can disable telemetry entirely
|
||||
(`telemetry.enabled: false`); a per-destination scoping toggle for
|
||||
trace propagation specifically is tracked as a follow-up.
|
||||
- **Span volume.** Non-LLM `fetch` calls show up as client HTTP spans in
|
||||
your OTLP backend. Filter on `http.url` or `peer.service` if you want
|
||||
to isolate the LLM-only subset.
|
||||
These settings live in a **separate top-level namespace** from `telemetry.*`
|
||||
on purpose: telemetry controls data flow into the operator's own
|
||||
observability backend, while `outboundCorrelation.*` controls what
|
||||
client-side correlation data qwen-code writes **into outbound LLM API
|
||||
request streams** that reach third-party LLM provider endpoints
|
||||
(DashScope, OpenAI, Anthropic, etc.). Different recipients, different
|
||||
consent decision. **All values default to off.** See PR #4390 review
|
||||
discussion for the framing rationale.
|
||||
|
||||
### Session correlation header
|
||||
|
||||
Alongside `traceparent`, Qwen Code can inject a custom HTTP header on
|
||||
outbound LLM requests when telemetry is enabled:
|
||||
|
||||
```
|
||||
X-Qwen-Code-Session-Id: <session id>
|
||||
```
|
||||
|
||||
Pattern matched from Claude Code's `X-Claude-Code-Session-Id` (see
|
||||
`src/services/api/client.ts:108` in the claude-code repo). The header is
|
||||
product-namespaced to avoid collision with generic `X-Session-Id` headers
|
||||
other tools may inject. Server-side ingestion (e.g. a DashScope
|
||||
gateway or an OTLP-aware API gateway) can use this header to stitch its
|
||||
observation of an LLM request back to the originating Qwen Code session
|
||||
without having to parse trace context.
|
||||
|
||||
**Default scope: first-party Alibaba/DashScope endpoints only.** The
|
||||
header is sent only to destinations whose hostname matches the
|
||||
`telemetry.sessionIdHeaderHosts` allowlist. The default allowlist is:
|
||||
|
||||
```
|
||||
dashscope.aliyuncs.com
|
||||
dashscope-intl.aliyuncs.com
|
||||
*.dashscope.aliyuncs.com
|
||||
*.dashscope-intl.aliyuncs.com
|
||||
*.alibaba-inc.com
|
||||
*.aliyun-inc.com
|
||||
```
|
||||
|
||||
Requests to third-party LLM providers (OpenAI, Anthropic, OpenRouter,
|
||||
MiniMax, ModelScope, Mistral, DeepSeek, vanilla Gemini, ...) **do not
|
||||
receive this header by default** — avoids broadcasting a stable
|
||||
client-side session identifier to providers who don't need it for the
|
||||
API call itself. See [PR #4390 review discussion](https://github.com/QwenLM/qwen-code/pull/4390)
|
||||
for the full rationale.
|
||||
|
||||
Operators with broader correlation requirements can override the scope
|
||||
in `settings.json`:
|
||||
### `outboundCorrelation.propagateTraceContext`
|
||||
|
||||
```jsonc
|
||||
"telemetry": {
|
||||
"sessionIdHeaderHosts": ["*"] // restore broadcast
|
||||
"sessionIdHeaderHosts": [] // fully disable header
|
||||
"sessionIdHeaderHosts": ["api.mycompany.com",
|
||||
"*.gateway.mycompany.internal"] // custom allowlist
|
||||
"outboundCorrelation": {
|
||||
"propagateTraceContext": false // default
|
||||
}
|
||||
```
|
||||
|
||||
The header value comes from `Config.getSessionId()`, read fresh on every
|
||||
outbound request (not captured at SDK construction time) on the OpenAI/
|
||||
Anthropic providers. After a session reset triggered by `/clear`,
|
||||
subsequent requests carry the new session id automatically — see the
|
||||
"Known limitation" note below for the one exception.
|
||||
When `false` (default), Qwen Code installs a no-op `TextMapPropagator` on
|
||||
the OTel SDK. UndiciInstrumentation still creates client HTTP spans for
|
||||
your OTLP collector, but `propagation.inject()` is a no-op so **no
|
||||
`traceparent` is written onto outbound requests**. Trace IDs stay
|
||||
internal to the operator's collector.
|
||||
|
||||
Empty session ids are not emitted (some HTTP middleware rejects empty
|
||||
header values). The header is omitted entirely when telemetry is disabled
|
||||
or the destination host is outside the allowlist.
|
||||
When `true`, the SDK's default W3C composite propagator
|
||||
(`tracecontext` + `baggage`) is installed and the standard `traceparent`
|
||||
header is written on every outbound `fetch`:
|
||||
|
||||
**Known limitation: Gemini provider.** `@google/genai`'s `HttpOptions`
|
||||
interface does not expose a `fetch` hook (only static `headers`), so the
|
||||
Gemini provider can only inject the session-id header at SDK construction
|
||||
time. The host-allowlist check happens once against the SDK's
|
||||
`baseUrl` (defaults to `generativelanguage.googleapis.com` — not on the
|
||||
default allowlist, so no header by default). When operators do put the
|
||||
Gemini SDK on a first-party endpoint via `baseUrl` and the allowlist
|
||||
matches, the header IS attached but goes stale on `/clear` until the
|
||||
content generator is recreated. All other providers (`openai`-family,
|
||||
`anthropic`) use a fetch wrapper and are immune to this. Tracked as a
|
||||
follow-up; in the meantime, spans and logs still carry the live session
|
||||
id, so trace/log backends can correctly attribute requests to the new
|
||||
session.
|
||||
```
|
||||
traceparent: 00-<32-hex traceId>-<16-hex parentSpanId>-<01-sampled | 00-not-sampled>
|
||||
```
|
||||
|
||||
Opt in only when the LLM provider also reports into your OTel collector
|
||||
for cross-process trace stitching — e.g. ARMS Tracing serving DashScope.
|
||||
For most operators the value is `false`; cross-vendor trace continuation
|
||||
is niche.
|
||||
|
||||
### 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.
|
||||
|
||||
## Aliyun Telemetry
|
||||
|
||||
|
|
|
|||
|
|
@ -1710,6 +1710,7 @@ export async function loadCliConfig(
|
|||
screenReader,
|
||||
},
|
||||
telemetry: telemetrySettings,
|
||||
outboundCorrelation: settings.outboundCorrelation,
|
||||
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
|
||||
clearContextOnIdle: settings.context?.clearContextOnIdle,
|
||||
fileFiltering: settings.context?.fileFiltering,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
MCPServerConfig,
|
||||
BugCommandSettings,
|
||||
TelemetrySettings,
|
||||
OutboundCorrelationSettings,
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
ModelProvidersConfig,
|
||||
|
|
@ -1032,17 +1033,34 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
sessionIdHeaderHosts: {
|
||||
description:
|
||||
'Destination hostnames (or "*.suffix" patterns) that receive the X-Qwen-Code-Session-Id outbound correlation header. Defaults to Alibaba/DashScope first-party endpoints (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, *.dashscope.aliyuncs.com, *.dashscope-intl.aliyuncs.com, *.alibaba-inc.com, *.aliyun-inc.com) so the stable session identifier is not broadcast to third-party LLM providers like OpenAI or Anthropic. Set to ["*"] to restore the broadcast-to-everywhere behavior, or [] to fully disable the header.',
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
|
||||
outboundCorrelation: {
|
||||
type: 'object',
|
||||
label: 'Outbound Correlation',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as OutboundCorrelationSettings | undefined,
|
||||
description:
|
||||
"SECURITY-RELEVANT. Controls what client-side correlation data qwen-code writes into outbound LLM API requests (DashScope, OpenAI, Anthropic, etc.) — separate from `telemetry.*` which governs data flow into the operator's OWN OTLP collector. All values default to off. Opt in only when the LLM provider also reports into your OTel collector for cross-process trace stitching (e.g. ARMS Tracing + DashScope).",
|
||||
showInDialog: false,
|
||||
jsonSchemaOverride: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
propagateTraceContext: {
|
||||
description:
|
||||
"Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Note: client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
||||
fastModel: {
|
||||
type: 'string',
|
||||
label: 'Fast Model',
|
||||
|
|
|
|||
|
|
@ -314,23 +314,6 @@ export interface TelemetrySettings {
|
|||
resourceAttributes?: Record<string, string>;
|
||||
/** Per-signal cardinality controls. */
|
||||
metrics?: TelemetryMetricsSettings;
|
||||
/**
|
||||
* Hostnames (or `*.suffix` patterns) that receive the
|
||||
* `X-Qwen-Code-Session-Id` outbound correlation header.
|
||||
*
|
||||
* Default (when unset): Alibaba/DashScope first-party endpoints only —
|
||||
* see `DEFAULT_SESSION_ID_HEADER_HOSTS` in `telemetry/trusted-llm-hosts.ts`.
|
||||
* The default is deliberately narrow so the stable session identifier
|
||||
* does not get broadcast to arbitrary third-party LLM providers
|
||||
* (OpenAI, Anthropic, OpenRouter, ...) configured under the
|
||||
* OpenAI-compatible provider. PR #4390 review (LaZzyMan) for rationale.
|
||||
*
|
||||
* Overrides:
|
||||
* - `[]` fully disables the header (no destinations)
|
||||
* - `["*"]` restores the broadcast behavior across all destinations
|
||||
* - `["api.mycompany.com", "*.internal.mycompany.com"]` custom allowlist
|
||||
*/
|
||||
sessionIdHeaderHosts?: string[];
|
||||
/**
|
||||
* Human-readable diagnostics produced while resolving
|
||||
* `resourceAttributes` (drops, coercions, reserved-key strips).
|
||||
|
|
@ -354,6 +337,42 @@ export interface TelemetryMetricsSettings {
|
|||
includeSessionId?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security-relevant settings controlling what client-side correlation
|
||||
* data qwen-code writes into outbound LLM API requests.
|
||||
*
|
||||
* **Why this is a separate namespace from `telemetry.*`:** telemetry
|
||||
* controls data flow into the user's OWN observability backend (OTLP
|
||||
* collector / file outfile). The settings here control data flow OUT of
|
||||
* the qwen-code process and INTO third-party LLM provider request
|
||||
* streams (DashScope, OpenAI, Anthropic, etc.). Different recipients =
|
||||
* different consent decision, so a different settings tree. See PR
|
||||
* #4390 review (LaZzyMan) for the framing rationale.
|
||||
*
|
||||
* All values default to off / no propagation. Operators who want to
|
||||
* propagate trace context for server-side trace stitching (e.g. ARMS
|
||||
* Tracing + DashScope) opt in explicitly.
|
||||
*/
|
||||
export interface OutboundCorrelationSettings {
|
||||
/**
|
||||
* Inject W3C `traceparent` header on outbound HTTP requests
|
||||
* originated by undici / global `fetch` (LLM SDK calls, MCP
|
||||
* StreamableHTTP clients, WebFetch tool, etc.). Default: `false`.
|
||||
*
|
||||
* When `false`, the SDK is configured with a no-op
|
||||
* `TextMapPropagator` so trace context stays internal to the user's
|
||||
* OTLP collector (operator still gets client HTTP spans, but the
|
||||
* trace id is not written onto third-party request streams).
|
||||
*
|
||||
* When `true`, the OTel default W3C composite propagator
|
||||
* (`tracecontext` + `baggage`) is installed and `traceparent` is
|
||||
* written on every outbound `fetch`. Useful when the LLM provider
|
||||
* also reports into the operator's OTel collector — e.g. ARMS
|
||||
* Tracing + DashScope — for cross-process trace stitching.
|
||||
*/
|
||||
propagateTraceContext?: boolean;
|
||||
}
|
||||
|
||||
export interface OutputSettings {
|
||||
format?: OutputFormat;
|
||||
}
|
||||
|
|
@ -582,6 +601,7 @@ export interface ConfigParameters {
|
|||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
telemetry?: TelemetrySettings;
|
||||
outboundCorrelation?: OutboundCorrelationSettings;
|
||||
gitCoAuthor?: GitCoAuthorParam;
|
||||
usageStatisticsEnabled?: boolean;
|
||||
/**
|
||||
|
|
@ -871,6 +891,7 @@ export class Config {
|
|||
private autoModeDenialState: AutoModeDenialState = createDenialState();
|
||||
private readonly accessibility: AccessibilitySettings;
|
||||
private readonly telemetrySettings: TelemetrySettings;
|
||||
private readonly outboundCorrelationSettings: OutboundCorrelationSettings;
|
||||
private readonly gitCoAuthor: GitCoAuthorSettings;
|
||||
private readonly usageStatisticsEnabled: boolean;
|
||||
private readonly fileReadCacheDisabled: boolean;
|
||||
|
|
@ -1037,9 +1058,12 @@ export class Config {
|
|||
outfile: params.telemetry?.outfile,
|
||||
resourceAttributes: params.telemetry?.resourceAttributes,
|
||||
metrics: params.telemetry?.metrics,
|
||||
sessionIdHeaderHosts: params.telemetry?.sessionIdHeaderHosts,
|
||||
resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings,
|
||||
};
|
||||
this.outboundCorrelationSettings = {
|
||||
propagateTraceContext:
|
||||
params.outboundCorrelation?.propagateTraceContext ?? false,
|
||||
};
|
||||
this.gitCoAuthor = {
|
||||
...normalizeGitCoAuthor(params.gitCoAuthor),
|
||||
name: 'Qwen-Coder',
|
||||
|
|
@ -2867,21 +2891,19 @@ export class Config {
|
|||
return this.telemetrySettings.metrics?.includeSessionId ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured host allowlist for the
|
||||
* `X-Qwen-Code-Session-Id` outbound header, or `undefined` to indicate
|
||||
* "use `DEFAULT_SESSION_ID_HEADER_HOSTS`". The caller (`llm-correlation-fetch`)
|
||||
* resolves the default — keeping it out of Config avoids importing the
|
||||
* telemetry helper from here.
|
||||
*/
|
||||
getTelemetrySessionIdHeaderHosts(): readonly string[] | undefined {
|
||||
return this.telemetrySettings.sessionIdHeaderHosts;
|
||||
}
|
||||
|
||||
getTelemetryResourceAttributeWarnings(): readonly string[] {
|
||||
return this.telemetrySettings.resourceAttributeWarnings ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to inject W3C `traceparent` on outbound `fetch` requests
|
||||
* (LLM SDKs, MCP, WebFetch, etc.). Default false — see
|
||||
* `OutboundCorrelationSettings` for rationale.
|
||||
*/
|
||||
getOutboundCorrelationPropagateTraceContext(): boolean {
|
||||
return this.outboundCorrelationSettings.propagateTraceContext ?? false;
|
||||
}
|
||||
|
||||
getTelemetryOutfile(): string | undefined {
|
||||
return this.telemetrySettings.outfile;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,30 +106,6 @@ describe('AnthropicContentGenerator', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('installs the correlation fetch wrapper on the Anthropic client', async () => {
|
||||
const { AnthropicContentGenerator } = await importGenerator();
|
||||
void new AnthropicContentGenerator(
|
||||
{
|
||||
model: 'claude-test',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
timeout: 10_000,
|
||||
maxRetries: 2,
|
||||
samplingParams: {},
|
||||
schemaCompliance: 'auto',
|
||||
},
|
||||
mockConfig,
|
||||
);
|
||||
const fetchFn = anthropicState.constructorOptions?.['fetch'] as
|
||||
| unknown
|
||||
| undefined;
|
||||
// Wrapper installed regardless of telemetry-enabled state — per-call
|
||||
// header injection is gated inside the wrapper itself. Exhaustive
|
||||
// behavior is in llm-correlation-fetch.test.ts.
|
||||
expect(typeof fetchFn).toBe('function');
|
||||
expect(fetchFn).not.toBe(globalThis.fetch);
|
||||
});
|
||||
|
||||
it('uses claude-cli identity (User-Agent + x-app + Bearer auth) for non-Anthropic baseURLs', async () => {
|
||||
// Non-Anthropic-native baseURL → IdeaLab-style proxy path:
|
||||
// - User-Agent presents as `claude-cli/<version> (external, cli)`
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import {
|
|||
buildRuntimeFetchOptions,
|
||||
redactProxyError,
|
||||
} from '../../utils/runtimeFetchOptions.js';
|
||||
import { wrapFetchWithCorrelation } from '../../telemetry/llm-correlation-fetch.js';
|
||||
import { DEFAULT_TIMEOUT } from '../openaiContentGenerator/constants.js';
|
||||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||
import { runtimeDiagnostics } from '../../utils/runtimeDiagnostics.js';
|
||||
|
|
@ -204,13 +203,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
// key as `X-Api-Key` to the IdeaLab proxy — leaking the credential to
|
||||
// a third-party endpoint. Explicit `null` suppresses the back-fill
|
||||
// and forces the intended auth path.
|
||||
// Wrap fetch with per-request correlation header injection. Read the
|
||||
// base fetch from runtimeOptions (proxy-aware) when present, else
|
||||
// globalThis. See design doc §4.3 — fetch wrapper (not defaultHeaders)
|
||||
// is required so the header tracks live session id across /clear resets.
|
||||
const baseFetch =
|
||||
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
|
||||
globalThis.fetch;
|
||||
this.client = new Anthropic({
|
||||
...(useProxyIdentity
|
||||
? { authToken: contentGeneratorConfig.apiKey, apiKey: null }
|
||||
|
|
@ -220,17 +212,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||
maxRetries: contentGeneratorConfig.maxRetries,
|
||||
defaultHeaders,
|
||||
...runtimeOptions,
|
||||
// Cast through unknown: Anthropic SDK's `Fetch` type uses the older
|
||||
// DOM-style `RequestInfo` (no URL) which `typeof fetch` (Node WHATWG
|
||||
// overloads) is not structurally assignable to, even though they're
|
||||
// call-compatible at runtime. The cast is safe because the wrapper
|
||||
// delegates to baseFetch (= runtimeOptions.fetch ?? globalThis.fetch)
|
||||
// without altering its call shape.
|
||||
fetch: wrapFetchWithCorrelation(
|
||||
baseFetch,
|
||||
this.cliConfig,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as unknown as any,
|
||||
});
|
||||
|
||||
this.converter = new AnthropicContentConverter(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ describe('createContentGenerator', () => {
|
|||
getCliVersion: () => '1.0.0',
|
||||
getTelemetryEnabled: () => false,
|
||||
getSessionId: () => 'test-session',
|
||||
getTelemetrySessionIdHeaderHosts: () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
const mockGenerator = {
|
||||
|
|
@ -62,7 +61,6 @@ describe('createContentGenerator', () => {
|
|||
getCliVersion: () => '1.0.0',
|
||||
getTelemetryEnabled: () => false,
|
||||
getSessionId: () => 'test-session',
|
||||
getTelemetrySessionIdHeaderHosts: () => undefined,
|
||||
} as unknown as Config;
|
||||
const mockGenerator = {
|
||||
models: {},
|
||||
|
|
|
|||
|
|
@ -91,103 +91,4 @@ describe('createGeminiContentGenerator', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits X-Qwen-Code-Session-Id from httpOptions.headers when telemetry is disabled', () => {
|
||||
const config = {
|
||||
model: 'gemini-1.5-flash',
|
||||
apiKey: 'k',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
};
|
||||
createGeminiContentGenerator(config, mockConfig);
|
||||
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as
|
||||
| { httpOptions?: { headers?: Record<string, string> } }
|
||||
| undefined;
|
||||
expect(callArgs?.httpOptions?.headers).toBeDefined();
|
||||
expect(callArgs?.httpOptions?.headers ?? {}).not.toHaveProperty(
|
||||
'X-Qwen-Code-Session-Id',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits X-Qwen-Code-Session-Id for vanilla Gemini endpoint even when telemetry is enabled (third-party scope)', () => {
|
||||
// PR #4390 review (LaZzyMan): the session id header is scoped to
|
||||
// first-party (Alibaba/DashScope) destinations by default. A vanilla
|
||||
// Gemini API call resolves to `generativelanguage.googleapis.com`,
|
||||
// which is NOT on the default allowlist, so no header.
|
||||
mockConfig = {
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
|
||||
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
const config = {
|
||||
model: 'gemini-1.5-flash',
|
||||
apiKey: 'k',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
};
|
||||
createGeminiContentGenerator(config, mockConfig);
|
||||
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
|
||||
httpOptions: { headers: Record<string, string> };
|
||||
};
|
||||
expect(callArgs.httpOptions.headers).not.toHaveProperty(
|
||||
'X-Qwen-Code-Session-Id',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes X-Qwen-Code-Session-Id when baseUrl points at a trusted DashScope endpoint', () => {
|
||||
mockConfig = {
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
|
||||
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
const config = {
|
||||
model: 'qwen-vl-plus',
|
||||
apiKey: 'k',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
// Operator has pointed the Gemini SDK at a DashScope-compatible
|
||||
// endpoint via baseUrl override. This IS on the default allowlist.
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/api/v1',
|
||||
};
|
||||
createGeminiContentGenerator(config, mockConfig);
|
||||
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
|
||||
httpOptions: { headers: Record<string, string> };
|
||||
};
|
||||
expect(callArgs.httpOptions.headers['X-Qwen-Code-Session-Id']).toBe(
|
||||
'sess-gemini',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits X-Qwen-Code-Session-Id when baseUrl is unset, even with allowlist override (factory fail-closed for unknown destination)', () => {
|
||||
// The factory only passes a destinationUrl to staticCorrelationHeaders
|
||||
// when `config.baseUrl` is set, because the Gemini SDK has two
|
||||
// invisible defaults (generativelanguage.googleapis.com for public,
|
||||
// {region}-aiplatform.googleapis.com for Vertex). Guessing one would
|
||||
// mis-bucket the other under a googleapis-covering allowlist override.
|
||||
// Operators who want correlation against Google endpoints must set
|
||||
// `baseUrl` explicitly.
|
||||
mockConfig = {
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('sess-gemini'),
|
||||
getTelemetrySessionIdHeaderHosts: vi.fn().mockReturnValue(['*']),
|
||||
} as unknown as Config;
|
||||
const config = {
|
||||
model: 'gemini-1.5-flash',
|
||||
apiKey: 'k',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
};
|
||||
createGeminiContentGenerator(config, mockConfig);
|
||||
const callArgs = vi.mocked(GeminiContentGenerator).mock.calls[0]?.[0] as {
|
||||
httpOptions: { headers: Record<string, string> };
|
||||
};
|
||||
expect(callArgs.httpOptions.headers).not.toHaveProperty(
|
||||
'X-Qwen-Code-Session-Id',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
} from '../contentGenerator.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { InstallationManager } from '../../utils/installationManager.js';
|
||||
import { staticCorrelationHeaders } from '../../telemetry/llm-correlation-fetch.js';
|
||||
|
||||
export { GeminiContentGenerator } from './geminiContentGenerator.js';
|
||||
|
||||
|
|
@ -39,27 +38,6 @@ export function createGeminiContentGenerator(
|
|||
'x-gemini-api-privileged-user-id': `${installationId}`,
|
||||
};
|
||||
}
|
||||
// Merge the session-id correlation header. `@google/genai`'s HttpOptions
|
||||
// does not expose a `fetch` hook (unlike `openai` / `@anthropic-ai/sdk`),
|
||||
// so we can only inject a static header here — captured at construction.
|
||||
// Known limitation: after a `/clear`-triggered session reset, the Gemini
|
||||
// SDK's cached headers retain the OLD session id until the contentGenerator
|
||||
// is recreated. See design doc §8.6 + #4384 follow-up sub-issue tracking.
|
||||
//
|
||||
// Destination resolution: only pass when we KNOW the destination — i.e.
|
||||
// when the operator has explicitly set `config.baseUrl`. The SDK has two
|
||||
// different invisible defaults (`generativelanguage.googleapis.com` for
|
||||
// public Gemini, `{region}-aiplatform.googleapis.com` for Vertex AI) and
|
||||
// guessing wrong here would mis-bucket Vertex traffic if the operator
|
||||
// overrides `telemetry.sessionIdHeaderHosts` to include any googleapis
|
||||
// domain. Passing `undefined` makes `staticCorrelationHeaders` return
|
||||
// `{}` (no header) — the fail-closed default. Operators who want
|
||||
// correlation against a Google endpoint set `baseUrl` explicitly
|
||||
// (which is the same input the SDK uses to resolve the destination).
|
||||
headers = {
|
||||
...headers,
|
||||
...staticCorrelationHeaders(gcConfig, config.baseUrl),
|
||||
};
|
||||
const httpOptions = config.baseUrl
|
||||
? {
|
||||
headers,
|
||||
|
|
|
|||
|
|
@ -492,17 +492,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('installs the correlation fetch wrapper on the OpenAI client', () => {
|
||||
provider.buildClient();
|
||||
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
// Wrapper is always installed; per-request behavior depends on
|
||||
// telemetry-enabled (verified end-to-end in llm-correlation-fetch.test.ts).
|
||||
expect(typeof callArg.fetch).toBe('function');
|
||||
expect(callArg.fetch).not.toBe(globalThis.fetch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMetadata', () => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import type {
|
|||
ChatCompletionToolWithCache,
|
||||
} from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import { wrapFetchWithCorrelation } from '../../../telemetry/llm-correlation-fetch.js';
|
||||
import { createDebugLogger } from '../../../utils/debugLogger.js';
|
||||
import { DefaultOpenAICompatibleProvider } from './default.js';
|
||||
|
||||
|
|
@ -139,9 +138,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
|
|||
'openai',
|
||||
this.cliConfig.getProxy(),
|
||||
);
|
||||
const baseFetch =
|
||||
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
|
||||
globalThis.fetch;
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
|
|
@ -149,7 +145,6 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr
|
|||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(runtimeOptions || {}),
|
||||
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -168,50 +168,6 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('installs the correlation fetch wrapper on the OpenAI client', () => {
|
||||
provider.buildClient();
|
||||
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
// Wrapper is always installed (so it can read live config per request);
|
||||
// per-call header injection behavior is verified exhaustively in
|
||||
// llm-correlation-fetch.test.ts.
|
||||
expect(typeof callArg.fetch).toBe('function');
|
||||
expect(callArg.fetch).not.toBe(globalThis.fetch);
|
||||
});
|
||||
|
||||
it('wraps the proxy fetch (not globalThis.fetch) when runtimeOptions provides one', async () => {
|
||||
// Regression guard for design §4.3: when proxy is configured,
|
||||
// buildRuntimeFetchOptions returns { fetch: <bundled undici fetch> }
|
||||
// so the proxy dispatcher and fetch share a single undici version.
|
||||
// The correlation wrapper must wrap THAT fetch, not globalThis.fetch.
|
||||
const proxyFetch = vi.fn(
|
||||
async () => new Response(),
|
||||
) as unknown as typeof fetch;
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai', proxyUrl?: string) => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue({
|
||||
fetch: proxyFetch,
|
||||
} as OpenAIRuntimeFetchOptions);
|
||||
provider.buildClient();
|
||||
const callArg = vi.mocked(OpenAI).mock.calls[0]![0] as {
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
expect(typeof callArg.fetch).toBe('function');
|
||||
// Wrapped, not raw — the wrapper is a different function reference.
|
||||
expect(callArg.fetch).not.toBe(proxyFetch);
|
||||
expect(callArg.fetch).not.toBe(globalThis.fetch);
|
||||
// Positive assertion (PR #4390 review): the negatives above pass
|
||||
// for ANY wrapper, including a buggy one that wraps globalThis.fetch
|
||||
// instead of proxyFetch. Call the wrapped fetch and verify the
|
||||
// delegation target is actually proxyFetch (telemetry off in
|
||||
// mockCliConfig, so wrapper short-circuits straight to baseFetch).
|
||||
await callArg.fetch!('https://api.example.com/v1');
|
||||
expect(proxyFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRequest', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
|||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import type { OpenAICompatibleProvider } from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import { wrapFetchWithCorrelation } from '../../../telemetry/llm-correlation-fetch.js';
|
||||
import {
|
||||
tokenLimit,
|
||||
CAPPED_DEFAULT_MAX_TOKENS,
|
||||
|
|
@ -89,13 +88,6 @@ export class DefaultOpenAICompatibleProvider
|
|||
'openai',
|
||||
this.cliConfig.getProxy(),
|
||||
);
|
||||
// Wrap fetch with per-request correlation header injection. Read the base
|
||||
// fetch from runtimeOptions (proxy-aware) when present, else globalThis.
|
||||
// Spread runtimeOptions FIRST, then override `fetch:` so our wrapper wraps
|
||||
// the proxy fetch instead of being replaced by it. See design doc §4.3.
|
||||
const baseFetch =
|
||||
(runtimeOptions as { fetch?: typeof fetch } | undefined)?.fetch ??
|
||||
globalThis.fetch;
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
|
|
@ -103,7 +95,6 @@ export class DefaultOpenAICompatibleProvider
|
|||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(runtimeOptions || {}),
|
||||
fetch: wrapFetchWithCorrelation(baseFetch, this.cliConfig),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,6 @@ export async function resolveTelemetrySettings(options: {
|
|||
outfile,
|
||||
resourceAttributes,
|
||||
metrics: { includeSessionId: metricsIncludeSessionId },
|
||||
sessionIdHeaderHosts: settings.sessionIdHeaderHosts,
|
||||
resourceAttributeWarnings: resourceAttributeWarnings.length
|
||||
? resourceAttributeWarnings
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -1,662 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { diag } from '@opentelemetry/api';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
SESSION_ID_HEADER,
|
||||
staticCorrelationHeaders,
|
||||
wrapFetchWithCorrelation,
|
||||
} from './llm-correlation-fetch.js';
|
||||
|
||||
// Local alias for tests; the public helper is generic.
|
||||
type FetchLike = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function mockConfig(opts: {
|
||||
enabled?: boolean;
|
||||
sessionId?: string | (() => string);
|
||||
/**
|
||||
* Host allowlist returned by `getTelemetrySessionIdHeaderHosts`.
|
||||
*
|
||||
* - Key OMITTED: returns `['*']` (broadcast) so the bulk of tests below
|
||||
* — which exercise header-injection mechanics, not host-gating —
|
||||
* keep operating against the `api.example.com` test URLs.
|
||||
* - Key PRESENT and `undefined`: returns `undefined`, letting the
|
||||
* wrapper fall back to `DEFAULT_SESSION_ID_HEADER_HOSTS` (the real
|
||||
* default allowlist). Used by host-gate tests.
|
||||
* - Key PRESENT and an array: returns that array verbatim.
|
||||
*/
|
||||
hosts?: readonly string[];
|
||||
}): Config {
|
||||
const hostsKeyPresent = 'hosts' in opts;
|
||||
return {
|
||||
getTelemetryEnabled: () => opts.enabled ?? true,
|
||||
getSessionId: () =>
|
||||
typeof opts.sessionId === 'function'
|
||||
? opts.sessionId()
|
||||
: (opts.sessionId ?? ''),
|
||||
getTelemetrySessionIdHeaderHosts: () =>
|
||||
hostsKeyPresent ? opts.hosts : ['*'],
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
// Typed fetch mock returning a recorded-args view that avoids the
|
||||
// `mock.calls[0]![1] as RequestInit` cast — calls is properly typed as
|
||||
// `[input, init?][]` and `init` is optional.
|
||||
function makeFetchMock(): {
|
||||
fetch: FetchLike;
|
||||
spy: ReturnType<typeof vi.fn>;
|
||||
lastInit: () => RequestInit | undefined;
|
||||
lastInput: () => string | URL | Request | undefined;
|
||||
callCount: () => number;
|
||||
} {
|
||||
const spy = vi.fn(
|
||||
async (_input: string | URL | Request, _init?: RequestInit) =>
|
||||
new Response(),
|
||||
);
|
||||
return {
|
||||
fetch: spy as unknown as FetchLike,
|
||||
spy,
|
||||
lastInit: () =>
|
||||
spy.mock.calls.length > 0
|
||||
? (spy.mock.calls[spy.mock.calls.length - 1]?.[1] as
|
||||
| RequestInit
|
||||
| undefined)
|
||||
: undefined,
|
||||
lastInput: () =>
|
||||
spy.mock.calls.length > 0
|
||||
? (spy.mock.calls[spy.mock.calls.length - 1]?.[0] as
|
||||
| string
|
||||
| URL
|
||||
| Request
|
||||
| undefined)
|
||||
: undefined,
|
||||
callCount: () => spy.mock.calls.length,
|
||||
};
|
||||
}
|
||||
|
||||
describe('wrapFetchWithCorrelation', () => {
|
||||
it('attaches X-Qwen-Code-Session-Id when telemetry is enabled', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: true, sessionId: 'sess-A' }),
|
||||
);
|
||||
await wrapped('https://api.example.com/v1/chat');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach header when telemetry is disabled (passes init through unchanged)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const userInit = { method: 'POST', headers: { 'X-Custom': 'keep' } };
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: false, sessionId: 'sess-A' }),
|
||||
);
|
||||
await wrapped('https://api.example.com/v1/chat', userInit);
|
||||
expect(m.spy).toHaveBeenCalledWith(
|
||||
'https://api.example.com/v1/chat',
|
||||
userInit,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach header when sessionId is empty (skips defensively)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: true, sessionId: '' }),
|
||||
);
|
||||
await wrapped('https://api.example.com/v1/chat');
|
||||
expect(m.spy).toHaveBeenCalledWith(
|
||||
'https://api.example.com/v1/chat',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides existing same-name header on init (server gets real session id, not user-supplied spoof)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: true, sessionId: 'real-sess' }),
|
||||
);
|
||||
await wrapped('https://api.example.com/v1/chat', {
|
||||
headers: { [SESSION_ID_HEADER]: 'spoofed' },
|
||||
});
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'real-sess',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves other headers and init fields when injecting', async () => {
|
||||
const m = makeFetchMock();
|
||||
const signal = new AbortController().signal;
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: true, sessionId: 'sess' }),
|
||||
);
|
||||
await wrapped('https://api.example.com/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer sk-xxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{"x":1}',
|
||||
signal,
|
||||
});
|
||||
const init = m.lastInit()!;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe('{"x":1}');
|
||||
expect(init.signal).toBe(signal);
|
||||
const h = init.headers as Headers;
|
||||
expect(h.get('Authorization')).toBe('Bearer sk-xxx');
|
||||
expect(h.get('Content-Type')).toBe('application/json');
|
||||
expect(h.get(SESSION_ID_HEADER)).toBe('sess');
|
||||
});
|
||||
|
||||
it('reads fresh session id after a session reset (staleness regression — design §4.3 critical)', async () => {
|
||||
let current = 'sess-A';
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: true, sessionId: () => current }),
|
||||
);
|
||||
|
||||
await wrapped('https://api.example.com/1');
|
||||
expect(
|
||||
(
|
||||
(m.spy.mock.calls[0]?.[1] as RequestInit | undefined)?.headers as
|
||||
| Headers
|
||||
| undefined
|
||||
)?.get(SESSION_ID_HEADER),
|
||||
).toBe('sess-A');
|
||||
|
||||
// Simulate /clear updating Config.sessionId without recreating SDK clients.
|
||||
current = 'sess-B';
|
||||
|
||||
await wrapped('https://api.example.com/2');
|
||||
expect(
|
||||
(
|
||||
(m.spy.mock.calls[1]?.[1] as RequestInit | undefined)?.headers as
|
||||
| Headers
|
||||
| undefined
|
||||
)?.get(SESSION_ID_HEADER),
|
||||
).toBe('sess-B');
|
||||
});
|
||||
|
||||
it('propagates baseFetch rejection unchanged', async () => {
|
||||
const err = new Error('network unreachable');
|
||||
const spy = vi.fn(async () => {
|
||||
throw err;
|
||||
});
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
spy as unknown as FetchLike,
|
||||
mockConfig({ enabled: true, sessionId: 'sess' }),
|
||||
);
|
||||
await expect(wrapped('https://api.example.com/x')).rejects.toBe(err);
|
||||
});
|
||||
|
||||
it('preserves Request input headers when init is undefined (defends Authorization etc.)', async () => {
|
||||
// PR #4393 review feedback: previously `new Headers(init?.headers)` with
|
||||
// undefined init dropped the Request's own headers (e.g. Authorization)
|
||||
// because we then passed `{...init, headers}` which had only our session
|
||||
// header. Fix seeds from input.headers when input is a Request.
|
||||
const m = makeFetchMock();
|
||||
const req = new Request('https://api.example.com/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer sk-xxx',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({ enabled: true, sessionId: 'sess' }),
|
||||
);
|
||||
await wrapped(req);
|
||||
const init = m.lastInit()!;
|
||||
const h = init.headers as Headers;
|
||||
expect(h.get('Authorization')).toBe('Bearer sk-xxx');
|
||||
expect(h.get('Content-Type')).toBe('application/json');
|
||||
expect(h.get(SESSION_ID_HEADER)).toBe('sess');
|
||||
});
|
||||
|
||||
it('falls through to baseFetch + diag.warn when header construction throws (telemetry never breaks LLM)', async () => {
|
||||
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
|
||||
const m = makeFetchMock();
|
||||
// Config getter that throws — simulates a runtime bug that must not
|
||||
// propagate and break the LLM request. Use a TRUSTED destination
|
||||
// (broadcast allowlist) so we reach the throwing getSessionId() — a
|
||||
// third-party destination would short-circuit at the host gate before
|
||||
// ever calling getSessionId.
|
||||
const config = {
|
||||
getTelemetryEnabled: () => true,
|
||||
getSessionId: () => {
|
||||
throw new Error('config bug');
|
||||
},
|
||||
getTelemetrySessionIdHeaderHosts: () => ['*'],
|
||||
} as unknown as Config;
|
||||
const wrapped = wrapFetchWithCorrelation(m.fetch, config);
|
||||
const userInit = { method: 'POST' };
|
||||
const res = await wrapped('https://api.example.com/v1/chat', userInit);
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
// baseFetch was called with the ORIGINAL init (no correlation header added)
|
||||
expect(m.spy).toHaveBeenCalledWith(
|
||||
'https://api.example.com/v1/chat',
|
||||
userInit,
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('correlation header'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('staticCorrelationHeaders', () => {
|
||||
// Tests pass `hosts: ['*']` (broadcast) unless they're specifically
|
||||
// exercising the host gate, since broadcast was the original behavior
|
||||
// before the LaZzyMan-review-driven scope narrowing.
|
||||
const TRUSTED = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
|
||||
it('returns header when telemetry enabled and sessionId non-empty', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({ enabled: true, sessionId: 'sess-A' }),
|
||||
TRUSTED,
|
||||
),
|
||||
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
|
||||
});
|
||||
|
||||
it('returns {} when telemetry disabled', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({ enabled: false, sessionId: 'sess-A' }),
|
||||
TRUSTED,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when sessionId is empty', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({ enabled: true, sessionId: '' }),
|
||||
TRUSTED,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when destinationUrl is undefined (fail closed)', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({ enabled: true, sessionId: 'sess-A' }),
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when destinationUrl host is not on the trusted allowlist', () => {
|
||||
// Default allowlist is Alibaba/DashScope only. A vanilla Gemini API call
|
||||
// to googleapis.com should NOT receive the header. PR #4390 review
|
||||
// (LaZzyMan): "the header should not be broadcast to every LLM provider".
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined, // use real default allowlist
|
||||
}),
|
||||
'https://generativelanguage.googleapis.com/v1beta',
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('returns header when destinationUrl host matches the default allowlist (DashScope)', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined, // use real default allowlist
|
||||
}),
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
),
|
||||
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
|
||||
});
|
||||
|
||||
it('returns header when destinationUrl host matches the default allowlist (internal alibaba-inc)', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined,
|
||||
}),
|
||||
'https://idealab.alibaba-inc.com/api/openai/v1',
|
||||
),
|
||||
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
|
||||
});
|
||||
|
||||
it('respects ["*"] override to restore broadcast', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: ['*'],
|
||||
}),
|
||||
'https://api.openai.com/v1',
|
||||
),
|
||||
).toEqual({ [SESSION_ID_HEADER]: 'sess-A' });
|
||||
});
|
||||
|
||||
it('respects [] override to fully disable', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: [],
|
||||
}),
|
||||
TRUSTED,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when destinationUrl is unparseable', () => {
|
||||
expect(
|
||||
staticCorrelationHeaders(
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined,
|
||||
}),
|
||||
'not a url',
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('captures session id at call time — caller responsible for re-invoking on reset (Gemini staleness §8.6)', () => {
|
||||
// Document by behavior: this helper takes a snapshot. Caller (e.g.
|
||||
// geminiContentGenerator/index.ts factory) calls it once and bakes the
|
||||
// value into SDK-construction `httpOptions.headers`. A session reset
|
||||
// after construction won't update the header — known limitation.
|
||||
let current = 'sess-A';
|
||||
const config = mockConfig({ enabled: true, sessionId: () => current });
|
||||
const snapshot = staticCorrelationHeaders(config, TRUSTED);
|
||||
current = 'sess-B';
|
||||
expect(snapshot[SESSION_ID_HEADER]).toBe('sess-A');
|
||||
});
|
||||
|
||||
it('returns {} (does not throw) when allowlist is a bare string instead of array', () => {
|
||||
// Same defensive normalization as wrapFetchWithCorrelation. Bare
|
||||
// string from a malformed settings.json must not crash construction.
|
||||
const malformedConfig = {
|
||||
getTelemetryEnabled: () => true,
|
||||
getSessionId: () => 'sess-A',
|
||||
getTelemetrySessionIdHeaderHosts: () =>
|
||||
'dashscope.aliyuncs.com' as unknown as readonly string[],
|
||||
} as unknown as Config;
|
||||
// Falls back to DEFAULT — DashScope destination matches.
|
||||
expect(staticCorrelationHeaders(malformedConfig, TRUSTED)).toEqual({
|
||||
[SESSION_ID_HEADER]: 'sess-A',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns {} (does not throw) when allowlist array contains non-string entries', () => {
|
||||
const malformedConfig = {
|
||||
getTelemetryEnabled: () => true,
|
||||
getSessionId: () => 'sess-A',
|
||||
getTelemetrySessionIdHeaderHosts: () =>
|
||||
[null, 'dashscope.aliyuncs.com', 42] as unknown as
|
||||
| readonly string[]
|
||||
| undefined,
|
||||
} as unknown as Config;
|
||||
expect(staticCorrelationHeaders(malformedConfig, TRUSTED)).toEqual({
|
||||
[SESSION_ID_HEADER]: 'sess-A',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls through to {} when config getters throw (telemetry must never break LLM path)', () => {
|
||||
// Mirror the safety contract of `wrapFetchWithCorrelation`. This helper
|
||||
// is called from the Gemini factory at construction time, so a throw
|
||||
// here would propagate up and crash content-generator init for the
|
||||
// whole session. PR #4390 review feedback (wenshao).
|
||||
const warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {});
|
||||
const exploding = {
|
||||
getTelemetryEnabled: () => {
|
||||
throw new Error('config exploded');
|
||||
},
|
||||
getSessionId: () => 'unreached',
|
||||
getTelemetrySessionIdHeaderHosts: () => ['*'],
|
||||
} as unknown as Config;
|
||||
expect(staticCorrelationHeaders(exploding, TRUSTED)).toEqual({});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('staticCorrelationHeaders'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapFetchWithCorrelation — host allowlist gating', () => {
|
||||
// Dedicated block for the LaZzyMan-review-driven host gate. Uses the
|
||||
// real default allowlist (no `hosts: ['*']` override) and exercises
|
||||
// both the trusted-host pass and the third-party-host skip.
|
||||
|
||||
it('injects header for default-allowlisted host (dashscope.aliyuncs.com)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined, // real default allowlist
|
||||
}),
|
||||
);
|
||||
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1/chat');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('injects header for sub-domain of allowlisted suffix (*.alibaba-inc.com)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined,
|
||||
}),
|
||||
);
|
||||
await wrapped('https://idealab.alibaba-inc.com/api/openai/v1');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips header for third-party host (api.openai.com) under default allowlist', async () => {
|
||||
// This is the core LaZzyMan-review fix: the stable session id no longer
|
||||
// gets broadcast to third-party LLM providers by default.
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined,
|
||||
}),
|
||||
);
|
||||
await wrapped('https://api.openai.com/v1/chat/completions');
|
||||
expect(m.lastInit()?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips header for third-party host (api.anthropic.com) under default allowlist', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined,
|
||||
}),
|
||||
);
|
||||
await wrapped('https://api.anthropic.com/v1/messages');
|
||||
expect(m.lastInit()?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects ["*"] override to restore broadcast behavior', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: ['*'],
|
||||
}),
|
||||
);
|
||||
await wrapped('https://api.openai.com/v1/chat/completions');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('tolerates whitespace around "*" so [" * "] still enables broadcast', async () => {
|
||||
// Defensive: settings.json hand-edits are easy to get a stray space
|
||||
// wrong. Without trim() the operator would silently get "no host
|
||||
// matches" instead of broadcast.
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: [' * '],
|
||||
}),
|
||||
);
|
||||
await wrapped('https://api.openai.com/v1/chat/completions');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('tolerates whitespace around host patterns (parity with "*" trim)', async () => {
|
||||
// Same defensive normalization applies to host patterns, not just the
|
||||
// broadcast wildcard. Without it, `" dashscope.aliyuncs.com"` would
|
||||
// silently never match — inconsistent with the `[" * "]` tolerance.
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: [' dashscope.aliyuncs.com '],
|
||||
}),
|
||||
);
|
||||
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw at buildClient when settings returns a bare string instead of array (malformed JSON)', async () => {
|
||||
// Regression: `broadcastAll` computation was outside the safety
|
||||
// try/catch. A typo like `"sessionIdHeaderHosts": "dashscope..."`
|
||||
// (forgot brackets) would let `.some(...)` throw TypeError at
|
||||
// wrap time — bricking buildClient and the LLM session.
|
||||
const m = makeFetchMock();
|
||||
const malformedConfig = {
|
||||
getTelemetryEnabled: () => true,
|
||||
getSessionId: () => 'sess-A',
|
||||
getTelemetrySessionIdHeaderHosts: () =>
|
||||
'dashscope.aliyuncs.com' as unknown as readonly string[],
|
||||
} as unknown as Config;
|
||||
// The wrap itself must not throw — fall back to default allowlist.
|
||||
expect(() =>
|
||||
wrapFetchWithCorrelation(m.fetch, malformedConfig),
|
||||
).not.toThrow();
|
||||
const wrapped = wrapFetchWithCorrelation(m.fetch, malformedConfig);
|
||||
// Default allowlist applies → header IS attached for DashScope.
|
||||
await wrapped('https://dashscope.aliyuncs.com/v1');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when settings array contains non-string entries (malformed JSON)', async () => {
|
||||
// Regression: `[null, "*.dashscope.aliyuncs.com"]` typo placeholder
|
||||
// would let `null.trim()` throw in the broadcastAll predicate.
|
||||
const m = makeFetchMock();
|
||||
const malformedConfig = {
|
||||
getTelemetryEnabled: () => true,
|
||||
getSessionId: () => 'sess-A',
|
||||
getTelemetrySessionIdHeaderHosts: () =>
|
||||
[null, 'dashscope.aliyuncs.com', undefined, 42] as unknown as
|
||||
| readonly string[]
|
||||
| undefined,
|
||||
} as unknown as Config;
|
||||
expect(() =>
|
||||
wrapFetchWithCorrelation(m.fetch, malformedConfig),
|
||||
).not.toThrow();
|
||||
const wrapped = wrapFetchWithCorrelation(m.fetch, malformedConfig);
|
||||
// Non-string entries filtered out, surviving string matched.
|
||||
await wrapped('https://dashscope.aliyuncs.com/v1');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('respects [] override to fully disable injection', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: [],
|
||||
}),
|
||||
);
|
||||
// Even an otherwise-trusted destination is skipped when allowlist is [].
|
||||
await wrapped('https://dashscope.aliyuncs.com/compatible-mode/v1');
|
||||
expect(m.lastInit()?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects custom allowlist (operator-supplied host)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: ['gateway.mycompany.internal'],
|
||||
}),
|
||||
);
|
||||
await wrapped('https://gateway.mycompany.internal/llm/v1');
|
||||
expect((m.lastInit()?.headers as Headers).get(SESSION_ID_HEADER)).toBe(
|
||||
'sess-A',
|
||||
);
|
||||
// Default allowlist hosts are NOT included when operator overrides.
|
||||
await wrapped('https://dashscope.aliyuncs.com/v1');
|
||||
expect(m.lastInit()?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips header when destination URL is unparseable (fail closed)', async () => {
|
||||
const m = makeFetchMock();
|
||||
const wrapped = wrapFetchWithCorrelation(
|
||||
m.fetch,
|
||||
mockConfig({
|
||||
enabled: true,
|
||||
sessionId: 'sess-A',
|
||||
hosts: undefined,
|
||||
}),
|
||||
);
|
||||
await wrapped('not a valid url');
|
||||
expect(m.lastInit()?.headers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { diag } from '@opentelemetry/api';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS,
|
||||
extractRequestHost,
|
||||
matchesTrustedHost,
|
||||
} from './trusted-llm-hosts.js';
|
||||
|
||||
/**
|
||||
* Custom HTTP header attached to outbound LLM service requests when
|
||||
* telemetry is enabled AND the destination is on the trusted-host
|
||||
* allowlist. Product-namespaced (matching claude-code's
|
||||
* `X-Claude-Code-Session-Id` pattern from `src/services/api/client.ts:108`)
|
||||
* to avoid collision with generic `X-Session-Id` headers other tools may
|
||||
* inject. Server-side ingestion can use this to stitch its observation of
|
||||
* an LLM request back to the originating qwen-code session.
|
||||
*
|
||||
* Scope: see `DEFAULT_SESSION_ID_HEADER_HOSTS` for the default destination
|
||||
* set and PR #4390 review (LaZzyMan) for the rationale behind not
|
||||
* broadcasting to every third-party LLM provider.
|
||||
*/
|
||||
export const SESSION_ID_HEADER = 'X-Qwen-Code-Session-Id';
|
||||
|
||||
/**
|
||||
* Loose fetch-like signature the wrapper internally programs against.
|
||||
*
|
||||
* Exists because the SDKs we wrap have **incompatible** Fetch types:
|
||||
* - `openai@5.11`: `(input: string | URL | Request, init?) => Promise<Response>`
|
||||
* - `@anthropic-ai/sdk`: `(input: RequestInfo, init?) => Promise<Response>`
|
||||
* where `RequestInfo = string | Request` (NOT including URL).
|
||||
*
|
||||
* No single concrete signature satisfies both as a structural subtype, so the
|
||||
* public `wrapFetchWithCorrelation` is generic and preserves the caller's
|
||||
* exact type (the SDK's own Fetch). This type only describes the input shape
|
||||
* we touch inside the wrapper.
|
||||
*/
|
||||
type FetchLikeLoose = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Wrap a fetch implementation so outbound requests to trusted LLM
|
||||
* destinations get the `X-Qwen-Code-Session-Id` correlation header
|
||||
* populated from the **current** session id, not the value captured
|
||||
* when the SDK client was constructed.
|
||||
*
|
||||
* Three gates, in order:
|
||||
* 1. Telemetry enabled (`config.getTelemetryEnabled()`)
|
||||
* 2. Destination host is on the trusted allowlist
|
||||
* (`config.getTelemetrySessionIdHeaderHosts()` ?? default in-vendor set)
|
||||
* 3. Session id is non-empty
|
||||
*
|
||||
* Any failed gate falls through to `baseFetch(input, init)` unchanged —
|
||||
* no header attached, no behavior change. This means a request to
|
||||
* `api.openai.com` from a default-config install goes out exactly the
|
||||
* same as it did before this PR landed.
|
||||
*
|
||||
* Why per-request and not `defaultHeaders`: SDK clients (and their static
|
||||
* `defaultHeaders`) are constructed once at content-generator init and are
|
||||
* NOT recreated on `/clear`-triggered session reset (`Config.resetSession()`
|
||||
* updates `this.sessionId` but doesn't rebuild the contentGenerator). A
|
||||
* static header would therefore go stale immediately after the first reset.
|
||||
* Reading `config.getSessionId()` from inside the wrapper on every call
|
||||
* gives the live value.
|
||||
*
|
||||
* Note on `trustedHosts`: snapshotted once at wrap time, not read per
|
||||
* request. The session id is live-read but the allowlist is not — a
|
||||
* mid-session change to `telemetry.sessionIdHeaderHosts` in settings.json
|
||||
* takes effect at next content-generator init (any change that mutates
|
||||
* Config snapshot the wrapper retains is by definition a Config-level
|
||||
* concern, not a request-time concern). Operators tuning the scope live
|
||||
* should restart, or call the openai/anthropic clients via a fresh
|
||||
* provider after settings reload.
|
||||
*
|
||||
* The caller is responsible for choosing the base fetch — usually
|
||||
* `runtimeOptions?.fetch ?? globalThis.fetch` so proxy-aware fetch (set up
|
||||
* by `buildRuntimeFetchOptions`) is preserved when ProxyAgent is in use.
|
||||
*
|
||||
* Safety: the wrapper catches its own exceptions and falls through to
|
||||
* baseFetch on any internal error. Telemetry must never break the LLM
|
||||
* request path — if Config getters throw, header construction fails, etc.,
|
||||
* we still want the model call to proceed.
|
||||
*/
|
||||
export function wrapFetchWithCorrelation<TFetch extends FetchLikeLoose>(
|
||||
baseFetch: TFetch,
|
||||
config: Config,
|
||||
): TFetch {
|
||||
// Resolve the host allowlist once at wrap time (Config snapshot).
|
||||
// Operators override via `telemetry.sessionIdHeaderHosts` in
|
||||
// settings.json (e.g. `["*.api.openai.com"]` for a specific OpenAI-
|
||||
// compatible proxy, or `["*"]` to restore the broadcast behavior the
|
||||
// initial design proposed).
|
||||
//
|
||||
// Defensive normalization (PR #4390 review feedback): qwen-code's
|
||||
// settings loader does not enforce JSON schema at runtime, so the
|
||||
// getter can legitimately return a malformed value — a bare string
|
||||
// ("dashscope.aliyuncs.com" instead of ["dashscope.aliyuncs.com"]),
|
||||
// an array containing non-strings, or whitespace-padded entries
|
||||
// (" * " or " dashscope.aliyuncs.com"). The chain below:
|
||||
// 1. catches a throwing getter (mock without stub, pre-getter Config)
|
||||
// 2. rejects a non-array value (bare string typo) → default allowlist
|
||||
// 3. filters out non-string elements ([null, "..."] typo placeholder)
|
||||
// 4. trims every surviving entry uniformly, so the `*` broadcast
|
||||
// escape hatch and the host-pattern match path have parity
|
||||
// Violating any of these would let `.includes / .some / matchesTrustedHost`
|
||||
// throw at buildClient time — bricking the LLM session before the first
|
||||
// prompt and violating the "telemetry must never break the LLM request
|
||||
// path" contract that `staticCorrelationHeaders` already honors via its
|
||||
// end-to-end try/catch.
|
||||
let trustedHosts: readonly string[];
|
||||
try {
|
||||
const raw =
|
||||
config.getTelemetrySessionIdHeaderHosts?.() ??
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS;
|
||||
trustedHosts = Array.isArray(raw)
|
||||
? raw
|
||||
.filter((p): p is string => typeof p === 'string')
|
||||
.map((p) => p.trim())
|
||||
: DEFAULT_SESSION_ID_HEADER_HOSTS;
|
||||
} catch {
|
||||
trustedHosts = DEFAULT_SESSION_ID_HEADER_HOSTS;
|
||||
}
|
||||
// Wildcard escape hatch so operators who want the old broadcast
|
||||
// behavior can opt in via `["*"]` without us extending the pattern
|
||||
// grammar in `matchesTrustedHost` (which would tempt other globbing).
|
||||
// Pre-trimmed above, so `.includes('*')` covers `["*"]` / `[" * "]` /
|
||||
// `["\t*\n"]` uniformly.
|
||||
const broadcastAll = trustedHosts.includes('*');
|
||||
|
||||
const wrapped: FetchLikeLoose = async function correlationFetch(
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
let headers: Headers;
|
||||
try {
|
||||
if (!config.getTelemetryEnabled()) {
|
||||
return baseFetch(input, init);
|
||||
}
|
||||
if (!broadcastAll) {
|
||||
// Host gate: skip injection for destinations not on the allowlist.
|
||||
// This is what scopes the "stable client fingerprint" exposure to
|
||||
// first-party endpoints only. PR #4390 review (LaZzyMan).
|
||||
const host = extractRequestHost(input);
|
||||
if (!host || !matchesTrustedHost(host, trustedHosts)) {
|
||||
return baseFetch(input, init);
|
||||
}
|
||||
}
|
||||
const sid = config.getSessionId();
|
||||
if (!sid) {
|
||||
// Defensive: empty header value is rejected by some HTTP middleware.
|
||||
// Skip injection rather than send `X-Qwen-Code-Session-Id: `.
|
||||
return baseFetch(input, init);
|
||||
}
|
||||
// Seed headers: prefer init.headers when caller provided them (their
|
||||
// intent overrides). Otherwise, if input is a Request, copy its own
|
||||
// headers so they aren't lost when we pass `{...init, headers}` back
|
||||
// to baseFetch — `new Headers(undefined)` would otherwise produce an
|
||||
// empty Headers and drop the Request's headers (including
|
||||
// Authorization). See PR #4393 review feedback.
|
||||
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, sid);
|
||||
} catch (err) {
|
||||
// Telemetry must never break the LLM request path. Log and fall through.
|
||||
diag.warn(
|
||||
`wrapFetchWithCorrelation: header construction failed, sending request without correlation header: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return baseFetch(input, init);
|
||||
}
|
||||
return baseFetch(input, { ...init, headers });
|
||||
};
|
||||
// Cast back to TFetch: runtime behavior matches whatever signature the
|
||||
// caller's baseFetch has (we delegate to it without altering shape).
|
||||
return wrapped as unknown as TFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static correlation headers. Captures the session id at call time —
|
||||
* subject to staleness if the host SDK keeps these headers in a
|
||||
* captured-at-construction slot (e.g. `@google/genai`'s
|
||||
* `httpOptions.headers` — see design doc §8.6 for the known Gemini
|
||||
* limitation). Prefer `wrapFetchWithCorrelation` whenever the SDK exposes
|
||||
* a `fetch` hook.
|
||||
*
|
||||
* Host scope: same trusted-host allowlist as `wrapFetchWithCorrelation`,
|
||||
* but evaluated once against the `destinationUrl` known at SDK
|
||||
* construction. Callers that can't determine the destination should pass
|
||||
* `undefined` — the helper returns `{}` (no header, same as telemetry off).
|
||||
*
|
||||
* When telemetry is disabled or the destination isn't trusted, returns
|
||||
* `{}` so the caller can spread it unconditionally without changing wire
|
||||
* behavior.
|
||||
*/
|
||||
export function staticCorrelationHeaders(
|
||||
config: Config,
|
||||
destinationUrl?: string,
|
||||
): Record<string, string> {
|
||||
// Mirror the safety contract of `wrapFetchWithCorrelation`: telemetry must
|
||||
// never break the LLM request path. This helper is called from the Gemini
|
||||
// content-generator factory at construction time — a throw here would
|
||||
// propagate up and crash content-generator init for the entire session.
|
||||
// Fall through to `{}` instead. See PR #4390 review feedback (wenshao).
|
||||
try {
|
||||
if (!config.getTelemetryEnabled()) return {};
|
||||
if (!destinationUrl) return {};
|
||||
// Same defensive normalization as `wrapFetchWithCorrelation`. Bare
|
||||
// string / array-with-nulls / whitespace-padded entries from a
|
||||
// hand-edited settings.json would otherwise crash `.includes` /
|
||||
// `matchesTrustedHost`. See sibling helper for full rationale.
|
||||
const raw =
|
||||
config.getTelemetrySessionIdHeaderHosts?.() ??
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS;
|
||||
const trustedHosts: readonly string[] = Array.isArray(raw)
|
||||
? raw
|
||||
.filter((p): p is string => typeof p === 'string')
|
||||
.map((p) => p.trim())
|
||||
: DEFAULT_SESSION_ID_HEADER_HOSTS;
|
||||
const broadcastAll = trustedHosts.includes('*');
|
||||
if (!broadcastAll) {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(destinationUrl).hostname;
|
||||
} catch {
|
||||
// Unparseable destination → treat as not on allowlist (fail closed).
|
||||
return {};
|
||||
}
|
||||
if (!matchesTrustedHost(host, trustedHosts)) return {};
|
||||
}
|
||||
const sid = config.getSessionId();
|
||||
if (!sid) return {};
|
||||
return { [SESSION_ID_HEADER]: sid };
|
||||
} catch (err) {
|
||||
diag.warn(
|
||||
`staticCorrelationHeaders: config read failed, omitting correlation header: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -147,6 +147,7 @@ describe('Telemetry SDK', () => {
|
|||
getDebugMode: () => false,
|
||||
getSessionId: () => 'test-session',
|
||||
getCliVersion: () => '1.0.0-test',
|
||||
getOutboundCorrelationPropagateTraceContext: () => false,
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
|
@ -640,6 +641,49 @@ describe('Telemetry SDK', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Outbound trace-context propagation gate', () => {
|
||||
function getTextMapPropagator(): unknown {
|
||||
const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!;
|
||||
return (constructorCall as { textMapPropagator?: unknown })
|
||||
.textMapPropagator;
|
||||
}
|
||||
|
||||
it('installs a no-op TextMapPropagator by default (propagateTraceContext=false)', () => {
|
||||
// Default behavior per PR #4390 R4 split: traceparent is NOT written
|
||||
// onto outbound wire. The propagator's inject() must be a no-op so
|
||||
// UndiciInstrumentation's `propagation.inject(carrier)` call writes
|
||||
// nothing into the outgoing request's headers.
|
||||
initializeTelemetry(mockConfig);
|
||||
const propagator = getTextMapPropagator() as
|
||||
| { inject: (...args: unknown[]) => void; fields: () => string[] }
|
||||
| undefined;
|
||||
expect(propagator).toBeDefined();
|
||||
expect(typeof propagator!.inject).toBe('function');
|
||||
// Sanity: fields() returns empty array → instrumentation knows there
|
||||
// are no headers to clear / no propagator state.
|
||||
expect(propagator!.fields()).toEqual([]);
|
||||
// inject is a no-op — does not throw, does not mutate the carrier.
|
||||
const carrier: Record<string, string> = { existing: 'h' };
|
||||
expect(() =>
|
||||
propagator!.inject({} as never, carrier, {} as never),
|
||||
).not.toThrow();
|
||||
expect(carrier).toEqual({ existing: 'h' });
|
||||
});
|
||||
|
||||
it('uses the SDK default propagator when propagateTraceContext=true (operator opt-in)', () => {
|
||||
vi.spyOn(
|
||||
mockConfig,
|
||||
'getOutboundCorrelationPropagateTraceContext',
|
||||
).mockReturnValue(true);
|
||||
initializeTelemetry(mockConfig);
|
||||
// textMapPropagator is omitted from NodeSDK options → SDK installs
|
||||
// its default W3C composite propagator. Test asserts the absence
|
||||
// because the actual W3CTraceContextPropagator instance is created
|
||||
// inside @opentelemetry/sdk-node which is auto-mocked here.
|
||||
expect(getTextMapPropagator()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Instrumentations', () => {
|
||||
function getInstrumentations(): unknown[] {
|
||||
const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!;
|
||||
|
|
@ -1115,6 +1159,7 @@ describe('refreshSessionContext', () => {
|
|||
getDebugMode: () => false,
|
||||
getSessionId: () => 'test-session',
|
||||
getCliVersion: () => '1.0.0-test',
|
||||
getOutboundCorrelationPropagateTraceContext: () => false,
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
*/
|
||||
|
||||
import { DiagLogLevel, diag } from '@opentelemetry/api';
|
||||
import type { DiagLogger } from '@opentelemetry/api';
|
||||
import type {
|
||||
Context,
|
||||
DiagLogger,
|
||||
TextMapPropagator,
|
||||
} from '@opentelemetry/api';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
|
||||
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc';
|
||||
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
|
||||
|
|
@ -90,6 +94,29 @@ export function resolveHttpOtlpUrl(
|
|||
// (2s) and overall (5s) timeouts, so this value is effectively unreachable there.
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* `TextMapPropagator` that emits nothing. Installed when
|
||||
* `outboundCorrelation.propagateTraceContext` is false (the default), so
|
||||
* trace context stays internal to the user's OTLP collector and is not
|
||||
* written into outbound `fetch` requests to third-party LLM providers.
|
||||
*
|
||||
* UndiciInstrumentation still creates client HTTP spans — the propagator
|
||||
* only governs whether `propagation.inject()` writes `traceparent` into
|
||||
* the outgoing request's header carrier. With this propagator installed,
|
||||
* inject is a no-op and outbound requests carry no trace headers. PR
|
||||
* #4390 review (LaZzyMan): split outbound-wire behavior out of telemetry
|
||||
* default-on.
|
||||
*/
|
||||
const NOOP_PROPAGATOR: TextMapPropagator = {
|
||||
inject() {},
|
||||
extract(context: Context): Context {
|
||||
return context;
|
||||
},
|
||||
fields(): string[] {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
let sdk: NodeSDK | undefined;
|
||||
let telemetryInitialized = false;
|
||||
let telemetryShutdownPromise: Promise<void> | undefined;
|
||||
|
|
@ -397,12 +424,26 @@ export function initializeTelemetry(config: Config): void {
|
|||
return path.slice(0, cut);
|
||||
};
|
||||
|
||||
// Outbound trace-context propagation gate (PR #4390 review, LaZzyMan):
|
||||
// by default, install a no-op propagator so `traceparent` does NOT get
|
||||
// written onto outbound `fetch` requests to LLM providers. Operators
|
||||
// who want server-side trace stitching (e.g. ARMS+DashScope) opt in via
|
||||
// `outboundCorrelation.propagateTraceContext: true`, which leaves the
|
||||
// SDK's default W3C composite propagator in place. UndiciInstrumentation
|
||||
// still creates client HTTP spans either way — the propagator only
|
||||
// governs whether trace ids leak onto third-party request streams.
|
||||
const textMapPropagator: TextMapPropagator | undefined =
|
||||
config.getOutboundCorrelationPropagateTraceContext()
|
||||
? undefined // undefined → NodeSDK keeps its default W3C propagator
|
||||
: NOOP_PROPAGATOR;
|
||||
|
||||
sdk = new NodeSDK({
|
||||
resource,
|
||||
// Disable async host/process/env resource detectors: they leave attributes
|
||||
// pending and trigger an OTel diag.error on any resource attribute read
|
||||
// before the detectors settle (e.g. during HttpInstrumentation span creation).
|
||||
autoDetectResources: false,
|
||||
...(textMapPropagator && { textMapPropagator }),
|
||||
spanProcessors: spanExporter ? [new BatchSpanProcessor(spanExporter)] : [],
|
||||
logRecordProcessors: logExporter
|
||||
? [new BatchLogRecordProcessor(logExporter)]
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS,
|
||||
extractRequestHost,
|
||||
matchesTrustedHost,
|
||||
} from './trusted-llm-hosts.js';
|
||||
|
||||
describe('matchesTrustedHost', () => {
|
||||
it('returns false for empty hostname (defensive)', () => {
|
||||
expect(matchesTrustedHost('', ['dashscope.aliyuncs.com'])).toBe(false);
|
||||
});
|
||||
|
||||
it('exact match (case-insensitive)', () => {
|
||||
expect(
|
||||
matchesTrustedHost('dashscope.aliyuncs.com', ['dashscope.aliyuncs.com']),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesTrustedHost('DashScope.Aliyuncs.COM', ['dashscope.aliyuncs.com']),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('exact pattern rejects sub-domain (no implicit wildcard)', () => {
|
||||
expect(
|
||||
matchesTrustedHost('sub.dashscope.aliyuncs.com', [
|
||||
'dashscope.aliyuncs.com',
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('*.suffix matches the bare suffix domain', () => {
|
||||
expect(matchesTrustedHost('alibaba-inc.com', ['*.alibaba-inc.com'])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('*.suffix matches sub-domains', () => {
|
||||
expect(
|
||||
matchesTrustedHost('idealab.alibaba-inc.com', ['*.alibaba-inc.com']),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesTrustedHost('a.b.alibaba-inc.com', ['*.alibaba-inc.com']),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('*.suffix rejects unrelated hosts that contain the suffix mid-name', () => {
|
||||
// `evil-alibaba-inc.com` ends with `alibaba-inc.com` as a substring but
|
||||
// is not a true sub-domain — the dot-anchored check should reject it.
|
||||
expect(
|
||||
matchesTrustedHost('evil-alibaba-inc.com', ['*.alibaba-inc.com']),
|
||||
).toBe(false);
|
||||
// Trailing-suffix attack: a host whose name HAPPENS to end in the suffix
|
||||
// text but on a different TLD.
|
||||
expect(
|
||||
matchesTrustedHost('alibaba-inc.com.evil.net', ['*.alibaba-inc.com']),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('mixed pattern list (exact + wildcard)', () => {
|
||||
const patterns = [
|
||||
'dashscope.aliyuncs.com',
|
||||
'*.dashscope.aliyuncs.com',
|
||||
'*.alibaba-inc.com',
|
||||
];
|
||||
expect(matchesTrustedHost('dashscope.aliyuncs.com', patterns)).toBe(true);
|
||||
expect(matchesTrustedHost('sub.dashscope.aliyuncs.com', patterns)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesTrustedHost('idealab.alibaba-inc.com', patterns)).toBe(true);
|
||||
expect(matchesTrustedHost('api.openai.com', patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('empty pattern list rejects everything', () => {
|
||||
expect(matchesTrustedHost('dashscope.aliyuncs.com', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRequestHost', () => {
|
||||
it('extracts from string URL', () => {
|
||||
expect(extractRequestHost('https://dashscope.aliyuncs.com/v1/x')).toBe(
|
||||
'dashscope.aliyuncs.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts from URL object', () => {
|
||||
expect(extractRequestHost(new URL('https://api.openai.com/v1'))).toBe(
|
||||
'api.openai.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts from Request', () => {
|
||||
expect(
|
||||
extractRequestHost(new Request('https://api.anthropic.com/v1/messages')),
|
||||
).toBe('api.anthropic.com');
|
||||
});
|
||||
|
||||
it('strips explicit port (URL.hostname does not include port)', () => {
|
||||
expect(extractRequestHost('https://dashscope.aliyuncs.com:443/v1')).toBe(
|
||||
'dashscope.aliyuncs.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips userinfo (URL.hostname does not include user/password)', () => {
|
||||
expect(
|
||||
extractRequestHost('https://user:pw@dashscope.aliyuncs.com/v1'),
|
||||
).toBe('dashscope.aliyuncs.com');
|
||||
});
|
||||
|
||||
it('ignores query string and fragment', () => {
|
||||
expect(
|
||||
extractRequestHost('https://dashscope.aliyuncs.com/v1?key=secret#frag'),
|
||||
).toBe('dashscope.aliyuncs.com');
|
||||
});
|
||||
|
||||
it('returns bracketed form for IPv6 (will never match a string pattern, behaves as fail-closed)', () => {
|
||||
// Document the IPv6 behavior so it's intentional rather than a
|
||||
// surprise: URL.hostname returns `[::1]` (with brackets), our
|
||||
// pattern grammar has no `[…]` form, so IPv6 destinations are
|
||||
// effectively never on the allowlist. This is acceptable because
|
||||
// qwen-code's allowlist is for named first-party endpoints, not
|
||||
// raw IPs. If/when raw-IP correlation becomes a real ask, extend
|
||||
// matchesTrustedHost — don't change extractRequestHost.
|
||||
expect(extractRequestHost('http://[::1]:8080/x')).toBe('[::1]');
|
||||
});
|
||||
|
||||
it('returns undefined for unparseable string', () => {
|
||||
expect(extractRequestHost('not a url')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SESSION_ID_HEADER_HOSTS', () => {
|
||||
it('matches DashScope global + intl endpoints', () => {
|
||||
expect(
|
||||
matchesTrustedHost(
|
||||
'dashscope.aliyuncs.com',
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesTrustedHost(
|
||||
'dashscope-intl.aliyuncs.com',
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches internal *.alibaba-inc.com / *.aliyun-inc.com', () => {
|
||||
expect(
|
||||
matchesTrustedHost(
|
||||
'idealab.alibaba-inc.com',
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesTrustedHost('gw.aliyun-inc.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT match third-party LLM providers', () => {
|
||||
expect(
|
||||
matchesTrustedHost('api.openai.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesTrustedHost('api.anthropic.com', DEFAULT_SESSION_ID_HEADER_HOSTS),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesTrustedHost('openrouter.ai', DEFAULT_SESSION_ID_HEADER_HOSTS),
|
||||
).toBe(false);
|
||||
expect(
|
||||
matchesTrustedHost(
|
||||
'generativelanguage.googleapis.com',
|
||||
DEFAULT_SESSION_ID_HEADER_HOSTS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default allowlist for outbound correlation headers
|
||||
* (`X-Qwen-Code-Session-Id`). Limited to Alibaba/DashScope endpoints where
|
||||
* the LLM provider, the upstream telemetry backend (ARMS Tracing), and
|
||||
* the qwen-code distribution are the same legal entity — so the session
|
||||
* id stays a first-party, in-vendor correlation handle.
|
||||
*
|
||||
* Why a default scope (rather than broadcasting to every LLM provider):
|
||||
* PR #4390 review (LaZzyMan) pointed out that an open-source CLI sending
|
||||
* a stable cross-request identifier to arbitrary third-party providers
|
||||
* (OpenAI, Anthropic, OpenRouter, ...) is a cross-vendor fingerprinting
|
||||
* surface those providers don't need for the API call itself. Restricting
|
||||
* the default to in-vendor destinations mirrors the claude-code pattern
|
||||
* (first-party client → first-party backend), preserves the real product
|
||||
* value (ARMS server-side trace stitching against DashScope), and makes
|
||||
* the third-party-broadcast failure mode opt-in rather than default.
|
||||
*
|
||||
* Mirrors `DashScopeOpenAICompatibleProvider.isDashScopeProvider` so the
|
||||
* two layers stay aligned. If you add a hostname there, add it here too.
|
||||
*
|
||||
* Pattern syntax (matches `matchesTrustedHost` below):
|
||||
* - bare hostname → exact match (case-insensitive)
|
||||
* - `*.suffix` → matches `suffix` itself AND any sub-domain of it
|
||||
* (e.g. `*.alibaba-inc.com` matches `alibaba-inc.com`
|
||||
* and `gw.alibaba-inc.com`)
|
||||
*/
|
||||
export const DEFAULT_SESSION_ID_HEADER_HOSTS: readonly string[] = [
|
||||
'dashscope.aliyuncs.com',
|
||||
'dashscope-intl.aliyuncs.com',
|
||||
'*.dashscope.aliyuncs.com',
|
||||
'*.dashscope-intl.aliyuncs.com',
|
||||
'*.alibaba-inc.com',
|
||||
'*.aliyun-inc.com',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check whether `hostname` matches any pattern in `patterns`.
|
||||
* Case-insensitive. Empty `hostname` always returns false.
|
||||
*
|
||||
* `*.suffix` patterns match:
|
||||
* - the suffix domain itself (`alibaba-inc.com` matches `*.alibaba-inc.com`)
|
||||
* - any sub-domain (`gw.alibaba-inc.com` matches `*.alibaba-inc.com`)
|
||||
*
|
||||
* This is intentionally a tiny pure helper, not a generic glob. Anything
|
||||
* more elaborate (regex, port-aware, scheme-aware) should be added at the
|
||||
* call site, not here, so the allowlist semantics stay obvious from the
|
||||
* pattern strings users put in settings.
|
||||
*/
|
||||
export function matchesTrustedHost(
|
||||
hostname: string,
|
||||
patterns: readonly string[],
|
||||
): boolean {
|
||||
if (!hostname) return false;
|
||||
const h = hostname.toLowerCase();
|
||||
for (const raw of patterns) {
|
||||
const p = raw.toLowerCase();
|
||||
if (p.startsWith('*.')) {
|
||||
const bare = p.slice(2);
|
||||
if (h === bare || h.endsWith('.' + bare)) return true;
|
||||
} else if (h === p) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the destination hostname from a fetch input. Returns `undefined`
|
||||
* if the URL can't be parsed — caller should treat that as "not on the
|
||||
* allowlist" (fail closed).
|
||||
*/
|
||||
export function extractRequestHost(
|
||||
input: string | URL | Request,
|
||||
): string | undefined {
|
||||
try {
|
||||
if (typeof input === 'string') return new URL(input).hostname;
|
||||
if (input instanceof URL) return input.hostname;
|
||||
if (input instanceof Request) return new URL(input.url).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -435,18 +435,23 @@
|
|||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"sessionIdHeaderHosts": {
|
||||
"description": "Destination hostnames (or \"*.suffix\" patterns) that receive the X-Qwen-Code-Session-Id outbound correlation header. Defaults to Alibaba/DashScope first-party endpoints (dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, *.dashscope.aliyuncs.com, *.dashscope-intl.aliyuncs.com, *.alibaba-inc.com, *.aliyun-inc.com) so the stable session identifier is not broadcast to third-party LLM providers like OpenAI or Anthropic. Set to [\"*\"] to restore the broadcast-to-everywhere behavior, or [] to fully disable the header.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"description": "Telemetry configuration."
|
||||
},
|
||||
"outboundCorrelation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"propagateTraceContext": {
|
||||
"description": "Inject W3C `traceparent` header on outbound `fetch` requests (LLM SDK calls, MCP StreamableHTTP, WebFetch, ...). Default: false — trace context stays internal to the operator's OTLP collector and is NOT written onto third-party request streams. Set true only when you want cross-process trace stitching with an OTel-aware LLM provider (e.g. ARMS+DashScope). Note: client HTTP spans are still emitted in either case; this flag only governs the wire `traceparent` header.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "SECURITY-RELEVANT. Controls what client-side correlation data qwen-code writes into outbound LLM API requests (DashScope, OpenAI, Anthropic, etc.) — separate from `telemetry.*` which governs data flow into the operator's OWN OTLP collector. All values default to off. Opt in only when the LLM provider also reports into your OTel collector for cross-process trace stitching (e.g. ARMS Tracing + DashScope)."
|
||||
},
|
||||
"fastModel": {
|
||||
"description": "Model used for generating prompt suggestions and speculative execution. Leave empty to use the main model. A smaller/faster model (e.g., qwen3-coder-flash) reduces latency and cost.",
|
||||
"type": "string",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue