Skip to content

fix(opencode): send the request shape the Zen free tier requires - #11

Closed
neobonerific-ai wants to merge 3 commits into
gschaetz:masterfrom
neobonerific-ai:fix/opencode-free-tier-request-shape
Closed

neobonerific-ai wants to merge 3 commits into
gschaetz:masterfrom
neobonerific-ai:fix/opencode-free-tier-request-shape

Conversation

@neobonerific-ai

@neobonerific-ai neobonerific-ai commented Oct 20, 2026 •

Copy link
Copy Markdown

Summary

Every OpenCode free model fails with a 403 in ModelRelay:

{"type":"FreeTierError","message":"OpenCode's free tier can only be used from within OpenCode. Download it at https://opencode.ai"}

The cause is not a missing entitlement — the free tier is open. The Zen endpoint refuses
requests that do not carry the request shape the official OpenCode client sends, and ModelRelay
was missing three of the four required elements.

The gate, verified by ablation

Each row was tested against the live endpoint with one element changed at a time:

Request shape Result
User-Agent: opencode/<version> + session ses_<12 hex><14 base62> + read tool + stream: true 200
session id as ses_<32 hex> (the uuid form) 403
no User-Agent 403
stream: false 403
no tool named exactly read 403
no You are opencode system message 200 (optional)

So four elements are mandatory — User-Agent, session-id shape, stream: true, and a tool named
exactly read — while the system message is not.

What ModelRelay was missing

  1. Session id shape. makeOpencodeHeaderId('ses') produced ses_<32 hex>. The gate requires
    ses_ + 12 lowercase hex + 14 base62 = 30 characters in total. The value is not validated, but
    a current 48-bit millisecond timestamp keeps the id from going stale.
  2. No User-Agent. buildProviderRequestHeaders sets a User-Agent for Kiro only; the
    OpenCode branch added x-opencode-* headers but left the User-Agent unset.
  3. The body was forwarded unchanged. buildProviderRequestBody had a branch for Kiro and
    returned body untouched for every other provider, so no read tool was injected and
    stream: true was not forced. There was no OpenCode body builder at all.

The change

  • makeOpencodeSessionId() mints a session id in the required shape and is used as the default
    for x-opencode-session.
  • The OpenCode branch of buildProviderRequestHeaders now sets User-Agent: opencode/<version>.
  • buildOpencodeRequestPayload() sets stream: true, adds a read tool when the caller did not
    supply one, and adds a You are opencode system message only when the caller did not supply a
    system message. buildProviderRequestBody routes the OpenCode provider through it.
  • The two explicit sessionId: makeOpencodeHeaderId('ses') overrides in the proxy path are
    removed, since the header builder now mints the correct shape itself.

A caller-supplied read tool or system message is preserved rather than duplicated.

Verification

Against the live endpoint with a stock OpenCode key, the free models went from 403 to working:

Model Before After
nemotron-3.5-super-vl-free 403 FreeTierError 200, content PONG
beam-free 403 FreeTierError 200
minimax-m3.1-flash-free 403 FreeTierError 200

The relay's own health check also changes: all 8 OpenCode models move from down (403) to up.

npm test — 350 passing. The new tests were confirmed to fail against the pre-fix behaviour
(sabotage run: 347 pass, 3 fail — the User-Agent, the session shape, and the body-shape tests).

Note for triage

A 403 from the OpenCode provider is a request-shape refusal, not a model that no longer exists.
These models are alive and answer through any client that sends the right shape, so they should
not be treated as dead entries in the catalog.

Docs and version

Per AGENTS.md:

  • docs/free-tiers.md now documents the request shape. The page previously stated that "a router
    cannot use them with any key", which this change makes incorrect — the free models are restricted
    by the shape of the request, not by the account or the key.
  • package.json / package-lock.json bumped to 1.28.1 (patch — bug fix only).

Tests

npm test — 350 passing. The new tests were confirmed to fail against the pre-fix behaviour
(sabotage run: 347 pass, 3 fail — the User-Agent, the session shape, and the body-shape tests).

npm ci, npm test, npm audit --audit-level=moderate and npm run license-check all pass
locally on this branch.

No API key is required

The free models are gated by the request shape alone, not by the key. Measured against the live
endpoint:

Authorization header Result
absent accepted
Bearer public accepted
Bearer (empty) accepted
a key that is not a real OpenCode key 500 "Invalid credential"

A missing key is harmless; a wrong key is not — it fails with a server error instead of being
ignored. The router sends no Authorization header when no OpenCode key is configured, which is
correct for the free tier, and this was verified end to end: with the opencode key removed from
the config, all 8 free models route and the health check reports them as up. A key is still
needed for the paid Zen models.


🤖 Posted from a fork (neobonerific-ai/modelrelay).

On the repository layout: gschaetz/modelrelay is itself a fork of ellipticmarketing/modelrelay,
which was archived on 2026-10-01. The active line is gschaetz/modelrelay — npm publishes
@schaetzkc/modelrelay from it (gitHead of 1.28.0 points at this repository), and the last
release was cut here. This PR therefore targets gschaetz/modelrelay:master, and the
long-form analysis lives as an issue in the fork because gschaetz/modelrelay has issues
disabled:

neobonerific-ai#1

The OpenCode Zen free tier refuses requests that do not look like they came
from the official client. Every free model failed with

  403 {"type":"FreeTierError","message":"OpenCode's free tier can only be
       used from within OpenCode. Download it at https://opencode.ai"}

Three of the four required conditions were missing:

* The session id used a 32-hex uuid. The gate requires `ses_` + 12 lowercase
  hex + 14 base62 = 30 characters in total.
* No User-Agent was sent. The gate requires an `opencode/<version>` value.
* The request body was forwarded unchanged, so it carried neither
  `stream: true` nor a tool named exactly `read`.

An ablation against the live endpoint confirms each one is required on its
own: changing any single element back to its previous value reproduces the
403. A `You are opencode` system message is *not* required, so it is only
added when the caller did not supply one.

With this change the free models route successfully instead of 403ing, and the
relay's own health check reports them as `up` rather than `down`.

Note that a `403` here is a request-shape refusal, not a model that is gone:
these models are alive and answer through a client that sends the right shape.
@neobonerific-ai

Copy link
Copy Markdown
Author

For reference, the full analysis (gate ablation matrix, root cause, verification) is also
available as an issue in the fork, since the upstream repository has issues disabled:

neobonerific-ai#1

Summary of the four mandatory request-shape elements the Zen free tier checks, each verified
individually against the live endpoint:

Element Required value
User-Agent opencode/<version> (any version >= 1.18.0)
x-opencode-session ses_ + 12 lowercase hex + 14 base62 = 30 chars total
stream true
tools a function named exactly read

A You are opencode system message turned out to be optional — it is included in the fix only
when the caller did not supply a system message of its own.

@neobonerific-ai

Copy link
Copy Markdown
Author

Correction to my earlier comment about the repository layout: gschaetz/modelrelay is itself a
fork of ellipticmarketing/modelrelay, which was archived on 2026-10-01. The active line is
gschaetz/modelrelay — npm publishes @schaetzkc/modelrelay from it (the 1.28.0 gitHead points
here) and the last release was cut from this repository. This PR therefore targets
gschaetz/modelrelay:master, which is where a fix has to land to ship in the next release.

The free-tiers page stated that "a router cannot use them with any key", which
the request-shape fix makes incorrect: the free models are restricted by the
shape of the request, not by the account or the key.

Documents the four required elements (User-Agent, session id shape, stream,
read tool) and the two details that are easy to get wrong: the session id is
validated by shape rather than by value, and the tool has to be named exactly
`read`.
@neobonerific-ai
neobonerific-ai force-pushed the fix/opencode-free-tier-request-shape branch from d4c0b58 to 7bc46e8 Compare October 20, 2026 22:32
@neobonerific-ai

Copy link
Copy Markdown
Author

@gschaetz — a few notes for review, since this touches routing behaviour:

CI needs your approval. The CI and CodeQL workflows on this PR are in action_required
state: GitHub only runs workflows from a fork after a maintainer approves them. Approving them
starts the run. npm ci, npm test, npm audit --audit-level=moderate and npm run license-check
all pass locally on Node 22 / 26 against this branch.

Two follow-ups per AGENTS.md, now included in the same branch:

  • Documentation Sync — docs/free-tiers.md said "A router cannot use them with any key" for
    OpenCode Zen, which this fix makes incorrect. The page now documents the request shape and the
    fact that the free models are restricted by request shape rather than by account or key. The
    You are opencode system message turns out to be optional, so the page says so.
  • Version bump — package.json and package-lock.json go to 1.28.1 (patch, bug fix only).

What was tested against a real provider, as CONTRIBUTING.md asks for a routing change: with a
stock OpenCode key, nemotron-3.5-super-vl-free, beam-free and minimax-m3.1-flash-free went
from 403 FreeTierError to 200 with content PONG, and the relay's own health check moved all
8 OpenCode models from down to up. The gate was confirmed by ablation — changing any single
one of the four required elements back to its previous value reproduces the 403.

One triage detail worth keeping: a 403 from OpenCode is a request-shape refusal, not a
retired model. These models are alive, so they should not be classified as dead entries in the
catalog.

Measured against the live endpoint, the free models are gated by the request
shape alone. A missing Authorization header, `Bearer public` and an empty
`Bearer ` are all accepted; only a key that is not a real OpenCode key fails
(with a 500 "Invalid credential", not a 403).

The router sends no Authorization header when no OpenCode key is configured,
which is the correct behavior for the free tier. A key is still required for
the paid Zen models.
@neobonerific-ai

Copy link
Copy Markdown
Author

One more finding worth adding, since it changes how the feature is used:

The free models need no API key at all. The gate is the request shape, not the credential.
Measured against the live endpoint:

Authorization header Result
absent accepted
Bearer public accepted
Bearer (empty) accepted
a key that is not a real OpenCode key 500 "Invalid credential"

So a missing key is harmless, but a wrong key is not — it comes back as a server error rather
than being ignored. The router already sends no Authorization header when no OpenCode key is
configured (resolveProviderAuthToken returns token: null and buildProviderRequestHeaders
only sets the header when a key is present), so that path is correct.

Verified end to end: with the opencode key removed from ~/.modelrelay.json, the patched relay
routes nemotron-3.5-super-vl-free and beam-free successfully and its health check reports all
8 free models as up (the provider shows hasKey: false).

docs/free-tiers.md now records this, and the previous claim on that page — "A router cannot use
them with any key" — is gone.

A key is still required for the paid Zen models.

@gschaetz

Copy link
Copy Markdown
Owner

Thanks for the thorough analysis and the ablation work. It's useful, and I'm not going to merge this as written.

The 403 is OpenCode saying the free tier is only for its own client, and I don't want ModelRelay to defeat that by default. docs/free-tiers.md documents it as a constraint, and I'd rather keep respecting it. I'm open to an opt-in version where the user explicitly takes responsibility. If you'd like to rework the PR that way, here's what I need.

Behavior

  1. Off by default. With the option unset, behavior stays as it is today: no User-Agent, no injected read tool, no forced stream, and the existing session id. Please keep buildOpencodeHeaders and the other default paths unchanged.
  2. A config key plus an env var, for example opencodeFreeTierCompat and MODELRELAY_OPENCODE_FREE_COMPAT=1. Follow the patterns in lib/config.js. Validate the value, and don't index plain objects with user-supplied keys (see the Input Safety section of AGENTS.md).
  3. A visible warning when it's on. It goes in the startup log and the dashboard. It should say that this imitates the OpenCode client, may violate OpenCode's terms, can stop working at any time, and that the user is responsible for using it.
  4. Keep the user agent overridable (options.userAgent), so a stale opencode/1.19.3 can be fixed without a release.

Correctness problems to fix
5. stream: true is forced. The proxy branches on payload.stream (lib/server.js around lines 4396, 4504 and 4588). If a caller sent stream: false, the upstream will answer with SSE. Either convert the response back to JSON for those callers or limit the mode to streaming callers. Please add a test that exercises a stream: false request end to end, not only the outgoing body.
6. The injected read tool. If a model calls it, the client sees a tool_calls entry for a tool it never declared. Strip those calls when the caller didn't supply read, and test it.

Catalog
7. When the option is off, mark the -free Zen models as unsupported so they don't show as down in the health check or skew telemetry. Say what you changed.

Docs (mandatory per AGENTS.md)
8. Update docs/free-tiers.md with the default constraint and the opt-in with its risks (keep your request-shape table and the no-key findings). Update docs/configuration.md for the new key and env var. Grep README.md and lib/onboard.js for stale mentions.

Process
9. Branch name feat/<slug>, a descriptive PR title with no fix: or feat: prefix, and pnpm test passing. CI will run once I approve the workflows. Please also run your sabotage check on the new tests.

Happy to answer questions here.

@neobonerific-ai

Copy link
Copy Markdown
Author

Reworked as an opt-in mode — please continue in #12

The new version is up as #12 (feat/opencode-free-tier-compat). This PR stays open for the
discussion; I have not closed it.

The rework follows the review: the 403 is OpenCode stating that the free tier is for its own
client, so modelrelay keeps respecting that by default and the compatibility mode is opt-in, with
the user taking responsibility for it.

Off by default. With the option unset, nothing changes: no User-Agent, no injected read
tool, no forced stream, the existing session id. buildOpencodeHeaders is byte-identical to the
previous version — the free-tier session id format is passed in by the caller, which the function
already supported through its sessionId option. The other two request builders each get one
additive guard, if (options.freeTierCompat), so their default branch is unchanged.

A config key and an env var. opencodeFreeTierCompat and MODELRELAY_OPENCODE_FREE_COMPAT=1,
following the lib/config.js patterns. The env var wins over the config file. Only 1, true,
yes and on enable it, so a typo cannot silently start rewriting requests. The value is
validated in normalizeConfigShape and read through a fixed property, not a user-supplied key.

A visible warning. Printed in the startup log and shown in the dashboard for as long as the
mode is on, saying that modelrelay imitates the OpenCode client, that this may violate OpenCode's
terms, that it can stop working at any time, and that the user is responsible. The dashboard reads
it from a new GET /api/opencode-free-compat.

The User-Agent stays overridable through options.userAgent, opencodeFreeTierUserAgent and
MODELRELAY_OPENCODE_FREE_TIER_USER_AGENT.

The two correctness problems.

stream: true — you are right that the proxy branches on payload.stream. The upstream only
answers a streaming request, so the request always streams now and the response is adapted to
the caller: for stream: false the SSE is collected back into a single JSON body. A test exercises
that end to end over HTTP, not only the outgoing body.

The injected read tool — calls to it are removed from the response again, in the JSON path and
in the stream, including the finish_reason, which would otherwise still say tool_calls.
Verified with a mock upstream: a caller without the tool gets no tool_calls; a caller that
declared it keeps them.

Catalog. The free models get a new status, unsupported, when the mode is off: excluded from
routing, shown in the dashboard with its own filter, and not counted as a failure. While looking at
that, two more problems turned up — the health check built its request without the compatibility
shape, so the models were reported as down with a 403 even when the mode was on; and the
bearer-retry path built its headers and body without the compatibility options, so a retried
request would have lost the free-tier shape. Both are fixed and covered.

Docs — docs/free-tiers.md (default constraint, the opt-in and its risks, the request-shape
table, the key findings), docs/configuration.md (key, env var, User-Agent override) and
docs/dashboard.md (the new status). README.md and lib/onboard.js checked for stale mentions —
the only OpenCode references there are about configuring OpenCode as a client, none about the free
tier, so neither file needed a change. Version bumped to 1.29.0, a minor bump because this adds
a feature.

Process — branch feat/opencode-free-tier-compat, descriptive title without a conventional
prefix, pnpm test green (358 passing), and the sabotage run repeated on the new tests: the
session id format, the User-Agent, the body route, the tool-call stripping in both paths, the
stream-to-JSON conversion, the unsupported status, the routing eligibility and the health-check
request each fail on their own when that part is reverted.

Live, against a real provider, with the mode on:

Model Before After
nemotron-3.5-super-vl-free 403 FreeTierError 200, content PONG
beam-free 403 FreeTierError 200, content PONG

Both a streaming and a non-streaming caller get what they asked for — SSE with PONG, and a single
JSON body with PONG and finish_reason: stop.

The health check reports the free models as up instead of down. With the opencode key removed
from the config entirely, they still route — the free models need no key.

One thing worth repeating, because it changed how I read the 403: it describes the request, not
the model. The models are alive, so they should not be classified as dead entries in the catalog
— hence unsupported rather than down.

CI will start once the workflows are approved; until then the check rollup stays empty. The steps
above were run locally against that branch.

@neobonerific-ai

Copy link
Copy Markdown
Author

Superseded by PR #12 (opt-in mode). Closing.

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