Skip to content

fix(error): treat 402 'can only afford N tokens' as recoverable rate-limit - #49785

Closed
tcconnally wants to merge 6 commits into
NousResearch:mainfrom
Perseus-Computing-LLC:fix/402-affordable-tokens-49769
Closed

tcconnally wants to merge 6 commits into
NousResearch:mainfrom
Perseus-Computing-LLC:fix/402-affordable-tokens-49769

Conversation

@tcconnally

@tcconnally tcconnally commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

When a provider returns HTTP 402 with the message "can only afford N tokens" (common with OpenRouter and other credit-routing platforms), the account HAS usable credit — just not enough for the requested max_tokens. Previously this was classified as terminal billing exhaustion (FailoverReason.billing, retryable=False), causing the request to be dropped when it would succeed with a lower output cap.

Root Cause

_classify_402 in error_classifier.py had no detection for the "can only afford" pattern — it fell through to terminal billing exhaustion.

Fix (2 coordinated changes)

1. error_classifier.py — detect affordable-tokens 402s

  • Added affordable-tokens patterns and regex extraction.
  • New branch in _classify_402: when "can only afford N" is detected, extract the token count, store affordable_max_tokens in ClassifiedError.error_context, and classify as rate_limit (retryable) with no credential rotation or fallback.

2. conversation_loop.py — bounded clamp max_tokens

  • Added affordable-tokens clamping check before eager fallback.
  • When error_context contains affordable_max_tokens, we do a strictly lowering clamp on _ephemeral_max_output_tokens to affordable - 64 and retry.
  • Bounded the loop to a single clamp attempt to prevent infinite spinning if the provider keeps returning 402, handing off to the standard backoff/fallback path once budget is spent.

Testing

  • Added regression test for bounded clamp to ensure loop terminates properly.
  • Reverted previous auxiliary_client.py changes (429 exclusion lists remain unaffected).
  • All test_error_classifier.py and test_auxiliary_client.py tests pass.

Closes #49769

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists labels Jun 20, 2026
@tcconnally
tcconnally force-pushed the fix/402-affordable-tokens-49769 branch from dd7821f to e94c813 Compare June 30, 2026 16:59

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating the 402 classification gap; current main still routes every non-transient affordable-token 402 through agent/error_classifier.py:1072-1098 as terminal billing.

Problems

  • agent/conversation_loop.py:3051 resets retry_count before continue. The current recovery loop is bounded by while retry_count < max_retries at agent/conversation_loop.py:1105; repeated affordable-token errors therefore never reach its exhaustion path. Keep the retry bounded (for example, one clamp retry or only a strictly lower cap).
  • The auxiliary edit does not change a real 402: _is_payment_error() returns immediately for status == 402 at PR agent/auxiliary_client.py:2620. It also changes 429 behavior while the unchanged test at tests/agent/test_auxiliary_client.py:1829-1832 expects "can only afford" not to be a rate limit.

Suggested changes

  • Add an end-to-end recovery test covering: 402 → capped outgoing request → success, plus a repeated 402 termination case.
  • Re-scope or complete the auxiliary path with an actual cap reduction and matching tests.

Automated hermes-sweeper review.

Comment thread agent/conversation_loop.py Outdated
f"retrying with max_tokens={safe_out:,} "
f"(affordable={affordable_tokens:,})"
)
retry_count = 0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retry_count is the bound for the enclosing while retry_count < max_retries loop. Resetting it for every repeated affordable-token 402 makes that loop unbounded; keep this recovery to one attempt or only retry when the cap strictly decreases.

Comment thread agent/auxiliary_client.py Outdated
@@ -2627,7 +2627,7 @@ def _is_payment_error(exc: Exception) -> bool:
if status in {402, 403, 404, 429, None}:
if any(kw in err_lower for kw in (
"credits", "insufficient funds",
"can only afford", "billing",
"billing",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not affect the stated HTTP 402 case because the preceding if status == 402: return True remains. It changes only non-402 message handling and needs either a matching auxiliary cap-recovery implementation or removal from this PR.

Comment thread agent/auxiliary_client.py Outdated
@@ -2685,7 +2685,7 @@ def _is_rate_limit_error(exc: Exception) -> bool:
# Generic 429 without billing keywords = likely a rate limit
if not any(kw in err_lower for kw in (
"credits", "insufficient funds", "billing",
"payment required", "can only afford",
"payment required",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this exclusion makes a 429 containing can only afford classify as a rate limit, but the unchanged tests/agent/test_auxiliary_client.py assertion for that exact message expects False.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 14, 2026
@tcconnally

Copy link
Copy Markdown
Contributor Author

Please hold off on this one — reviewing my own change, it has two real problems I need to fix before it should be considered:

  1. The retry path resets the counter before continue, so repeated affordable-token 402s aren't bounded and could loop.
  2. The auxiliary-client edit is effectively a no-op for real 402s (status 402 short-circuits first) but changes 429 handling, and it conflicts with the existing expectation in test_auxiliary_client.py that "can only afford" is not classified as a rate limit — so that test would fail.

The underlying bug (affordable-token 402s being treated as terminal) is real, but I'll rework this with a bounded cap, drop/rescope the auxiliary change, add an end-to-end test (402 → cap → success, plus repeated-402 termination), and correct the description, which referenced a function name and line numbers that don't match the diff. Apologies for the premature PR — will update or replace it.

@tcconnally

Copy link
Copy Markdown
Contributor Author

Rework complete: added bounded clamp to the conversation loop to prevent infinite spinning, reverted unnecessary auxiliary_client.py changes, added a regression test, and corrected the PR body description.

@tcconnally

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 148d01a13: removed the unrelated auxiliary-client classification change so the PR stays focused on the stated 402 recovery path. Focused tests: test_402_clamp 4 passed, test_error_classifier 193 passed, guard-class tests 27 passed, and test_ctx_halving_fix 27 passed.

@tcconnally

Copy link
Copy Markdown
Contributor Author

Follow-up: CI exposed an attribution-only failure for tcconnally@users.noreply.github.com. Added the repository-standard contributor mapping in 317f545e3; the code/test jobs were already green.

@tcconnally
tcconnally force-pushed the fix/402-affordable-tokens-49769 branch from 317f545 to 7b5fc9e Compare July 23, 2026 16:33
@tcconnally

Copy link
Copy Markdown
Contributor Author

Hi — just checking in. This PR has been quiet for a while. Is there anything still needed from my side to move it forward?

tcconnally and others added 6 commits July 27, 2026 14:01
…limit

When a provider returns HTTP 402 with 'can only afford N tokens'
(common with OpenRouter), the account HAS usable credit — just not
enough for the requested max_tokens. Previously classified as terminal
billing exhaustion, causing the request to be dropped when it would
succeed with a lower output cap.

Changes:
- error_classifier: _AFFORDABLE_TOKENS_PATTERNS + regex to detect
  'can only afford N' in 402 bodies; new branch in _classify_402
  before billing exhaustion: extract token count, classify as
  rate_limit (retryable) with affordable_max_tokens in error_context.
- conversation_loop: intercept affordable-tokens before eager fallback;
  clamp _ephemeral_max_output_tokens and retry instead.
- auxiliary_client: remove 'can only afford' from credit exhaustion
  keywords (402 status-code check unchanged — safe).
- tests: 2 new tests for affordable-tokens classification.

Closes #49769
Reviewer feedback on #49785: the auxiliary_client.py edits could not
affect the stated HTTP 402 case (status==402 returns True before the
keyword lists are consulted) and only changed non-402 handling. The
remaining diff was a pure reordering of the keyword lists. Revert
agent/auxiliary_client.py to the upstream state so the PR contains no
auxiliary changes at all; the 402 'can only afford N' recovery lives
entirely in error_classifier.py (classification + affordable_max_tokens
hint) and conversation_loop.py (bounded one-shot clamp).

Guarded by existing tests:
- tests/agent/test_auxiliary_client.py::TestIsRateLimitError::
  test_429_with_billing_message_is_not_rate_limit (429 'can only afford'
  is NOT a rate limit)
- tests/agent/test_auxiliary_client.py::TestIsPaymentError::
  test_402_with_credits_message (402 'can only afford' IS payment)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recoverable 402 ("can only afford N tokens") is treated as terminal billing and drops the request

4 participants