Repository navigation
fix(sse): forward client app-attribution headers to upstream (OpenRouter Inkling 403) - #16445
Merged
diegosouzapw merged 5 commits intoOct 16, 2026
Conversation
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).
6 of 7 tasks
…iling Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
diegosouzapw
merged commit Oct 16, 2026
ee182b5
into
cheaper-inference:release/v3.8.52
9 checks passed
This was referenced Oct 16, 2026
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
OpenRouter blocks Inkling with 403 "only available on agentic harnesses"
Summary
Requests for
openrouter/thinkingmachines/inkling:freethrough 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}):openrouter/thinkingmachines/inkling:freepermission_denied, message aboveopenrouter/thinkingmachines/inkling-small:freeinkling-smallopenrouter/nvidia/nemotron-3-ultra-550b-a55b:free(control)thinkingmachines/inkling:freewithout provider prefixthinkingmachines(never reached OpenRouter; expected)OpenRouter publisher gates key on app attribution (
HTTP-Referer/X-Title). OmniRoute never let a client's attribution through:forwardOpencodeClientHeadersforwardedx-titleandx-session-idbut droppedHTTP-Referer, so every call presented the registry staticHTTP-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.X-Title: Endpoint Proxyand the forwarded lowercasex-titleboth reached the wire, and undici comma-joins same-name case-duplicates, so upstream sawX-Title: Endpoint Proxy, My Agent.mergeUpstreamExtraHeaders,setUserAgentHeader, thex-opencode-*loop), found in review.Fix (4 commits):
90b16b2f5eforwards client app-attribution headers:http-refererjoins the forwarded set; case-dedupe before writing, so a client value replaces a registry static instead of comma-joining with it.080318447fgives operator-configured headers final precedence:mergeUpstreamExtraHeadersdedupes case-insensitively (operator pins land last and win),DefaultExecutorruns the client forward beforeapplyCustomHeaders, thex-opencode-*loop shares the samesetForwardedHeaderhelper, andOMNIROUTE_FORWARD_ATTRIBUTION=0is a kill-switch that keeps the registry/operator identity and drops client attribution (default on).beb5e4c05dfixes the same comma-join class insetUserAgentHeader(kept a lowercase mirror next to the canonical key) and corrects a comment that overstated the kill-switch scope.045f1d13c5documents 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-evidencereceipttests, exit 0).npm run lintexit 0. A wire-level probe (.gstack/qa-reports/qa-attribution-*, capture 002) drives the real merge sequence and asserts via undiciHeadersthatHTTP-RefererandX-Titleeach 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:setUserAgentHeadercomma-join and a stale comment fixed inbeb5e4c05d; the rest dispositioned as skipped with rationale below.Skipped findings and why:
http-refereris spoofable on multi-tenant proxies. This deployment is single-operator; the all-or-nothingOMNIROUTE_FORWARD_ATTRIBUTION=0switch covers operators who need it. API keys are never exposed by this path.OMNIROUTE_FORWARD_ATTRIBUTION=disableenables). Deliberate: identical semantics to the establishedOPENCODE_SYNTHESIZE_CLI_HEADERSmotif in the same file.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:
HTTP-Referer/X-Title/x-session-idforwarded, single-valued on the wire — passOMNIROUTE_FORWARD_ATTRIBUTION=0kills attribution forwarding, keeps statics — passx-opencode-*forwarding, anthropic-beta merge) — passEvidence:
.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):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 lintexit 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-credentialinternal.hostnamefindings, all test fixtures mirroring the shippedhttps://endpoint-proxy.localregistry literal), lanes fail on.env.examplehaving 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(newOMNIROUTE_FORWARD_ATTRIBUTIONrow) and.env.example(commented template entry), verified bynpm 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
:freeInkling stays gated; the paidthinkingmachines/inklingendpoints (DeepInfra, Together) show no gate. The pending confirmation curls above settle it.Test plan
node --import tsx/esm --testover the 11 touched header suites: 170 pass, 0 failnpm run lint: exit 0npm run check:env-doc-sync: in sync