Skip to content

fix(opencode): make the free tier work standalone (tool fingerprint + session value) - #16727

Closed
Moseyuh333 wants to merge 2 commits into
cheaper-inference:release/v3.8.52from
Moseyuh333:fix/16571-opencode-free-zen-standalone
Closed

Moseyuh333 wants to merge 2 commits into
cheaper-inference:release/v3.8.52from
Moseyuh333:fix/16571-opencode-free-zen-standalone

Conversation

@Moseyuh333

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #15306 (release/v3.8.52 is 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, alias oc) 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:

403 {"error":{"type":"FreeTierError","message":"... OpenCode's free tier can only be used from within OpenCode"}}

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:

  1. The tools list 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/completions and /responses across 9 gated free models: an empty list, a lone _noop placeholder (what OmniRoute sent), a single client tool and the capitalised Bash spelling are all answered 403; the lowercase quartet bash, glob, grep, read is answered 200. Order is irrelevant and extra tools beside the quartet are accepted.
  2. The x-opencode-session value is decoded, not just shape-checked. The 12 hex digits are read through the client's Identifier.descending() layout, where the low 12 bits carry 0xFFF - counter (counter is the within-millisecond sequence, 0-63), so only a low-12 landing in 0xFC0-0xFFF passes (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_TOOLS is 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 (Bash from 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, and addsNothingBeyondFirstDispatch counts 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 — passes reencodeSession: 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 OpencodeExecutor from a standalone process (no OpenCode CLI in the loop, no credentials):

Provider Model Result Request observed on the wire
opencode deepseek-v4.1-flash-free 200, "OK" POST https://opencode.ai/zen/v1/chat/completions, stream: true, tools bash,glob,grep,read,_noop, x-opencode-session: ses_…ffe…
opencode-zen big-pickle 200, "OK" same shape

The outgoing request was also compared against the one the real client sends: OpenCode CLI 1.19.0 was 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 sends Authorization: Bearer public and x-opencode-session-id; neither is required for the upstream verdict — a request without them still answered 200 after a 12-minute idle window from the same egress, and the tool-less control in the same batch answered 403 — so they are deliberately not sent.

One honest caveat from that session: the gate answered 403 to every shape for a window of a few minutes — including shapes it answered 200 to 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)

Tests

TDD: tests/unit/opencode-standalone-free-tier-16571.test.ts was 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, the opencode-go exclusion, and one end-to-end execute() 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):

node --import tsx/esm --test tests/unit/opencode-standalone-free-tier-16571.test.ts   # 14 pass
node --import tsx/esm --test <all tests/unit/opencode-*.test.ts>                      # 865 pass, 0 fail
node --import tsx/esm --test tests/unit/{db-proxies-ventilated,refactor-opencodeHeaders,session-affinity-generic-7274}.test.ts  # 49 pass
npm run typecheck:core                                                                # clean
npx eslint <changed files>                                                            # clean
npm run check:docs-all                                                                # pass
npm run check:changelog-integrity                                                      # OK
node scripts/check/check-test-masking.mjs   # OK — nothing weakened

The two failures this branch's npm run test:vitest run 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

… 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
Copilot AI balanced review requested due to automatic review settings October 15, 2026 15:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

- 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).
@Moseyuh333

Copy link
Copy Markdown
Contributor Author

Closing as superseded — release/v3.8.52 now serves the standalone free-tier path itself.

What landed elsewhere (all merged 2026-10-16):

Live check today against the release tip (3c3edb9e) — fresh process, no OpenCode CLI headers, no credentials, OpencodeExecutor called directly:

  • opencode + big-pickle → 200 OK, session ses_…ffe…, UA opencode/1.18.31, endpoint /inference/v1/chat/completions, tools ["read"].
  • opencode-zen + big-pickle → 200 OK.
  • Declared-tool shapes: none / capitalised Bash,Glob,Grep,Read (Claude Code style) / lowercase quartet / single custom tool / empty schemas / entries without parameters → 7/7 × 200.

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 read placeholder with a real schema, so this PR is redundant. Thanks for moving the fix through.

@Moseyuh333 Moseyuh333 closed this Oct 17, 2026
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.

fix(providers): OpenCode free-tier requests return FreeTierError outside OpenCode

2 participants