feat(runner): accept caller trace headers for OpenRouter API requests - #45
feat(runner): accept caller trace headers for OpenRouter API requests#45jamespsterling wants to merge 6 commits into
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…router-trace-headers
…router-trace-headers
…ndary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…strict allowed header names Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Perry's Review
Verdict: ✅ APPROVE
Risk: 🟢 Low
Risk assessment
| Dimension | Severity | Risk | Reasoning |
|---|---|---|---|
| Implementation risk | 🟩 | Low | Clean threading of traceHeaders through the benchmark type, all benchmark definitions, both providers (chat completions + responses), and the generation resolver. New filterTraceHeaders allowlist prevents header injection (e.g. authorization is dropped). Tests verify the filter and that auth headers cannot be overridden. |
| Premise risk | 🟩 | Low | Propagating OTel trace context from the Temporal worker activity into the OpenRouter API is the right observability improvement. URL-prefix gating in the companion PR (#35408) ensures trace headers can't leak to third-party hosts. |
| Estimated impact | 🟩 | Low | Optional field with backward-compatible default. filterTraceHeaders returns undefined when unset or when nothing survives the filter, so the feature is a no-op unless configured. |
| Risk Factor | Severity | Risk | Reasoning |
|---|---|---|---|
| Reversibility | 🟩 | Low | Omit traceHeaders or revert. |
| Detectability | 🟩 | Low | Trace headers visible in Datadog APM. |
| Blast radius | 🟩 | Low | Only the HTTP client layers in benchmark runs. |
| Data integrity | None | No persisted state touched. | |
| Financial exposure | None | No billing code involved. | |
| Security and privacy exposure | 🟩 | Low | filterTraceHeaders allowlist drops authorization and any non-trace header. Tests verify authorization: Bearer attacker-key is dropped and the real auth header is preserved. Trace headers carry trace context IDs, not user data. |
| Propagation | 🟩 | Low | Additive optional field across all benchmark types and providers; existing callers unaffected. |
| Availability | None | Header injection is a no-op for request handling. | |
| Recovery cost | 🟩 | Low | Omit the field. |
| Time to correct | 🟩 | Low | One-line change. |
Analysis
What this does: Threads an optional traceHeaders field through the entire benchmark harness: RunBenchmarkInput → benchmark type definitions → both providers (chat completions + responses) → generation resolver. A new filterTraceHeaders function ensures only allowlisted trace headers (traceparent, tracestate, x-or-traceparent, x-benchmark-trace) pass through, dropping authorization and any other non-trace header.
Key additions since the initial review (3 files, 80 additions → 16 files, 203 additions):
-
filterTraceHeadersinsrc/runner/trace-headers.ts: Allowlist of 4 header names, normalizes to lowercase, drops anything not in the set. Returnsundefinedwhen nothing survives the filter. This is a security improvement: even if a caller passesauthorizationintraceHeaders, it gets dropped. -
traceHeadersthreaded through all benchmark types:deep-swe,define-single-turn,swe-atlas,tau-bench-airline,tau3-bench-banking,wandr. Each uses the same conditional spread pattern (...(input.traceHeaders !== undefined && { traceHeaders: input.traceHeaders })). -
Both providers accept
traceHeaders:openrouter-model.ts(chat completions) andresponses-client.ts/responses-model.ts(responses API). The responses client spreads...traceHeadersinto the request headers (after the harness referer/title, beforeextraHeadersand version override). -
Generation resolver accepts
traceHeaders:generation-resolver.tsspreads...traceHeadersinto the lookup request headers, withAuthorizationalways last (so it can't be overridden by trace headers even iffilterTraceHeadersis bypassed). -
run-by-id.tsfilters once at the top: Both the benchmark layer and the generation resolver receivefilterTraceHeaders(input.traceHeaders)(the filtered result), not the raw input.
Why it's correct:
-
filterTraceHeadersis a security boundary. Only 4 allowlisted header names pass through.authorization,cookie,x-session-id, etc. are dropped. This prevents header injection through thetraceHeadersfield even if a caller passes arbitrary headers. -
Header ordering ensures auth cannot be overridden. In
responses-client.ts,...traceHeaderscomes before...options.extraHeadersbut the harness always setsAuthorizationin theHttpClientlayer (not via trace headers). Ingeneration-resolver.ts,Authorization: Bearer ${config.apiKey}is set after...traceHeaders, so even ifauthorizationsurvived the filter (it won't), the resolver's auth takes precedence. -
Tests verify the security boundary. The
openrouter-model.test.tstest passesauthorization: "Bearer attacker-key"intraceHeadersand asserts the request'sauthorizationheader is"Bearer sk-test"(the real key), not the attacker key. Thegeneration-resolver.test.tstest makes the same assertion. Thetrace-headers.test.tstest directly verifiesfilterTraceHeadersdropsauthorizationandx-session-id. -
No-op when unset.
filterTraceHeaders(undefined)returnsundefined, and the conditional spread...(traceHeaders !== undefined && { traceHeaders })omits the field entirely.
CI: validate, Analyze (actions), Analyze (javascript-typescript), CodeQL all pass.
| @@ -188,6 +191,7 @@ export function makeResponsesLayer(config: ResponsesConfig): Layer<Responses> { | |||
| const headers: Record<string, string> = { | |||
| "HTTP-Referer": BENCH_HARNESS_APP_REFERRER, | |||
There was a problem hiding this comment.
Question: The trace headers are spread (...traceHeaders) into the request headers before ...options.extraHeaders. If a caller passes an extraHeaders entry with the same name as a trace header (e.g. traceparent), the extra header would override the trace header. Is this intentional (caller can override trace headers per-request), or should trace headers take precedence over extra headers?
TL;DR
runBenchmarkByIdaccepts an optionaltraceHeadersmap and sets those headers on OpenRouter API requests only, so a caller (the monorepo TemporalrunBenchmarkactivity) can propagate its OTel trace context (traceparent,x-or-traceparent,x-benchmark-trace) into the API.What changed?
RunBenchmarkInput.traceHeaders?: Readonly<Record<string, string>>(optional; behavior unchanged when omitted).src/runner/trace-headers.ts:makeHttpClientLayerwrapsFetchHttpClient.layerwithHttpClient.mapRequest, applying the headers only to requests whose URL starts with the normalized OpenRouter API base URL — dataset/sandbox/HuggingFace requests are untouched.run-by-id.tsprovides this layer instead of the bareFetchHttpClient.layer.Why?
Benchmark traces in Datadog currently end at the worker activity; the API side of each request is a separate trace. Threading the caller's trace headers lets the API continue the same trace (companion monorepo PR: OpenRouterTeam/openrouter-web#35408).
How to test
bun test src/runner/trace-headers.test.ts— covers header application to API-prefixed requests and non-application to other hosts.traceHeaders: request headers are byte-identical to before.Reviewer focus
normalizeBaseUrl(baseUrl ?? DEFAULT_BASE_URL)) so the trust-gate header can't leak to third parties.Checklist
Link to Devin session: https://openrouter.devinenterprise.com/sessions/8669b85c73fb4378aebc0b5476836b6e
Requested by: @jamespsterling