fix(opencode): record the free-tier refusal instead of counting it as success - #14011
Merged
diegosouzapw merged 1 commit intoSep 18, 2026
Conversation
maxmad64bis
force-pushed
the
fix/opencode-freetier-403-classification
branch
from
September 17, 2026 18:30
0322719 to
4645275
Compare
maxmad64bis
marked this pull request as ready for review
September 17, 2026 18:31
maxmad64bis
force-pushed
the
fix/opencode-freetier-403-classification
branch
4 times, most recently
from
September 18, 2026 00:28
6772e30 to
543dabd
Compare
… 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
force-pushed
the
fix/opencode-freetier-403-classification
branch
from
September 18, 2026 00:34
543dabd to
9db0c20
Compare
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
merged commit Sep 18, 2026
fec8dc2
into
diegosouzapw:release/v3.8.51
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>
4 of 5 tasks
Merged
5 tasks done
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.
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.
Summary
The OpenCode free tier has been answering
403since 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 answeredno 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
npm run lint— clean on the touched files with the repository suppressions; the full run is not green on the baseTests 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_blockedand 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.tsanderrorClassifier.tsby the predicate file, including every exclusion vector and an ordinary api-key 403 that must stay unclassified;opencode.tsand the extractedopencodeAccountHealth.tsby the rotation file, which drives the real executor loop with a stubbed upstream;auth.tsby the third file, throughmarkAccountUnavailableagainst a real connection.Reviewer Notes
Why an early return in
auth.tsrather 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 existingisTerminalConnectionStatusguard in the same function, and is scoped toopencode*the same wayclassifyProviderErrorscopes it.Why
PROJECT_ROUTE_ERROR.FORBIDDENwrites the terminal banned state,GEO_BLOCKEDparks the connection 24h,FINGERPRINT_REJECTIONdescribes a CDN signature refusal.PROJECT_ROUTE_ERRORis the existing family for "recoverable, explicitly not a ban". The gain is a correcterror_typein the call log instead ofunknown, not new routing behaviour. Recognition keys on the sentence becauseparseUpstreamErrorkeepserror.typeaside, 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-typecheckfails identically at the bare base (codex-responses-ws/route.ts,chat.ts), neither touched here; the test file-size gate flagschatcore-translation-paths.test.ts, untouched, same drift as #14004.opencode.tsends at 1182 against a 1200 cap;auth.tsgains 5 lines and stays under its frozen size.