Repository navigation
fix(opencode): match the upstream free-tier request contract - #14013
Merged
diegosouzapw merged 3 commits intoSep 18, 2026
Merged
diegosouzapw merged 3 commits into
diegosouzapw merged 3 commits into
Conversation
maxmad64bis
force-pushed
the
fix/opencode-free-tier-request-contract
branch
from
September 17, 2026 19:02
525f51b to
fcb0033
Compare
maxmad64bis
marked this pull request as ready for review
September 17, 2026 19:03
maxmad64bis
force-pushed
the
fix/opencode-free-tier-request-contract
branch
2 times, most recently
from
September 17, 2026 19:35
03a4f33 to
31cb500
Compare
Every request OmniRoute sends to a keyless OpenCode model has been answered 403 since 2026-09-17. The upstream accepts one only when all four of these hold, and removing any single one turns a 200 back into a 403: - the body carries stream: true; - the body declares a non-empty tools array; - the session header is shaped ses_ + 12 hex + 14 base62 (the shape is checked, the value is not: 12 arbitrary hex digits pass); - the User-Agent carries opencode/<version> with version >= 1.17 (an older version answers 426 UpgradeRequired instead). OmniRoute satisfied none of them: the synthesized user-agent had no version, the ids went out as UUIDs, and the client body was relayed as it came. The tools condition is the one that shapes this change. The upstream inspects which names a request declares. Measured twice per cell, cells interleaved so none runs on a pool another just emptied: one made-up name is refused, twelve made-up names are refused, twelve plausible names from another vocabulary are refused, three names from the official client are refused, and six, nine or twelve of them pass. What is accepted also differs by model, and it moved between two consecutive days on a model that had accepted it the day before. So no list is worth pinning here. This change reuses the one the traffic already carries: when a request goes out with tools and comes back accepted, its tool names are remembered for that provider, model and — when the caller supplied a session id — that conversation. A later request of the same conversation carrying no tools goes out with the list its own client declared a moment earlier. That is the failing case exactly: a client sends its build requests with a full tool list and its title and compaction requests with none, on one session, and only the second kind is refused. Names only are kept, never a schema or any conversation content, and the placeholder built from them declares an empty parameter object. Resolution falls back to any conversation on the same model, then to a configured list, then to a single placeholder, so an install that observes nothing and configures nothing keeps the previous behaviour. A borrowed list is dropped after three refusals in a row rather than one — the same body was refused and then accepted on one model minutes apart, so a single verdict is not proof — and only on requests that actually borrowed it. Other changes, for free-tier models only; paid models on the same host are not gated and stay untouched: - no tool_choice is imposed on any surface. The upstream answers 400 invalid_request_error, only "auto" is supported for tool_choice, so a value the caller did not send is never added and one it did send travels unchanged; - the synthesized user-agent default carries a version, and a client user-agent is preserved only when it already satisfies the contract (the previous rule preserved opencode-cli/..., which the upstream refuses). On a gated request a configured user-agent that does not satisfy it is replaced by that default; outside the gate it is honoured as-is; - the session keeps the existing conversation fingerprint, so upstream prompt caching still hits, and is rendered in the canonical shape; a foreign client session is translated deterministically, one to one. The fingerprint also reads the Responses surface body, whose conversation lives under input; - the scope comes from the surface, derived from the registry base url, instead of matching two provider ids. The other surface refuses a request that carries tools rather than one that does not, so it is never brought up to this contract — including for the free model its own registry declares; - the request is streamed upstream even when the caller asked for JSON, and the executor hands that caller a JSON body rebuilt from the event stream. The rebuild is lazy: it drains the upstream only when the caller reads, so a caller that cancels pays nothing; - two settings, both defaulting to today's behaviour: OPENCODE_FREE_TIER_REQUEST_CONTRACT=off stops adjusting request bodies while leaving headers in place, and OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS names the list to declare where nothing has been observed. The Muse Responses workaround that forced a UUID session is removed: the shape it produced is the one the upstream now refuses. Resolving the CLI defaults moves into the headers module, which already owns the default user-agent and the contract that validates one, leaving the executor below where this change found it. Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com>
maxmad64bis
force-pushed
the
fix/opencode-free-tier-request-contract
branch
from
September 18, 2026 00:13
31cb500 to
3fcc713
Compare
4 of 5 tasks
# Conflicts: # open-sse/executors/opencode.ts
diegosouzapw
merged commit Sep 18, 2026
0349627
into
cheaper-inference:release/v3.8.51
5 of 7 checks passed
Mistertelecom
pushed a commit
to Mistertelecom/OmniRoute
that referenced
this pull request
Sep 18, 2026
Closes cheaper-inference#13998 1. .env.example: CREDENTIAL_HEALTH_CHECK_INTERVAL default is 60m (3600000), not 5m (300000) 2. README.md: clarify OMNIROUTE_SKIP_POSTINSTALL only skips the native SQLite warm-up 3. TROUBLESHOOTING.md: add Windows PATH guidance for "omniroute is not recognized" (The OpenCode header-defaults half of cheaper-inference#13998 was already fixed upstream by cheaper-inference#14013.)
This was referenced Sep 18, 2026
4 of 5 tasks
Merged
5 tasks done
diegosouzapw
pushed a commit
that referenced
this pull request
Sep 22, 2026
Closes #13998 1. .env.example: CREDENTIAL_HEALTH_CHECK_INTERVAL default is 60m (3600000), not 5m (300000) 2. README.md: clarify OMNIROUTE_SKIP_POSTINSTALL only skips the native SQLite warm-up 3. TROUBLESHOOTING.md: add Windows PATH guidance for "omniroute is not recognized" (The OpenCode header-defaults half of #13998 was already fixed upstream by #14013.) Co-authored-by: Mistertelecom <noc@ysoftware.com>
4 of 5 tasks
muhamadgalihsaputra
pushed a commit
to niyatna/NiyatnaRoute
that referenced
this pull request
Sep 27, 2026
…-inference#14013) Match the upstream OpenCode free-tier request contract (issue cheaper-inference#13935): canonical ses_/msg_ identity ids, versioned User-Agent, and the measured body requirements (stream:true + non-empty tools) with a learn-and-reuse tool-name cache, so no-auth oc/* requests stop being refused with 403 FreeTierError. Supersedes cheaper-inference#13937 (session regex and minimum-version rule kept, credited below). Complements cheaper-inference#14011 (refusal classification) and cheaper-inference#13819 (stream_options strip), both already merged. Closes cheaper-inference#13935 Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This was referenced Sep 30, 2026
This was referenced Oct 1, 2026
idoomblast
added a commit
to idoomblast/OmniRoute
that referenced
this pull request
Oct 4, 2026
…y synthesis
The keyless OpenCode free tier (opencode.ai/zen/v1) refuses any request that
does not match the OpenCode client contract with 403 FreeTierError
("OpenCode's free tier can only be used from within OpenCode"). Port the
upstream stack (release/v3.8.52) onto the fork:
- opencodeFreeTierContract.ts: gate the keyless models, force the streaming
flag, declare an observed tool list, assemble the CLI identity defaults.
- opencodeRequestShape.ts / opencodeToolObservation.ts: request shaping for
the gate (streaming shape, tool names learned from compliant callers).
- opencodeSessionIdentity.ts / opencodeCliVersion.ts: canonical ses_/msg_
identity and opencode/<version> UA (>= 1.17, default 1.18.31).
- opencodeHeaders.ts: CLI header synthesis is ON by default (cheaper-inference#10571); a
client value wins and non-canonical sessions are translated one-to-one.
- base.ts: buildHeaders now receives the request body (gating needs it).
- executorClientHeaders.ts + chatCore.ts: preserve native conversation
identity before body translation, strip lease-control headers (cheaper-inference#14390).
- opencodeFreeTierRetry.ts + opencodeRequestContext.ts: retry a refusal once
with the observed tool list.
- opencode.ts: integrate the contract, send Accept: text/event-stream for
gated models, clean stream_options on non-streaming shapes (cheaper-inference#13819),
retry refusals on the fast path and during account rotation.
Ported from upstream PRs cheaper-inference#14013, cheaper-inference#10571, cheaper-inference#14390, cheaper-inference#13819.
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.
Summary
Requests to keyless OpenCode models have been answered
403since 2026-09-17, making the wholeoc/*-freecatalog unusable. The upstream accepts a request only when all four hold —stream: true, a non-emptytoolsarray, a session header shapedses_+ 12 hex + 14 base62, and aUser-Agentcarryingopencode/<version >= 1.17>— and removing any one turns a 200 into a 403. OmniRoute satisfied none. Paid models on the same host are not gated and stay untouched.The tools condition shapes this PR: the upstream inspects which names are declared. Measured twice per cell on 2026-09-18 — one made-up name, twelve made-up names and twelve plausible names from another vocabulary are all refused; three names from the official client are refused; six or more pass. What is accepted also differs by model and moved between two consecutive days, so no list is worth pinning here.
This PR reuses the one the traffic already carries. A request that goes out with tools and comes back accepted has its tool names remembered for that provider, model and — when the caller supplied a session id — that conversation. A later request of the same conversation carrying no tools goes out with the list its own client declared a moment earlier. That is the failing case exactly: a client sends its builds with a full tool list and its title and compaction requests with none, on one session, and only the second kind is refused (anomalyco/opencode#49433).
Also: a client
User-Agentis now preserved only when it already satisfies the contract (the previous rule preservedopencode-cli/…, which the upstream refuses), client ids are translated one-to-one into the accepted shape instead of passed verbatim, and notool_choiceis imposed anywhere — the upstream answers400 — only "auto" is supported. Supersedes #13937, keeping @AStupidBear's session regex and minimum-version rule.Related Issues
Validation
npm run lint— clean on the touched files with the repository suppressions; the full run is not green on the baseTests Added Or Updated
tests/unit/opencode-free-tier-request-contract.test.ts— 36 cases over scope, both body shapes, the reuse path (teach, borrow within a conversation, conversation list beating the shared one, drop after three refusals in a row, a refusal on the caller's own tools changing nothing) and both settings.opencode-cli-headers-synthesis-5997,opencode-session-fingerprint-headers-10571,refactor-buildHeaders-opencode,opencode-executor(updated): these pinned values the upstream now refuses — UUID ids,opencode-cli/…preservation, the bareopencodeuser-agent, a UUID session for Muse Responses.Negative controls on each new guard: lowering the refusal threshold, reintroducing
tool_choice, dropping the session from the store key and neutralising the store each fail the cases covering them.Coverage Notes
opencodeToolObservation.tsand the contract module: recording, scoping, resolution order, streak-based dropping, bounds, both body shapes, idempotence and the stream rebuild.opencodeHeaders.tsandopencode.ts: canonical rendering, translation, the client-session reader, theAcceptdecision and the rebuild through the real executor with a stubbed upstream. Neighbouring opencode suites stay green.Reviewer Notes
What is stored. Tool names only, never a schema or conversation content; the placeholder built from them carries an empty parameter object and the client's own "do not call this tool" description. In memory, 32 names per entry, 64 entries, least-recently-confirmed eviction.
Resolution order is conversation, model, configured list, single placeholder. The model step means one caller's tool names can be declared on another caller's request in the same instance. A borrowed list is dropped only after three refusals in a row, and only on requests that borrowed it — the same body was refused then accepted on one free model minutes apart, so one verdict is not proof.
Scope comes from the surface derived from the registry
baseUrl, not from provider ids./zen/go/v1refuses requests carrying tools (#44300, #44382 upstream) and is never brought up to this contract, including forox-alpha-free; that rests on those reports, since this installation exposes no model there. Background calls (discovery, quota fetcher) hit the same gate and are out of scope — #13005 covers them.Ordering with open PRs on these files. #13819 strips
stream_optionsunless the body is OpenAI-format withstream: true, which this PR sets intransformRequest— whichever lands second should check the strip reads the transformed body. #12719 synthesizes a session without canonical rendering on the opt-out path, a shape the upstream refuses; it should reuse the renderer kept here.Inherited gates.
check-api-typecheckfails identically at the bare base (codex-responses-ws/route.ts,chat.ts), neither touched here; the test file-size gate flagschatcore-translation-paths.test.ts, untouched, same drift as #14004.opencode.tsleaves this PR shorter than it entered (1192 → 1189), no rebaseline.