Skip to content

fix(sse): forward client app-attribution headers to upstream (OpenRouter Inkling 403) - #16445

Merged
diegosouzapw merged 5 commits into
cheaper-inference:release/v3.8.52from
woodsonl:woodsonl/openrouter
Oct 16, 2026
Merged

diegosouzapw merged 5 commits into
cheaper-inference:release/v3.8.52from
woodsonl:woodsonl/openrouter

Conversation

@woodsonl

Copy link
Copy Markdown
Contributor

OpenRouter blocks Inkling with 403 "only available on agentic harnesses"

⚠️ base-red inherited: #15306

Summary

Requests for openrouter/thinkingmachines/inkling:free through OmniRoute fail while the same key serves other models. Verbatim upstream error, passed back through OmniRoute:

{"error":{"message":"[403]: thinkingmachines/inkling:free is only available on agentic harnesses. Try plugging it into a coding agent or productivity app listed on https://openrouter.ai/apps","type":"permission_error","code":"permission_denied"}}

Reproduction (all calls POST /v1/chat/completions, body {"model": <id>, "messages":[{"role":"user","content":"Reply with the single word: ready"}], "max_tokens": 300}):

Call Result
openrouter/thinkingmachines/inkling:free 403 permission_denied, message above
openrouter/thinkingmachines/inkling-small:free Same 403, names inkling-small
openrouter/nvidia/nemotron-3-ultra-550b-a55b:free (control) 200, "ready"
thinkingmachines/inkling:free without provider prefix OmniRoute routing error: no credentials for provider thinkingmachines (never reached OpenRouter; expected)

OpenRouter publisher gates key on app attribution (HTTP-Referer / X-Title). OmniRoute never let a client's attribution through:

  1. forwardOpencodeClientHeaders forwarded x-title and x-session-id but dropped HTTP-Referer, so every call presented the registry static HTTP-Referer: https://endpoint-proxy.local (open-sse/config/providers/registry/openrouter/index.ts:18). "Endpoint Proxy" is not a listed harness, so gated models 403.
  2. Even forwarded headers were corrupted: the registry static X-Title: Endpoint Proxy and the forwarded lowercase x-title both reached the wire, and undici comma-joins same-name case-duplicates, so upstream saw X-Title: Endpoint Proxy, My Agent.
  3. Three more merge points had the same case-duplicate hole (mergeUpstreamExtraHeaders, setUserAgentHeader, the x-opencode-* loop), found in review.

Fix (4 commits):

  • 90b16b2f5e forwards client app-attribution headers: http-referer joins the forwarded set; case-dedupe before writing, so a client value replaces a registry static instead of comma-joining with it.
  • 080318447f gives operator-configured headers final precedence: mergeUpstreamExtraHeaders dedupes case-insensitively (operator pins land last and win), DefaultExecutor runs the client forward before applyCustomHeaders, the x-opencode-* loop shares the same setForwardedHeader helper, and OMNIROUTE_FORWARD_ATTRIBUTION=0 is a kill-switch that keeps the registry/operator identity and drops client attribution (default on).
  • beb5e4c05d fixes the same comma-join class in setUserAgentHeader (kept a lowercase mirror next to the canonical key) and corrects a comment that overstated the kill-switch scope.
  • 045f1d13c5 documents the new env var.

Precedence after the fix: operator config > client headers > registry statics. API keys never leave the proxy; the forwarded headers only carry app identity.

Test Coverage

All new code paths have test coverage. Tests: 0 → 17 (+17 new, 3 pre-existing assertions updated to the single-entry contract).

  • tests/unit/refactor-opencodeHeaders.test.ts (9 new: forwarding, case-variant replacement for all three keys, empty-string contract, kill-switch, static fallback)
  • tests/unit/executor-base-utils.test.ts (mergeUpstreamExtraHeaders case-dedupe and precedence; setUserAgentHeader single-entry regression)
  • tests/unit/executor-default-base.test.ts (single-entry UA assertions updated)

TDD order: the three core tests fail on the pre-fix tree (90b16b2f5e~), pass after. Final tree: 170 tests across 11 header-related suites, 0 failures (gstack-evidence receipt tests, exit 0). npm run lint exit 0. A wire-level probe (.gstack/qa-reports/qa-attribution-*, capture 002) drives the real merge sequence and asserts via undici Headers that HTTP-Referer and X-Title each appear exactly once on the wire with the operator value winning.

Tests ran: tests (focused node:test lanes, exit 0; per-runner count not parsed by the ledger, 170+ by runner summary in the log). Full unit + vitest suites are CI's responsibility per CONTRIBUTING; the local vitest lane fails loading an untouched upstream file (chaosVirtualCombo.test.ts, No such built-in module: node:) — recorded as inherited base-red #15306.

Pre-Landing Review

Two review rounds. Round 1 (/gstack-review: testing, maintainability, native adversarial, Codex outside review) found 9 issues; 8 fixed in 080318447f, 1 skipped (referer value-shape validation: pre-existing behavior class, bounded by inbound header limits, affects only the abusive client's own attribution). Round 2 (fresh native adversarial + Codex on the final tree) found 8 more: setUserAgentHeader comma-join and a stale comment fixed in beb5e4c05d; the rest dispositioned as skipped with rationale below.

Skipped findings and why:

  • Client-controlled http-referer is spoofable on multi-tenant proxies. This deployment is single-operator; the all-or-nothing OMNIROUTE_FORWARD_ATTRIBUTION=0 switch covers operators who need it. API keys are never exposed by this path.
  • Kill-switch fails open on unrecognized values (OMNIROUTE_FORWARD_ATTRIBUTION=disable enables). Deliberate: identical semantics to the established OPENCODE_SYNTHESIZE_CLI_HEADERS motif in the same file.
  • CC-protocol routes (claude-oauth early return) never forward attribution. Pre-existing asymmetry; those routes target Anthropic-family upstreams, not OpenRouter publisher gates. Out of scope.
  • Alias/canonical model header merges stay case-sensitive (upstreamExecuteHeaders.ts:40, src/lib/db/models.ts:1130), so mixed-case configured attribution could pick the alias pin over the canonical one in a corner configuration. Pre-existing spread semantics, upstream of this diff; the wire output stays single-valued either way.

Gate edits: none detected (2 listed, 1 read; patterns: RH-1,RH-3,RH-4,RH-12,RH-13,RH-14,RH-15,RH-16; unlisted idioms: none)

Outside review: Codex completed on both candidates (round 1: 1 Medium, fixed; round 2: 1 Medium fixed + 1 Low fixed, both same comma-join class).

Exploratory QA

Functional target (unit + wire shape), 4 contracts, all passing on the final tree:

  1. Client HTTP-Referer/X-Title/x-session-id forwarded, single-valued on the wire — pass
  2. Operator config > client > registry statics, no comma-join at the final merge — pass
  3. OMNIROUTE_FORWARD_ATTRIBUTION=0 kills attribution forwarding, keeps statics — pass
  4. Adjacent behavior unchanged (User-Agent opt-out fix(api): provider models endpoint sends wrong Content-Length (partial transfer) + client User-Agent forwarded upstream (Groq 403) #15632, x-opencode-* forwarding, anthropic-beta merge) — pass

Evidence: .gstack/qa-reports/qa-attribution-* (captures 001-002, exit 0) and .gstack/qa-reports/review-attribution-195228. Not run: live confirmation against OpenRouter with real harness attribution (needs a funded key):

# attribution forwarded: expect 200 if the gate keys on headers
curl -s https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" -H "Content-Type: application/json" \
  -H "HTTP-Referer: https://<listed-harness-url>" -H "X-Title: <listed-harness>" \
  -d '{"model":"thinkingmachines/inkling:free","max_tokens":10,"messages":[{"role":"user","content":"ready?"}]}'

# paid endpoint control: expected 200 regardless of the free-tier gate
curl -s https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" -H "Content-Type: application/json" \
  -d '{"model":"thinkingmachines/inkling","max_tokens":10,"messages":[{"role":"user","content":"ready?"}]}'

Design Review

No frontend files changed — design review skipped.

Eval Results

No prompt-related files changed — evals skipped.

Scope Drift

Scope Check: CLEAN. Intent (commit messages): forward client app attribution so publisher-gated models work through the router, and fix the same duplicate-header class everywhere it was found. Delivered: exactly that plus tests and the env var doc. No undeclared behavior changes beyond the documented precedence flip (client over registry statics; operator over client), which is the feature.

Plan Completion

Plan completion audit: not run (no plan is bound to this branch and no docs/designs/ file matches). Fix: add "Plan: " to the PR body, or run /autoplan.

Verification Results

3 PASS (focused test lanes exit 0 with 170 passing; npm run lint exit 0; env-doc sync gate green), 0 FAIL, 1 BLOCKED-equivalent disclosed (local vitest lane: inherited upstream load error, #15306; full suites are CI-owned per CONTRIBUTING). Pregate: secrets warn (5 MEDIUM non-credential internal.hostname findings, all test fixtures mirroring the shipped https://endpoint-proxy.local registry literal), lanes fail on .env.example having no test lane (template file; the lane pin is read from the base branch and cannot be extended from a feature branch). Neither finding touches runtime code.

Documentation

Updated: docs/reference/ENVIRONMENT.md (new OMNIROUTE_FORWARD_ATTRIBUTION row) and .env.example (commented template entry), verified by npm run check:env-doc-sync. Scope: the one operator-facing variable this PR introduces; no other docs describe the affected behavior.

Known limits

Whether OpenRouter accepts forwarded header attribution through a proxy is OpenRouter's call. If they key harness identity on OAuth/Ori sessions instead of headers, attribution is now correct but :free Inkling stays gated; the paid thinkingmachines/inkling endpoints (DeepInfra, Together) show no gate. The pending confirmation curls above settle it.

Test plan

  • node --import tsx/esm --test over the 11 touched header suites: 170 pass, 0 fail
  • npm run lint: exit 0
  • npm run check:env-doc-sync: in sync
  • Full unit + vitest suites + 60% coverage gate: CI's responsibility on this PR (local lane blocked by inherited 🔴 Release branch not green: release/v3.8.52 #15306)

OpenRouter publisher gates (thinkingmachines/inkling:free returns 403
permission_denied "only available on agentic harnesses") key on app
attribution. forwardOpencodeClientHeaders forwarded X-Title but not
HTTP-Referer, and wrote the forwarded key alongside same-name registry
statics under a different case — undici comma-joins those into one
garbage header (e.g. 'X-Title: Endpoint Proxy, My Agent'). Add
http-referer to the forwarded set and case-dedupe before writing, so a
request that starts in a listed harness keeps its attribution through
the router; clients sending nothing keep the registry defaults.
…merge

Review findings on 90b16b2:
- mergeUpstreamExtraHeaders (operator-configured per-model/connection
  headers) merged case-sensitively AFTER the client attribution forward,
  so an operator HTTP-Referer/X-Title plus the client's forwarded key
  comma-joined into one garbage wire value. Dedupe case-insensitively;
  operator headers land last and win.
- DefaultExecutor ran applyCustomHeaders before the client forward, so a
  client value silently deleted an operator-pinned header. Forward now
  runs first; operator custom headers re-apply after.
- Extend case-variant dedupe to the x-opencode-* loop (same undici
  comma-join hole) via a shared setForwardedHeader helper.
- Add OMNIROUTE_FORWARD_ATTRIBUTION=0 kill-switch: keeps the
  registry/operator app identity and drops client x-session-id/x-title/
  http-referer forwarding (default on).
- Refresh stale allowlist comments; add regression tests for each.
Adversarial review round 2 on 0803184:
- setUserAgentHeader kept a lowercase mirror alongside the canonical key,
  so both spellings reached the wire and undici comma-joined them into
  one garbage User-Agent. Delete same-name case variants, set once —
  same fix class as mergeUpstreamExtraHeaders and setForwardedHeader.
- default.ts comment claimed OMNIROUTE_FORWARD_ATTRIBUTION gates the
  whole forwarded set; it only gates the agent-metadata trio.
- Tests updated to the single-entry contract (+1 regression).
…iling

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants