Repository navigation
fix(opencode): make the free tier work standalone (tool fingerprint + session value) - #16727
Closed
Moseyuh333 wants to merge 2 commits into
Closed
Moseyuh333 wants to merge 2 commits into
Moseyuh333 wants to merge 2 commits into
Conversation
… session value) OpenCode Free (`opencode`) and the free models of OpenCode Zen (`opencode-zen`) were answered `403 FreeTierError` whenever OmniRoute ran standalone, because two legs of the upstream client contract were missing: - the `tools` list is a fingerprint, not a non-empty check: the lowercase quartet `bash`/`glob`/`grep`/`read` must be declared, while a lone `_noop` placeholder, a single client tool or the capitalised `Bash` are refused; - the `x-opencode-session` value is decoded through the client's `Identifier.descending()` layout, so a shape-valid id with a foreign encoding is refused as well. The id is now re-minted inside the counter window and stays a pure function of the conversation seed, so prompt-cache affinity is preserved. The quartet is additive - the caller's own tool spellings are never renamed - the refusal-retry merge restores it too, and `opencode-go` plus paid models are untouched (the `go` surface refuses requests carrying tools). Validated live: both providers answer 200 through the patched executor from a standalone process (no CLI, no credentials), and the request it puts on the wire matches the one the real client sends. Refs cheaper-inference#16571, cheaper-inference#16328
- Drop the stale `void model;` in `OpencodeExecutor.buildHeaders`: `model` is already consumed by the free-tier scope check above it, and the dead statement was the line that pushed the file over its `check:file-size` freeze. - Revert the `docs/reference/FREE_TIERS.md` edit to its base text. A core-doc change owes `npm run i18n:run --files=<doc>` across 65 mirrors and the translation backend key is absent here, so the doc correction belongs in its own change; the measured contract stays documented in the changelog fragment and in the executor comments. - Rename the changelog fragment to the issue number (16571). No behaviour change: `buildHeaders` output is untouched (opencode suites green).
Contributor
Author
|
Closing as superseded — What landed elsewhere (all merged 2026-10-16):
Live check today against the release tip (
The remaining delta in this PR was the tool-fingerprint quartet measured on 2026-10-02/03; the current gate is satisfied by the release branch's single |
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.
release/v3.8.52is not release-green — the full unit suite and the package-artifact gates exceed their ceilings and one MCP vitest file fails on the base tip). None of those failures come from this branch; the focused suites listed below are green on it.What
OpenCode Free (
opencode, aliasoc) and the free models of OpenCode Zen (opencode-zen) currently only work when the caller is the OpenCode CLI. A standalone OmniRoute install (plain OpenAI client, no OpenCode client headers, no tools) is answered:Both providers share
https://opencode.ai/zen/v1, and on that surface the upstream validates the request against the OpenCode client's own contract. Two of its four legs were missing here:toolslist is a fingerprint, not a non-empty check. The upstream inspects the declared names and their casing. Measured live on 2026-10-02/03 over/chat/completionsand/responsesacross 9 gated free models: an empty list, a lone_noopplaceholder (what OmniRoute sent), a single client tool and the capitalisedBashspelling are all answered 403; the lowercase quartetbash,glob,grep,readis answered 200. Order is irrelevant and extra tools beside the quartet are accepted.x-opencode-sessionvalue is decoded, not just shape-checked. The 12 hex digits are read through the client'sIdentifier.descending()layout, where the low 12 bits carry0xFFF - counter(counter is the within-millisecond sequence, 0-63), so only a low-12 landing in0xFC0-0xFFFpasses (measured 2026-10-13/14). OmniRoute's previous render — 12 arbitrary hex digits — satisfied the regex and was still refused.How
open-sse/executors/opencodeFreeTierContract.ts—OPENCODE_FREE_TIER_FINGERPRINT_TOOLSis added to every gated body when the caller's own list does not spell the four names exactly. The caller's entries are never renamed or dropped: a client that spells one differently (Bashfrom Claude Code) keeps its own entry and gains the canonical one next to it, which is what the measurement allows. The refusal-retry merge (mergeClientToolsWithObserved) restores the quartet too, andaddsNothingBeyondFirstDispatchcounts it as already sent so no request is wasted re-sending a shape the upstream just refused.open-sse/utils/opencodeHeaders.ts—canonicalId("ses_")now renders the hex half in the client's own descending layout (~(timestamp36 << 12 | counter), counter pinned to 1, timestamp taken from the conversation hash). It stays a pure function of the conversation seed, so prompt-cache affinity is preserved.forwardOpencodeClientHeaders({ reencodeSession })re-mints a caller-supplied value that only matched the shape, deterministically from that value.open-sse/executors/opencode.ts— passesreencodeSession: gatedScope, i.e. only for the zen surface + non-premium model.Live validation (executor driven against the real upstream, no mocks)
Both providers were exercised through the patched
OpencodeExecutorfrom a standalone process (no OpenCode CLI in the loop, no credentials):opencodedeepseek-v4.1-flash-free200,"OK"POST https://opencode.ai/zen/v1/chat/completions,stream: true, toolsbash,glob,grep,read,_noop,x-opencode-session: ses_…ffe…opencode-zenbig-pickle200,"OK"The outgoing request was also compared against the one the real client sends: OpenCode CLI
1.19.0was installed and pointed at a local logging proxy, and its captured request matches this branch's wire shape on every leg (endpoint,stream: true, session layout,x-opencode-client: desktop,x-opencode-project: global,x-opencode-request,User-Agent: opencode/<version>). The CLI additionally sendsAuthorization: Bearer publicandx-opencode-session-id; neither is required for the upstream verdict — a request without them still answered200after a 12-minute idle window from the same egress, and the tool-less control in the same batch answered403— so they are deliberately not sent.One honest caveat from that session: the gate answered
403to every shape for a window of a few minutes — including shapes it answered200to minutes later and that the CLI itself accepts — while the tools rule held throughout (a tool-less body was refused before, during and after that window). So the upstream verdict is stable in the "what must be declared" dimension and evidently transient in another; the fix targets the former.Scope boundaries (deliberate)
https://opencode.ai/inference/v1; that is a separate change with its own blast radius and stays out of this PR. (The live capture confirms the client uses that path, while both paths currently answer identically.)isPremiumOpencodeModelkeeps the-freesuffix + known-set rules. Making it read the synced catalog means importingsrc/lib/*fromopen-sse/executors/*, which is exactly the couplingscripts/quality/measure-boundary.mjstracks as "should trend to zero" for v4 — the right home is a follow-up that passes the classification into the engine from the app layer. fix(providers): mint OpenCode free-tier sessions with the client's descending Identifier encoding #16598 already carries that half.opencode-goand paid models untouched (gorefuses requests carrying tools — upstream Zen API: x-preview-f-free / ox-alpha-free fails with "Endpoint is unavailable" for any request containing tools anomalyco/opencode#44300/#44382), and non-OpenAI body formats still only getstream: true(the tool shapes are unmeasured there).bareprobe path is unchanged. It is the store's adaptive attempt at a tool-less shape; the quartet is injected on thetoolsshape only.Tests
TDD:
tests/unit/opencode-standalone-free-tier-16571.test.tswas written first and ran red (10 of 12 failing; the 2 green ones are the negative controls that must keep passing), then the fix made it green (14/14). It covers both providers, both surfaces (/chat/completions+/responses), the client-tool-preservation rule, idempotence, the retry merge, the re-mint determinism, theopencode-goexclusion, and one end-to-endexecute()assertion that the outgoing request carries all four legs.Existing suites touched by the new baseline were updated, never weakened (per-file assertion counts are equal or higher):
The two failures this branch's
npm run test:vitestrun reports (tests/unit/autoCombo/tieredRotation.test.ts,open-sse/services/autoCombo/__tests__/pipeline-router-taskfitness-mismatch.test.ts) are timeouts under a loaded machine — they pass when re-run on their own — and belong to the base-red set tracked in #15306, not to this change, which touches no autoCombo path.Overlap with in-flight PRs (no code copied, independent implementation)
Closes #16571, #16328.
Changelog
changelog.d/fixes/0000-opencode-free-tier-standalone.md