Three real defects flagged in the post-merge review of #616, plus one cheap
hardening:
1. OpenCode `limit.output` override was a silent no-op on opencode-ai@1.1.56.
Top-level `limit.output` has no read site in OpenCode (verified against
the v1.1.56 source: `OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|| 32_000` in session/llm.ts; per-model `model.limit.output` has its own
scope). Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX=5000` env var
on the OpenCode spawn instead. Drops dead `OpenCodeConfig.limit?` type
field and the corresponding config write in `buildSecurityConfig`. This
was the headline mechanism of #616 — without the env var, the upfront
`max_tokens` reservation stayed at 32_000 and low-wallet runs continued
failing the way #616 was supposed to prevent.
2. Phantom auto-reload buffer for detached-card accounts. DELETE
/payment-method clears `stripeCustomerId` but leaves `autoReloadEnabled`
intact, so an account with welcome-credit residue and a detached card
could mint a key with `keyLimitCents = balance + autoReloadAmountCents`
($50 default, schema-cap $100K) of free spend headroom we have no way
to bill. Conjunctive `account.autoReloadEnabled && hasCard` in the
buffer selection closes this. Defense-in-depth follow-up worth doing:
clear `autoReloadEnabled` in the card-detach handler.
3. The autoReloadEnabled 402 branch fired for phase-1 noop paths
(`!stripeCustomerId`, `reloadAmountCents < 50`, `balance >= threshold`)
where `result.failure == null`, returning `"insufficient balance"` with
no actionable code. Gated on `result.status === "failed"` so non-charge
paths fall through to the `hasCard` / no-card branches and emit
`router_balance_exhausted` / `router_requires_card` instead.
4. (cheap) `ROUTER_KEYLIMIT_EXHAUSTED_PATTERN` now uses `/is` instead of
`/i` so `.*?` crosses newlines. Defends the BillingError reclassification
against any upstream layer that wraps the OpenRouter error onto multiple
lines. Trivial.
Test plan: 488/488 unit tests pass (1 new test for newline regex behavior).
Replaces today's `keyLimitUsd = min(walletBalance, $25)` with population-aware
buffers so users can use 100% of their credits before being paywalled, and
opaque mid-run "more credits" failures (e.g. https://github.com/pullfrog/app/actions/runs/25531633203)
get a clear PR comment instead of a generic stack-trace dump.
Policy matrix:
- Auto-reload accounts: `wallet + autoReloadAmountCents` (default $50, no cap)
- Card + no-autoreload: `wallet + $5` overdraft buffer
- No card: `wallet` (no buffer; existing zero-balance 402 stays)
- OSS: `$10` (unchanged)
Removes the $25 per-run cap entirely. Long Build runs at high-balance
accounts no longer silently cap at $25.
Other changes:
- Classify mid-run OpenRouter "requires more credits, or fewer max_tokens"
errors as `router_keylimit_exhausted` BillingError so users get an
actionable PR comment.
- Override OpenCode `max_tokens: 32000` default to `5000` via
OpenCodeConfig.limit.output. Drops Opus per-call upfront budget reservation
from ~$2.40 to ~$0.38 — what makes low-wallet runs viable at all.
- Switch `findInitialComment` and `findExistingPaywallComment` to GraphQL
`issueOrPullRequest(number:) { comments(last: 100) }` (single round trip,
actually returns newest-100; REST listComments doesn't support sort/direction).
Also fixes a latent `comments.find()` returning the OLDEST match instead
of the most recent — now selects max(databaseId).
- Wrap `syncAccountUsage` in `prisma.$transaction` with `SELECT ... FOR UPDATE`
on the account row. Pre/post-balance reads inside the transaction enable
deterministic low-balance edge detection (currently logs; will push the
outreach.low_balance task once #592 lands).
Plan: .cursor/plans/router-low-balance-paywall.plan.md (in companion wiki-billing branch)
* fix(action): tighten provider error detection and propagate agent error events
Both bugs from #562:
1. detectProviderError used substring matches against "429", "rate limit",
etc. — false-positives on commit SHAs containing 429 and on x-ratelimit-*
response headers in dumped 401 error JSON. rewrote with anchored regexes:
numeric status codes only match adjacent to a recognised status key, and
`\brate[_ ]limit(?=[_ ]|\b)` rejects ratelimit-* headers (no separator).
word-boundary anchors on INTERNAL / UNAVAILABLE / quota / limit:0 reject
INTERNAL_SERVER_ERROR / time_limit:0 substrings. added 11-case regression
test.
2. opencode 401s slipped through `eventCount === 0 && lastProviderError`
because opencode's own type=error event increments eventCount before
the guard runs. added an explicit `error:` handler that captures the
event and propagates it to a non-success AgentResult. opencode emits
the message under `error.data.message`, not the top level. mirror fix
in claude.ts: error_max_turns / error_during_execution / any error*
subtype on the result event now flips success: false.
* fix(action): match quota inside identifiers like insufficient_quota
\bquota\b missed insufficient_quota / quota_exceeded / quotaExceeded
because _ is a word character and camelCase has no boundary. quota is
specific enough to be matched as a plain substring.
* fix(action): match `rate limited` and `rate limits exceeded`
Drop the trailing `(?=[_ ]|\b)` lookahead from the rate-limit regex. The
lookahead failed when `limit` was followed by another word character
(`limited`, `limits`), so `rate limited` and `rate limits exceeded` were
slipping past detection. The leading `\b` plus `[_ ]` separator already
rejects `x-ratelimit-*` / `anthropic-ratelimit-*` headers without it.
---------
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: David Blass <david@arktype.io>