Skip to content

fix(opencode): match the upstream free-tier request contract - #14013

Merged
diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.51from
maxmad64bis:fix/opencode-free-tier-request-contract
Sep 18, 2026
Merged

diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.51from
maxmad64bis:fix/opencode-free-tier-request-contract

Conversation

@maxmad64bis

@maxmad64bis maxmad64bis commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #14004

Summary

Requests to keyless OpenCode models have been answered 403 since 2026-09-17, making the whole oc/*-free catalog unusable. The upstream accepts a request only when all four hold — stream: true, a non-empty tools array, a session header shaped ses_ + 12 hex + 14 base62, and a User-Agent carrying opencode/<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-Agent is now preserved only when it already satisfies the contract (the previous rule preserved opencode-cli/…, which the upstream refuses), client ids are translated one-to-one into the accepted shape instead of passed verbatim, and no tool_choice is imposed anywhere — the upstream answers 400 — only "auto" is supported. Supersedes #13937, keeping @AStupidBear's session regex and minimum-version rule.

Related Issues

Validation

  • Change type: provider
  • Focused tests and category gates from the golden path
  • npm run lint — clean on the touched files with the repository suppressions; the full run is not green on the base
  • Reconciled with the current active release base; focused checks rerun afterward
  • Production-code changes include a new or updated automated test in this PR

Tests 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 bare opencode user-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

  • New opencodeToolObservation.ts and the contract module: recording, scoping, resolution order, streak-based dropping, bounds, both body shapes, idempotence and the stream rebuild.
  • opencodeHeaders.ts and opencode.ts: canonical rendering, translation, the client-session reader, the Accept decision 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/v1 refuses requests carrying tools (#44300, #44382 upstream) and is never brought up to this contract, including for ox-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_options unless the body is OpenAI-format with stream: true, which this PR sets in transformRequest — 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-typecheck fails identically at the bare base (codex-responses-ws/route.ts, chat.ts), neither touched here; the test file-size gate flags chatcore-translation-paths.test.ts, untouched, same drift as #14004. opencode.ts leaves this PR shorter than it entered (1192 → 1189), no rebaseline.

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>
@diegosouzapw
diegosouzapw merged commit 0349627 into cheaper-inference:release/v3.8.51 Sep 18, 2026
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.)
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>
@maxmad64bis
maxmad64bis deleted the fix/opencode-free-tier-request-contract branch September 24, 2026 21:14
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>
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.
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