fix(llm): provider-availability failures no longer block fallover - #322
Merged
Conversation
Two kinds of "this provider is unusable" failure were filed under categories that shouldAdvance refuses to advance on, so a permanently broken link pinned the whole fallback chain and the user got a diagnosis for a problem they did not have. - llama-server 4xx: every non-null status under 500 mapped to grammar. A 404 (the configured localModels.url does not serve completions) or a 405 read as "Turn failed [grammar]" and stopped the chain. The endpoint/auth/availability statuses (401 402 403 404 405 408 409 429) now classify as transport; the request-shape statuses (400 413 422 and any other unlisted 4xx) stay grammar. - SubscriptionCliNotInstalledError / SubscriptionCliAuthError were plain Errors, so they fell through to the catch-all tool arm. A missing or signed-out claude/codex CLI now classifies as transport. Routing the llama 404 to transport also unblocks the llama-unreachable hint in format-agent-error-for-chat, which is gated on the transport category — the one failure where "check your llama URL" is the right advice was the one that never got it. Doc comments in failure-category.ts, classify-failure.ts and should-advance.ts updated to the new rule.
toLlmFailure carried a second, hardcoded copy of the llama-server taxonomy (status null or >= 500 => transport, everything else => grammar). That copy, not classifyFailure, decided the category the user actually reads: executeStep wraps every escaping error through toLlmFailure and rethrows the wrapper, and classifyFailure short-circuits on `err instanceof LlmFailure`, so the new endpoint/availability status set was never consulted on the path that produces the chat message. Concretely, a raw LlamaServerError(404) still surfaced as `Turn failed [grammar]: llama-server returned http 404` with no unreachable hint, and the Sentry clusters CLI-B7 / CLI-BE (category=grammar, cause_type=LlamaServerError) are exactly the signature of the GrammarError constructed here — they would have kept firing at the same rate. Delete the duplicate and ask classifyFailure instead: transport keeps TransportError(message, status, url), anything else keeps the historical GrammarError(message, ""). The cause chain is unchanged in both arms, so the scrubber's causeType still resolves. classifyFailure cannot answer cancelled/model/tool for a LlamaServerError, and an aborted step is already claimed by the ctx.signal.aborted check above this arm, so no other category is laundered into a TransportError. The fallover half was already correct: runWithFallback catches the raw LlamaServerError before executeStep's wrapper, so only the user-facing category was stuck on the old taxonomy.
…line
The block added in this PR claimed to mirror production but composed
formatAgentErrorForChat(classifyFailure(err), err.message, local) by
hand, omitting the toLlmFailure link that was exactly what was broken.
It was green while a 404 still reached the user as a grammar failure.
Rewritten to run the whole path: AgentLoop executes a step whose
llmComplete throws a raw LlamaServerError, executeStep normalises it
through toLlmFailure, the loop's catch classifies that wrapper and emits
loop_failed { category, error }, and the assertion formats exactly those
fields the way agent-event-reducer's loop_failed case does.
With the toLlmFailure change reverted, the 404 and 405 cases fail with
`Turn failed [grammar]: llama-server returned http 404`, reproducing the
production defect. The 400 case is the regression guard for the half that
is intentionally unchanged.
The AdvanceDecision doc has long said an immediate signal "should switch
on the FIRST occurrence, bypassing the consecutive-failure threshold",
and the test names this PR added inherited that wording ("advances via
threshold on a local llama 404", "advances immediately on a local llama
429").
ProviderFallbackChain.advanceFrom returns the next link on the FIRST
fallover-worthy failure whenever decision.advance is true, immediate or
not. What immediate changes is registerFailure: it arms the breaker
cooldown right away instead of waiting for failureThreshold consecutive
failures. The threshold governs how long a failed link stays quarantined
across later turns, not the in-turn switch.
Assertions are unchanged — only the doc comment and the six new test
names/comments that misstated the mechanism.
This was referenced Sep 2, 2026
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.
What
A failure meaning "this provider link is unusable" must not be filed under a category that blocks fallover. Two such failures were.
(a) llama-server 4xx.
classifyFailuremapped everyLlamaServerErrorwith a non-null status under 500 to"grammar". So an HTTP 404 — the configuredlocalModels.urldoes not expose the completion endpoint (wrong URL, or a server that is not a llama-server) — and a 405 (wrong method) were reported as grammar failures.(b) subscription-CLI providers.
SubscriptionCliNotInstalledErrorandSubscriptionCliAuthErrorare plainErrors, soclassifyFailurefell through to its catch-allreturn "tool".(c) a duplicate copy of the same taxonomy in
step-executor.ts.toLlmFailurecarried its own hardcodedstatus === null || status >= 500 ⇒ transport, else grammarsplit.executeStepwraps every escaping error throughtoLlmFailureand rethrows that wrapper, andclassifyFailure's first line short-circuits onerr instanceof LlmFailure— so on the path that produces the message the user reads, the duplicate decided the category and (a) never applied. This was found in review of the first commit here and is fixed in33af1f6; without it the llama half of this PR would have changed only the fallover decision, not the chat message or the hint.Three consequences, all in-repo:
Turn failed [grammar]: llama-server returned http 404, orTurn failed [tool]: "claude" was not found on PATH…— a tool bug it is not. Both are unactionable.shouldAdvance()returns{advance:false}forgrammarandtool, so the fallback chain refuses to move to the next provider even though the current link is permanently broken. Its own doc comment states the opposite intent for the cloud path: everyOpenAiHttpErrorclassifies astransportregardless of status, "so a 404 model-not-found or a 401 dead key advances too — a different link may have the model or a working key." The local and CLI paths contradicted that. A configuredclaude/codexprovider that is simply not installed blocked the chain permanently.format-agent-error-for-chat.tsappendsformatLlamaUnreachableHint(local.llamaUrl)only whencategory === "transport". The one case where "check your llama URL" is exactly the right advice — a 404 from a wrong URL — was the one case that never got the hint.Sentry:
category=grammar,cause_type=LlamaServerError,http_status = 400 (118), 404 (15), 405 (8),release = 0.5.4 (111), 0.4.2 (28), 0.5.3 (2).http_status = 404 (6), 400 (3), samegrammarcategory, releases 0.4.2 / 0.5.4 / 0.5.1.error_type=ToolExecutionError,category=tool,cause_type=SubscriptionCliNotInstalledError, releases 0.5.1 / 0.4.2.The
grammar+LlamaServerErrorsignature in CLI-B7 / CLI-BE is preciselynew GrammarError(err.message, "", { cause: err })intoLlmFailure— i.e. those two clusters are addressed by33af1f6, not by the classifier change alone. The 404/405 slice of them (29 of 150 events) stops being reported asgrammar; the 400 slice (121 events) is deliberately left where it is, see Deliberately out of scope. CLI-BH goes through the catch-all arm, which already defers toclassifyFailure, so it is fixed by the classifier change on its own.How
src/llm/reliability/classify-failure.ts:LlamaServerError4xx arm is split against a named set. Statuses that describe the endpoint / auth / availability rather than the request return"transport":401, 402, 403, 404, 405, 408, 409, 429(joining the existingnulland>= 500). None of these repeat identically on a different provider."grammar":400,413,422, and — deliberately conservative — any other 4xx not named above. The default stays on the non-advancing side so an unfamiliar status cannot silently start burning the chain.SubscriptionCliNotInstalledErrorandSubscriptionCliAuthErrorroute to"transport".SubscriptionCliInvocationErrordeliberately does not: the binary ran and came back unhappy, which is not evidence the link is unusable.Imported straight from
../provider/subscription-cli/subscription-cli-errors.js, not thesubscription-cli/index.jsbarrel.subscription-cli-errors.tshas zero imports of its own, so there is no cycle; the barrel would have dragged the providers and adapters into the reliability layer. Same shape as the existing../provider/openai/openai-http.jsimport.src/agent/step-executor.ts: theLlamaServerErrorarm oftoLlmFailureno longer restates the split — it asksclassifyFailure(err)and mapstransporttoTransportError(message, status, url), anything else to the historicalGrammarError(message, ""). Thecausechain is untouched in both arms, so the scrubber'scauseTypestill resolves.classifyFailurecannot answercancelled/model/toolfor aLlamaServerError(its own arm returns onlytransportorgrammar, ahead of the abort and network branches), and an aborted step is already claimed by thectx.signal.abortedcheck above this arm, so no other category is laundered into aTransportError. This was the last hardcoded llama status split insrc/;llama-server-client.ts(isRetryableLlamaError) andshould-advance.ts(isImmediateSignal) read statuses for different decisions and are unchanged.Doc comments updated to the new rule in
classify-failure.ts,failure-category.ts(itsgrammar:bullet said "also covers HTTP 4xx from llama-server") andshould-advance.ts(itsgrammarbullet said "A grammar/4xx failure is request-shape").isImmediateSignalinshould-advance.tsneeded no change and does not double-count: it reads the status straight offLlamaServerError, so a 429 or 408 that is nowtransportis correctly immediate, while 401/402/403/404/405/409 are not, and the CLI errors carry no status field. To be precise about what that buys —ProviderFallbackChain.advanceFromreturns the next link on the first fallover-worthy failure whether or not it is immediate;immediateonly arms the breaker cooldown straight away instead of waiting forfailureThresholdconsecutive failures, i.e. it governs how long the failed link stays quarantined across later turns, not the in-turn switch. TheAdvanceDecisiondoc said otherwise and has been corrected.Trade-offs accepted
A subscription CLI can be called signed-out by its own output.
mapCliFailureregex-matchesstderr + "\n" + stdout, and stdout carries the CLI's own model output. So a non-zero exit whose text merely discusses auth — "unauthorized", "not logged in" — is relabelledSubscriptionCliAuthError. Before this PR that misfire stopped the chain and printed "not signed in, run /login", which at least named itself. After it, the chain falls over silently, and a genuine sign-out on a multi-link chain becomes a silent, repeated degradation to a paid cloud provider. On balance still the right trade — a CLI that cannot serve should not pin the chain — but it is a real behaviour change and it is undocumented anywhere else. The regex is a pre-existing weakness and is deliberately not touched here.403and the proxy case. A llama-compatible endpoint behind LiteLLM / vLLM / a corporate gateway can answer403for a content rejection, which would fail identically on every link — so the chain burns every provider before giving up. That is consistent with what the cloud path already does for everyOpenAiHttpErrorstatus, andsrc/llm/llama-server-auth-probe.ts:31already treats a llama401/403as an auth verdict, so403is placed with the existing precedent rather than against it;402and409are almost certainly unreachable from llama.cpp itself and are set members only for the same proxied deployments.Tests
classify-failure.test.ts: a table over statuses —400/413/422 → grammar,401/402/403/404/405/408/409/429 → transport,500/503 → transport,null → transport, plus an unlisted418 → grammarguard for the conservative default; the two subscription-CLI errors →transport, andSubscriptionCliInvocationErrorstill →tool.should-advance.test.ts: llama 404 and 405 advance without arming the breaker; llama 429 and 408 advance and arm it on the first failure; llama 400 still does not advance; both subscription-CLI errors advance.format-agent-error-for-chat.test.ts: drives the whole production path rather than imitating it —AgentLoopruns a step whosellmCompletethrows a rawLlamaServerError,executeStepnormalises it throughtoLlmFailure, the loop's catch classifies that wrapper and emitsloop_failed { category, error }, and the assertion formats exactly those fields the wayagent-event-reducer'sloop_failedcase does. A llama 404 and 405 now carry the unreachable hint; a llama 400 still renders as a bare grammar failure.Not vacuous.
classify-failure.tsreverted and the tests kept, 18 of the new tests fail (10 inclassify-failure.test.ts, 6 inshould-advance.test.ts, 2 informat-agent-error-for-chat.test.ts). The remaining new rows are regression guards for behaviour that is intentionally unchanged.toLlmFailurechange (33af1f6) reverted, the twoformat-agent-error-for-chat.test.tspipeline rows fail withTurn failed [grammar]: llama-server returned http 404/… 405— the exact production symptom. The earlier, hand-composed version of that test passed in that state, which is why it was rewritten.Verification
npm run lint(tsc --noEmit): clean.npx vitest run src/llm/reliability src/llm/fallback src/tui/format-agent-error-for-chat.test.ts src/llm/provider/subscription-cli— 18 files, 197 tests, all passing.npx vitest run src/agent(the suite over the changedstep-executor.ts) — 15 files, 247 tests, all passing.npx vitest run agent-loop step-executor error-scrubber llama-server-provider llama-server-client llm-fallback-seam replay-session tasks tracing turn-controller tui/llm-panel— 44 files, 483 tests, all passing.Deliberately out of scope
A llama-server 400 is by far the largest bucket in CLI-B7 (118 of 141) and a good share of those are almost certainly context-overflow — the prompt exceeded the server's
n_ctx. Sniffing the 400 response body to separate "prompt too long" from "malformed request" is not attempted here. It needs a taxonomy change, not a status remap:ModelFailureReasonhas nocontextvalue, and the correct handling for an overflow (compact or trim, then retry the same provider) is neithergrammarnortransport. Both currently land on the non-advancing side, which is the safer of the two answers, so this change leaves 400 exactly where it was.