Skip to content

fix(opencode): record the free-tier refusal instead of counting it as success - #14011

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
maxmad64bis:fix/opencode-freetier-403-classification
Sep 18, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
maxmad64bis:fix/opencode-freetier-403-classification

Conversation

@maxmad64bis

@maxmad64bis maxmad64bis commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #14004

Summary

The OpenCode free tier has been answering 403 since 2026-09-17, and OmniRoute treats that refusal as a healthy response: it is never classified, nothing is recorded on the connection, and the account that was just refused is marked successful — clearing the failure history that drives its cooldown. A refusal improves the rotation health of the account it hits. One layer up, the per-model lockout arm (#3027/#12242) also recorded it as this model being forbidden on this connection; since every sibling account answers the same request the same way, one refusal per account empties the pool, after which requests that would have been served are answered no active credentials.

The refusal is scoped to the request, not the account or the model: four different API keys and an anonymous request return the same 403 for the same request, and the same key returns 200 once the request matches the upstream contract. So it is now recognized, returned unchanged without a pointless hop across accounts, recorded as a non-banning routing error, and leaves rotation state alone. Every other 403 keeps its existing handling. Behaviour change worth calling out: the account-health reset now requires an HTTP success, where previously any status without a dedicated branch (403, 451, 401, 404, 422, and a 400 carrying a real upstream error) reset the failure history.

This does not make the free tier work again — the request contract is a separate concern, tracked in #13935 and addressed by #14013. It makes the failure honest.

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

  • opencode-free-tier-refusal-predicate.test.ts (new): the predicate on the verbatim body and on the relayed message alone, 403 and 451 alike, precedence over the geo-block, user_blocked and fingerprint predicates, edge bodies and statuses, the provider scope, and and a parity table pinning the executor predicate, the classifier and the auth guard to the same vectors, including the 402 that must stay a per-model credit failure.
  • opencode-free-tier-refusal-rotation.test.ts (new): returned as-is with one upstream call and no rotation, history and cooldown untouched; a 400 carrying a real error and an unhandled 401 no longer reset the account; a success still does.
  • opencode-free-tier-refusal-no-model-lockout.test.ts (new): the refusal leaves the model usable on the account it landed on and the connection active and uncooled, while a foreign provider echoing the sentence, and a 402 carrying it, both keep their existing handling. Negative controls: removing the guard fails the first case, widening the recognition to 402 fails the last, and dropping the provider scope fails the scope case.

Coverage Notes

  • opencodeGeoBlock.ts and errorClassifier.ts by the predicate file, including every exclusion vector and an ordinary api-key 403 that must stay unclassified; opencode.ts and the extracted opencodeAccountHealth.ts by the rotation file, which drives the real executor loop with a stubbed upstream; auth.ts by the third file, through markAccountUnavailable against a real connection.

Reviewer Notes

Why an early return in auth.ts rather than an extra condition. Excluding the refusal from the per-model arm alone sends it into the connection-wide path, which marks the whole connection unavailable — worse than the lockout it avoids. The early return sits before any state write, mirroring the existing isTerminalConnectionStatus guard in the same function, and is scoped to opencode* the same way classifyProviderError scopes it.

Why PROJECT_ROUTE_ERROR. FORBIDDEN writes the terminal banned state, GEO_BLOCKED parks the connection 24h, FINGERPRINT_REJECTION describes a CDN signature refusal. PROJECT_ROUTE_ERROR is the existing family for "recoverable, explicitly not a ban". The gain is a correct error_type in the call log instead of unknown, not new routing behaviour. Recognition keys on the sentence because parseUpstreamError keeps error.type aside, so matching the token alone would never fire in production.

Ordering with open PRs on these files. #13548 widens the same per-model arm for credit exhaustion — textual conflict likely, and both move the same way: it takes a failure from the connection down to the model, this takes one from the model down to the request. #12340 does the same for another provider family. #13161 widens the fingerprint predicate, which this one excludes before matching.

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 ends at 1182 against a 1200 cap; auth.ts gains 5 lines and stays under its frozen size.

@maxmad64bis
maxmad64bis force-pushed the fix/opencode-freetier-403-classification branch from 0322719 to 4645275 Compare September 17, 2026 18:30
@maxmad64bis
maxmad64bis marked this pull request as ready for review September 17, 2026 18:31
@maxmad64bis
maxmad64bis force-pushed the fix/opencode-freetier-403-classification branch 4 times, most recently from 6772e30 to 543dabd Compare September 18, 2026 00:28
… success

An OpenCode Zen free-tier 403 ("free tier can only be used from within
OpenCode") reached the end of the executor loop unrecognized: nothing was
persisted about it, and the account that had just been refused was marked
successful, which clears the failure history driving its cooldown backoff. A
refusal was therefore improving the rotation health of the account it hit.

The refusal is now recognized by its own predicate, returned unchanged without
rotating (it is request-scoped, so every sibling account returns the same
verdict), and classified as a non-banning routing error, so the connection
records lastErrorType/lastError/errorCode and stays active.

The account-health reset is also reserved for HTTP successes at both call sites
in the loop, since the same reset ran on any status the loop did not handle in a
dedicated branch.
@maxmad64bis
maxmad64bis force-pushed the fix/opencode-freetier-403-classification branch from 543dabd to 9db0c20 Compare September 18, 2026 00:34
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks @maxmad64bis — merging via the release merge-train. Validated in local merge-train (.claude/worktrees/merge-train-20260918-111718-suite.log) on the devbox @ train tip 7bb373fba5e241964c0ffb17bd03a804700839bb, boarded with 55 sibling PRs: typecheck:core, file-size, complexity, cognitive-complexity, changelog-integrity green; 747/747 changed-area node:test cases + 476/476 vitest green (fast parity mode — the full suite ran today on the release tip via the base-red train and runs again on the 3b train). Merged --admin per merge-gates §7.

@diegosouzapw
diegosouzapw merged commit fec8dc2 into diegosouzapw:release/v3.8.51 Sep 18, 2026
9 of 16 checks passed
diegosouzapw added a commit that referenced this pull request Sep 18, 2026
Match the upstream OpenCode free-tier request contract (issue #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 #13937 (session regex and minimum-version rule kept, credited below).
Complements #14011 (refusal classification) and #13819 (stream_options strip),
both already merged.

Closes #13935

Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@maxmad64bis
maxmad64bis deleted the fix/opencode-freetier-403-classification branch September 24, 2026 21:14
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
… success (diegosouzapw#14011)

An OpenCode Zen free-tier 403 ("free tier can only be used from within
OpenCode") reached the end of the executor loop unrecognized: nothing was
persisted about it, and the account that had just been refused was marked
successful, which clears the failure history driving its cooldown backoff. A
refusal was therefore improving the rotation health of the account it hit.

The refusal is now recognized by its own predicate, returned unchanged without
rotating (it is request-scoped, so every sibling account returns the same
verdict), and classified as a non-banning routing error, so the connection
records lastErrorType/lastError/errorCode and stays active.

The account-health reset is also reserved for HTTP successes at both call sites
in the loop, since the same reset ran on any status the loop did not handle in a
dedicated branch.

Co-authored-by: Max <maxmad64@gmail.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…uzapw#14013)

Match the upstream OpenCode free-tier request contract (issue diegosouzapw#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 diegosouzapw#13937 (session regex and minimum-version rule kept, credited below).
Complements diegosouzapw#14011 (refusal classification) and diegosouzapw#13819 (stream_options strip),
both already merged.

Closes diegosouzapw#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
A 403/451 FreeTierError refusal says nothing about the account or the model:
every sibling account gets the same verdict for the same request, and the
same account answers 200 once the request matches the contract. Handle it
without poisoning account health (upstream diegosouzapw#14011, diegosouzapw#14675, diegosouzapw#14313):

- errorClassifier.ts: classify the refusal as PROJECT_ROUTE_ERROR instead of
  FORBIDDEN (would ban the connection permanently) or GEO_BLOCKED (would park
  a healthy account for 24h).
- auth.ts: zero-cooldown fallback for the refusal; per-model 402 credits keep
  the model-only lockout on passthrough providers (connection stays active);
  skip the synthetic noauth candidate while its short-TTL refusal skip is on.
- comboPredicates.ts: request-scoped classification for combos, both by
  FreeTierError type and by the relayed sentence alone (the parser keeps
  error.type aside, so the body sentence is the reachable signal).
- resilienceCandidateFilter.ts: exclude the skipped synthetic noauth node.
- noauth.ts + FREE_TIERS.md: disclose the client-contract restriction so the
  dashboard does not promise keyless access upstream will refuse.
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