fix(fallback): benched credentials must not disqualify a chain entry - #79840
fix(fallback): benched credentials must not disqualify a chain entry#79840rodrigogs wants to merge 1 commit into
Conversation
21da4f4 to
50a465b
Compare
612b802 to
493f5a4
Compare
|
Hi — heads-up that CI has never actually run on this PR: every workflow run (CI + Docker Build) since it was opened ends in |
acf0d5f to
dab42b7
Compare
|
Rebased onto current Effectively zero drift on the touched file, which is worth stating precisely because 492 commits sounds like Verification on the new head: This repository does not run CI on pull requests from forks, so the checks tab stays empty and |
Two defects made a healthy provider vanish from the fallback chain, leaving a free model as the last resort for 47 minutes. When DeepSeek returned 402 Insufficient Balance, credential_pool benched the key for an hour (EXHAUSTED_TTL_DEFAULT_SECONDS). resolve_provider_client then returned None, which at the call site is indistinguishable from a provider the user never configured. Two consequences followed: 1. The chain logged "Fallback to deepseek failed: provider not configured" — a false statement that sent diagnosis toward config and credentials when the real cause was a billing bench with a known expiry. credential_pool already separates the cases: has_credentials() means configured, has_available() means usable right now. Use that, and report the remaining cooldown. 2. The entry was added to _unavailable_fallback_keys, a memo cleared only by a fallback_providers content edit. Credentials normally arrive via `hermes auth` (auth.json) or a new env var, neither of which touches config.yaml, so a one-hour bench removed the entry for the entire life of the cached agent. Give the memo a 10-minute TTL: an unconfigured provider still is not re-probed every activation, but the memo can no longer outlive the condition that created it. Observed impact: with deepseek dropped from a 6-entry chain that collapses into 3 distinct credentials (zai, openai-codex, nous), the only pool not already exhausted was the free tier, which then ran 467 API calls and produced a 517-iteration tool loop.
dab42b7 to
d8b5cd8
Compare
|
Force-pushed a metadata-only fix so the contributor attribution check can pass.
Every commit's author is now |
Problem
A healthy provider silently vanished from the fallback chain, leaving a free-tier model as the last resort for 47 minutes.
When DeepSeek returned
402 Insufficient Balance,credential_poolbenched the key for an hour (EXHAUSTED_TTL_DEFAULT_SECONDS; 402 is billing by definition, so it correctly keeps the full bench).resolve_provider_clientthen returnedNone— which at the call site intry_activate_fallbackis indistinguishable from a provider the user never configured. Two things followed:1. A false log message. The chain logged:
DeepSeek was the primary provider in
config.yamland had served 195 calls earlier the same day. The message sent diagnosis toward config and credentials when the real cause was a billing bench with a known expiry.2. Permanent suppression from a temporary condition. The entry was added to
_unavailable_fallback_keys, a memo cleared only whenfallback_providerscontent changes (gateway/run.py:_apply_fallback_chain_to_agent). Credentials normally arrive viahermes auth(writesauth.json) or a new env var — neither touchesconfig.yaml— so a one-hour bench removed the entry for the entire life of the cached agent.Reproduced deterministically by setting the pool entry to
last_status=exhausted, last_error_code=402, last_status_at=now-7s:Fix
credential_poolalready separates the two cases —has_credentials()means the user configured something,has_available()means one is usable right now. Use it:_fallback_provider_benched_until()returnsNonefor genuinely unconfigured (memoize, as before), or the epoch time the next credential re-enters rotation for a benched one (skip this turn, stay retryable). An unreadable pool is treated as unconfigured, preserving previous behavior.Fallback to deepseek skipped: configured, but all credentials are in cooldown (retry in 59m). Staying retryable for later turns._unavailable_fallback_keysgains a 10-minute TTL (UNAVAILABLE_FALLBACK_RETRY_SECONDS). An unconfigured provider still is not re-probed on every activation, but the memo can no longer outlive the condition that created it. The container stays asetso every existingkey in unavailablecheck — includingtests/run_agent/test_nous_fallback_unavailable.py— is unaffected; stamps live in a side table.Impact
The affected chain had 6 entries but only 3 distinct credentials (zai ×2 sharing
GLM_API_KEY, openai-codex ×2 sharing onedevice_code, nous). With deepseek wrongly dropped, the only pool not already exhausted was the free tier, which then ran 467 API calls and produced a 517-iteration tool loop (separate fix in #79839).Redundancy that collapses under pressure into "the worst model is the only survivor" is worth guarding: this fix keeps a temporarily-benched good provider in the running instead of retiring it.
Verification
BENCHED (retryable) (retry in 59m)and is not memoized.tests/run_agent/test_fallback_benched_credential.py: benched entry stays retryable; unconfigured entry still memoized; memo expires so a provider configured mid-uptime is reconsidered; memo still short-circuits within its window.pytest -k "fallback or failover or credential_pool or guardrail or chat_completion"→ 960 passed, 4 skipped.ruffclean.(One unrelated pre-existing failure in
tests/tools/test_web_tools_config.py::TestParallelClientConfigreproduces on a clean checkout — order-dependent state leakage, untouched here.)