Repository navigation
fix: make the free pool actually fail over when one member is retired - #1155
Conversation
The `hive-free` alias is served by a load-balanced LiteLLM group of four free provider keys, and its whole product claim is that one exhausted or retired key fails over to the others. It does not. One member answering 404 takes the entire alias down for that request, which is what has been failing the Live integration job on main. Root cause, read out of the pinned image's source (v1.98.0) rather than inferred: * `litellm/router.py::should_retry_this_error` re-raises immediately for `NotFoundError`, and again for any status where `litellm._should_retry()` is false. 404 is both. It is called from `async_function_with_retries` AND from `async_function_with_fallbacks`, so neither the in-group retry across the other three members nor the cross-group fallback ever runs. * `litellm/types/router.py::RetryPolicy` has no `NotFoundErrorRetries` field, so no configuration lifts this. * A retired free model is exactly what returns 404, and free model ids churn constantly on every provider in the pool. The edge now retries a LiteLLM router-exhaustion 404 in the one shared dispatch seam every chat, completions, responses and streaming path already flows through. This is sound rather than hopeful because LiteLLM cools the offending deployment down before raising (`_should_cooldown_deployment` returns true on the first failure when `_should_retry(status)` is false), so the next attempt picks a different member, and the whole ladder finishes inside the cooldown window. The retry is matched on LiteLLM's message, not on a bare 404, so a customer naming a model that does not exist still gets a fast 404 and pays no retries. Also makes the failure legible instead of generic: * A new step probes each pool member individually through LiteLLM's own per-group health endpoint and names any model that is gone. One dead member is a warning, since surviving it is the pool's entire purpose; every member dead is a hard failure, since the free tier then serves nothing. * The upstream-refusal classifier gained a branch for the router exhaustion signature, so it reads as "a pool member is dead" rather than falling through to "something else". `allowed_fails` and `cooldown_time` in the LiteLLM config look misplaced under `litellm_settings` and are now documented as deliberately left there: moving `allowed_fails` onto the Router would require four failures before a dead member leaves rotation and would quietly restore this bug. Fixes #1064
|
Warning Review limit reachedNext included review available in 17 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The Live integration job is gated on the run-live-integration label, which was added after the first push, so the initial run predates it. This empty commit re-triggers the workflow so the free pool fix is exercised against real providers before merge.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Verified live, twice
The three JS tests that this issue was about now pass: The suites now reach the token meter's success branch, which was never exercised on the failing runs because it is gated on Eighteen metered completions on What the new probe revealed, and what it changes about the diagnosisAll four members answer. So the 404 that took the alias down was an intermittent blip from a member that is alive, not a permanently retired model. That is a sharper diagnosis than the one this PR was opened with, and it makes the fix more clearly right rather than less: the pool was not surviving even a transient single-member 404, which is the cheapest possible failure to absorb and exactly what a four-member pool exists to absorb. It also means the earlier failures were load-shaped, which fits the evidence: in every failing file the first The probe step keeps its value either way. Free model ids do get retired, and when one does, this step now names it instead of leaving a generic |
Post-merge safety audit (independent, PR merged before review completed)Verdict: no double-billing bug, no stranded-hold bug. Read against the actual merged code on Why double-billing cannot happen here
The 404 addition in this PR only changes what counts as retryable; it does not touch reservation/finalize/release logic at all. Confirmed via Why holds cannot strandThe single-terminal-state pattern ( Streaming: retry cannot fire after bytes reach the clientBoth Retry ladder vs. LiteLLM cooldown — independently re-verified against the pinned image source (not just the PR's citation)Pulled
The 2.9s worst-case retry ladder is comfortably inside the 5s cooldown window, so a dead pool member cannot be re-selected mid-ladder, provided One real, non-blocking gap found (opened separately)The retry is scoped by matching LiteLLM's error message text ( No action needed on this PR itself; it does not need to be reverted. |
…lization boundary (#1169) ## Summary The deploy of merge commit 2df3afd (PR #1163) failed its SDK replay against the demo box (run 32879931588): ``` tests/chat-completions/chat-completions.test.ts:36 "Chat Completions > returns a valid chat completion via SDK" AssertionError: expected 'object' to be 'string' Expected: "string" Received: "object" ``` **Correction to the field named in the incident report.** The failing assertion is `typeof response.choices[0].message.content`, not `response.model` as first reported. Line 36 of the test is: ```ts expect(typeof response.choices[0].message.content).toBe("string"); ``` `response.model` is asserted separately at line 51 in the very next test in the same file (`model field shows Hive alias not provider handle`), which passed. `typeof null === "object"` in JavaScript, so "Received: object" for a `content` assertion means the wire value was a bare `null`, not an actual object. ## Root cause **Not PR #1163.** That PR touches only `apps/edge-api/internal/anthropic/`, adding request-side fields (`Thinking`, `TopK`, `User`, `ParallelToolCalls`, `ThinkingBlocks`, `ThinkingConfig`) to that package's own `OAIRequest`/`OAIMessage` structs. Those structs are private to the `anthropic` package: `grep -rn "anthropic\." apps/edge-api/internal/inference/*.go` returns no hits outside a comment. The OpenAI-compatible chat-completions path (`apps/edge-api/internal/inference`) has its own separate `ChatCompletionResponse`/`ChatCompletionMessage` types and never touches the anthropic package's types. No shared struct, no cross-package marshaling effect. **The real cause is the free-pool router**, PR #1115 (`hive-free`, four load-balanced provider members: OpenRouter `dots-3-note-preview:free`, Google `gemini-flash-latest`, and two Groq `gpt-oss-20b` keys) plus its retry fix PR #1155 (`dispatchWithRetry`, which lets a request land on any member on retry). The replay's `HIVE_TEST_MODEL` is `hive-free` (`deploy-demo-box.yml`), so this plain "Say hello" / `max_tokens: 256` request could land on any of the four. The failing test ran for 50.8s versus 1-7s for its four sibling tests in the same run, consistent with a reasoning-capable member (Groq's `gpt-oss-20b` or Gemini's thinking-capable flash model) spending its entire token budget on hidden reasoning before hitting the length limit, returning `finish_reason: "length"` with `message.content` omitted from the JSON entirely. Go's `ChatCompletionMessage.Content` is `*string`. Unmarshaling a response missing that key leaves it `nil`; `normalizeChatCompletion` in `apps/edge-api/internal/inference/chat_completions.go` re-marshaled that nil pointer straight back out as JSON `null`, which every OpenAI SDK (this test uses the real `openai` npm package) treats as an unconditional string. This exact class of failure was already documented in `deploy-demo-box.yml`'s own comments for a different alias (`deepseek-v4-flash`: "returned message.content as object/null/string across probes"), which is why `HIVE_TOOLS_MODEL` was pinned away from it. `hive-free` was never given the same treatment because its tool-free smoke test uses no `max_tokens` pressure test and had not yet hit a reasoning-heavy member in CI. **This gateway is provider-blind by design.** No pool member's response shape should leak to a client through the OpenAI-compatible surface. OpenAI's own contract makes `content` nullable *only* alongside `tool_calls`/`function_call`; every other case must be a string. That is where this fix lives. ## Fix `normalizeChatCompletion` (`apps/edge-api/internal/inference/chat_completions.go`) now coerces a `nil`, tool-free `message.content` to an empty string before marshaling the response, via a new `coerceNullContent` helper. A genuine tool-call message with `content: null` is left untouched, since that shape is spec-correct per OpenAI and is exercised by the existing "passes tools through" replay test. ## Why no existing test caught this pre-merge `packages/sdk-tests` only runs against a live deployed box (`sdk-replay` job, post-merge), never in CI unit/integration tests, because it needs a real LiteLLM + provider round trip. The Go unit-test suite for `normalizeChatCompletion` (`usage_clamp_test.go`) had fixtures for zero-completion-token clamping but none exercising an upstream response with `content` entirely absent from the JSON. Added two new unit tests in `chat_completions_null_content_test.go` that assert on the actual marshaled wire bytes (not just the Go struct), which would have caught this before any live deploy: - `TestNormalizeChatCompletion_NullContentCoercedToEmptyString`: reproduces the missing-`content` upstream shape and asserts the outgoing JSON has `"content":""`, never `null`. - `TestNormalizeChatCompletion_NullContentPreservedWithToolCalls`: asserts a genuine tool-call message's `content: null` is left alone. ## Buglog entry To be appended to `.wolf/buglog.jsonl` on `main` via a separate buglog-only PR, per `.claude/rules/openwolf.md`: ```json {"date":"2026-08-25","error_message":"AssertionError: expected 'object' to be 'string' at tests/chat-completions/chat-completions.test.ts:36 (response.choices[0].message.content)","root_cause":"hive-free free-pool member (PR #1115/#1155) returned message.content omitted (null) after burning its max_tokens budget on hidden reasoning; normalizeChatCompletion re-marshaled the nil *string as JSON null instead of coercing it, leaking a non-OpenAI-contract shape to every SDK client","fix":"apps/edge-api/internal/inference/chat_completions.go: normalizeChatCompletion coerces nil, tool-free message.content to an empty string; tool-call messages with null content are left untouched per the OpenAI contract","tags":["free-pool","chat-completions","normalization","sdk-replay","hive-free"]} ``` ## Test plan - [x] `go build ./apps/edge-api/...` - [x] `go vet ./apps/edge-api/...` - [x] New unit tests pass: `go test ./apps/edge-api/internal/inference/... -run 'NullContent|NormalizeChatCompletion' -v` - [x] Full `apps/edge-api` suite green, no regressions: `go test ./apps/edge-api/... -count=1 -short` - [ ] Next deploy's `sdk-replay` job green against the live demo box (this PR cannot verify that itself; the orchestrator confirms on merge) Reproduction: static, from the actual failing-run log (`gh run view --job 97907530300 --log`) plus static code reading. Live reproduction against the demo box was not attempted directly (no API key available to this agent; another agent was separately reported to be investigating an unrelated Cloudflare Tunnel issue on the same box). The box itself answered a plain reachability probe (`401` on `/v1/models` with no auth) during this session, so it was not down at the time of investigation.
…1177) ## Summary Live verification of every capability DEMO.md claims, against the actually deployed box (chat-hive, console-hive, api-hive, control-hive), run today after the 2026-08-25 Cloudflare regional-edge false alarm cleared. Full capability matrix and methodology in `docs/proof/demo-readiness-verify-2026-08-25/log.md`. **Confirmed fixed, DEMO.md corrected (was stale):** - Artifacts (#1110, fixed by PR #1141): `/artifacts` renders a real empty-state index today, not the "spins forever" DEMO.md described. No sidebar entry yet (tracked by #943 item 4, not new). - In-chat credits (#1063, fixed by PR #1119): a "You've used N credits today, N remaining" strip sits above the composer, matching the console Billing balance exactly. **Confirmed still broken, unchanged:** - Knowledge nav (#1109): clicking it still does nothing (URL unchanged). Direct `/knowledge` now answers an honest 404 instead of the originally-reported silent bounce home, a minor symptom shift, not a fix. **Verified today's merges, all landed after this session started:** - Cache-aware billing (#1157) and Anthropic `cache_control` passthrough (#1152): shipped and tested, but unexercised live. The catalog has no Anthropic model today, and a direct `usage_events` query shows zero cache-bearing requests since deploy. - Free pool failover (#1155) and the null-content coercion fix (#1169): no regressions in the trailing 24h of live traffic (zero error-status `usage_events` rows across 356 requests), though neither fix's specific trigger recurred live to re-test directly. - External uptime probe (#1166): confirmed running on its 15-minute schedule, all green. Added a T-1 checklist note pointing at it, since today's regional Cloudflare maintenance window is exactly the scenario it exists to catch. **Corrected a claim broader than the two named stale items:** the "not demoable: multi user isolation (#947, #948, #949 family)" line was itself stale. All three were fixed 2026-08-23 (PRs #960, #1067, #1091, #1096). One residual, #1056 (two Knowledge by-id/files routes still short-circuit on `role == admin`), is still open, so the line now says that precisely instead of citing three closed issues. ## New issues filed None. Every genuinely broken thing found already has an open tracking issue (#1109, #1056, #943). ## Verification - `node tools/lint-no-token-in-proof-captures.mjs` passes against the new proof log. - Live session obtained via the standard admin one-time-token mint (`docs/live-test-auth.md`), read-only against the demo fixture account, no password touched, no message sent, no key minted, no task submitted. - Screenshots posted separately to the PR via `scripts/post-pr-visual-proof.sh` (permanent GitHub Release, per `.wolf/decisions.md` D-042). ## Test plan - [x] `node tools/lint-no-token-in-proof-captures.mjs` - [x] Manual read of the rendered DEMO.md for internal consistency Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
hive-freeis served by a load-balanced LiteLLM group of four free provider keys, and its whole product claim is that one exhausted or retired key fails over to the others. It does not. One member answering 404 takes the entire alias down for that request. That is what has been failingLive integration (SDK tests + smoke)on main.Fixes #1064.
Which of the four candidate causes it was
Routing. Not credentials, not pricing, not metering.
All four members' credentials and models were verified live while diagnosing this:
route-free-pool-freedots-studio/dots-3-note-preview:freeroute-free-pool-groqopenai/gpt-oss-20b/v1/modelsright nowroute-free-pool-groq-2openai/gpt-oss-20b, second key slotroute-free-pool-geminiopenai/gemini-flash-latestAll four repository secrets exist. The gateway serves the group: the run's own assertion printed
gateway serves route-free-pool.Request failed, or metering silently dropped it?
Request failed. This matters because the repo has been burned by the second shape before (2026-07-31, the gateway billed nothing for three days and every failure was silent), so it was checked first rather than assumed.
The failing run's own spend report:
Fourteen metered completions on
hive-free, plus one meterederror. Metering is healthy and is recording both outcomes. The "zero metered completions" framing does not hold for this run: theserved == 0assertion is gated onSUITES_OUTCOME = successand never executed, because the suites had already failed.The actual failure
Three JS tests failed, all of them the second
hive-freecall in their file. The first call in each file passed. Python's suite passed 14/14 because pytest runs sequentially while vitest runs test files in parallel, so only the JS run had concurrenthive-freetraffic.The customer-facing
hive-free is not available.is our provider-blind collapse of this, from the run'scompose-logsartifact:One deployment returned 404. Three were fine. The request died anyway.
Root cause
Read out of the pinned image's source (
v1.98.0), not inferred:litellm/router.py::should_retry_this_errorre-raises immediately forNotFoundError, and again for any status wherelitellm._should_retry()is false. 404 is both:async_function_with_retriesandasync_function_with_fallbacks, so neither the in-group retry across the other three members nor the cross-group fallback ever runs.litellm/types/router.py::RetryPolicyhas fields for BadRequest, Authentication, Timeout, RateLimit, ContentPolicyViolation and InternalServer errors, and none for NotFound. There is no configuration that lifts this.A retired free model is exactly what returns 404, and free model ids churn constantly on every provider in this pool. So the pool's failover premise is false against the single most likely way a free member dies.
The fix
Make the pool answer.
apps/edge-api/internal/inference/retry.gois the one shared dispatch seam that every chat, completions, responses and streaming path already flows through, so the fix lands once for every pooled alias rather than forhive-freealone. It now retries a LiteLLM router-exhaustion 404.This is sound rather than hopeful because LiteLLM cools the offending deployment down before raising:
router_utils/cooldown_handlers.py::_should_cooldown_deploymentends its base case withlitellm._should_retry(status) is False -> return True, so a 404 member is out of rotation on its first failure, not afterallowed_fails. The next attempt therefore picks a different member, and the existing 300/800/1800 ms ladder finishes inside 2.9 s, comfortably inside the 5 s cooldown, so the dead member cannot be re-picked mid-ladder.Scope is deliberately narrow: the retry matches on LiteLLM's message, not on a bare 404, so a customer naming a model that does not exist still gets a fast 404 and pays no retries.
isRouterExhaustion404splices the body back together after peeking, so the caller always receives a complete response; a half-consumed body would truncate the error the customer sees and the text CI classifies on.Make the failure legible. Two additions, neither of which weakens a check:
/health?model=route-free-pooland names the model that is gone. One dead member is a::warning::, because surviving one is the pool's entire purpose and failing the job on it would make that resilience worthless; the SDK suites remain the real gate. Every member dead is a hard failure, because the free tier then genuinely serves nothing. Output goes throughscripts/redact-log-credentials.pylike every other published dump in this job.No fallback model group foundsignature, so this reads as "a pool member is dead" instead of falling through to "this failure is something else".No paid fallback was added. Pointing
hive-freeat a paid model would silently spend real budget and break the one-alias-one-price rule; if a paid fallback is the right product answer, that is an owner decision, not one to smuggle into a CI fix.A trap this leaves behind, documented rather than fixed
allowed_failsandcooldown_timesit underlitellm_settingsindeploy/litellm/config.yaml. They look misplaced and in the narrow sense they are: both are Router constructor params, so they never reach the Router, and the effective cooldown is the default 5 s, not the 30 written there. That is observed, not deduced: the failing run's log prints'cooldown_time': 5.Moving
allowed_failsonto the Router would quietly restore this bug. With it unset,_should_cooldown_deploymenttakes the base case that cools a 404 member down on the first failure. Set it, and that branch is skipped forshould_cooldown_based_on_allowed_fails_policy, which needsfails > allowed_fails: four failures before a dead member leaves rotation, which the retry ladder above cannot outlast. The config now says so in place, so the next tidy-up does not walk into it.What kind of check actually closes this gap
The pool already had unit coverage,
apps/control-plane/internal/routing/free_pool_router_test.go, and it passed the entire time this was broken. It parses the migration text and asserts the four rows share onelitellm_model_name. That assertion was true; the shape was right and the behaviour was wrong. A test that can only see the config can never catch a failure that lives in the router's exception handling.What closes it is a test that exercises the real function against the real upstream envelope, so this PR adds behavioural coverage over
dispatchWithRetryusing the verbatim 404 body from run 32830060362:TestDispatchWithRetry_FailsOverWhenOnePoolMemberIsGone— dead member on attempt 1, 200 on attempt 2. Fails onmain.TestDispatchWithRetry_GenuineModelNotFoundIsNotRetried— bounds the blast radius; a real model-not-found stays one attempt.TestDispatchWithRetry_DeadPoolReturnsTheUpstream404Intact— when retries cannot rescue it, the client still gets the real status and a complete body.TestIsRouterExhaustion404LeavesTheBodyReadable— the body splice survives a payload larger than the peek window.Plus
scripts/report-free-pool-health.py --selfcheck, covering the one-dead / all-dead / missing-counts / malformed-endpoint verdicts.The live half is closed by the new probe step, which is the only thing in CI that can name a retired model, and by the classifier branch that stops this failure shape reading as unexplained.
Test evidence
Full module, plus build and vet:
Redaction verified end to end on a synthetic error carrying a key-shaped value:
gofmt -loutput is byte-identical before and after this change, so no formatting regression was introduced.Buglog entry
{"id":"free-pool-404-no-failover","date":"2026-08-25","title":"LiteLLM aborts a load-balanced group on one member's 404 instead of failing over, taking hive-free down","error_message":"litellm.NotFoundError: NotFoundError: OpenAIException - Error code: 404No fallback model group found for original model_group=route-free-pool. Available Model Group Fallbacks=None -- surfaced to the customer as 'hive-free is not available.'","root_cause":"litellm/router.py::should_retry_this_error re-raises immediately for NotFoundError and for any status where litellm._should_retry() is false; 404 is both. It is called from async_function_with_retries and async_function_with_fallbacks, so one pool member answering 404 (what a retired free model returns) killed the request while the group's other three members were healthy. litellm/types/router.py::RetryPolicy has no NotFoundErrorRetries field, so no config lifts it. The pool's existing unit test asserts the four rows share a litellm_model_name, which stayed true throughout, so the shape was right and the behaviour was wrong.","fix":"Retry a LiteLLM router-exhaustion 404 in the shared dispatch seam (apps/edge-api/internal/inference/retry.go), matched on LiteLLM's message rather than on a bare 404 so a genuine model-not-found stays fast. Sound because LiteLLM cools the 404 deployment down on its first failure, so the next attempt picks a different member inside the 5s window. Added behavioural tests over dispatchWithRetry, a CI step that names a dead member via /health?model=, and a classifier branch for the signature.","tags":["litellm","routing","free-pool","failover","ci","edge-api","hive-free","404"]}{"id":"litellm-router-knobs-in-wrong-config-block","date":"2026-08-25","title":"allowed_fails and cooldown_time under litellm_settings never reach the LiteLLM Router, and moving allowed_fails would break free pool failover","error_message":"config declares cooldown_time: 30 but the runtime log prints 'cooldown_time': 5 in its Cooldown Deployments line","root_cause":"allowed_fails and cooldown_time are Router constructor params (litellm/types/router.py::UpdateRouterConfig), not litellm_settings keys, so the values are silently ignored and DEFAULT_COOLDOWN_TIME_SECONDS (5) applies. The apparent misplacement is load-bearing: with allowed_fails unset on the Router, _should_cooldown_deployment takes its base case and cools a 404 member down on the first failure, which is what lets the edge retry land on a different member.","fix":"Left both settings in place deliberately and documented the trap in deploy/litellm/config.yaml, including that moving allowed_fails into router_settings would require four failures before a dead member leaves rotation and would quietly restore the free pool 404 bug.","tags":["litellm","config","cooldown","free-pool","footgun"]}