feat(agents): Harden the LLM call path against relay failure modes - #100
Merged
Conversation
Five changes to src/agents/base.py, all motivated by observed production incidents on the OPENAI_BASE_URL relay: - Stream OpenAI calls (stream=True + include_usage). The relay sits behind Cloudflare's ~120s proxy read timeout (HTTP 524), so any non-streamed generation over 120s could never succeed (2026-06-08/09: every long call 524'd and mornings died). Streaming keeps bytes flowing; missing usage falls back to a loud chars/4 estimate instead of 0/0. - Wall-clock retry deadline (480s, QUANT_AGENT_RETRY_DEADLINE_S). The attempt budget alone doesn't bound time: 7 attempts at 120-380s each collided with the wrapper's 1200s kill, so the Anthropic failover below the loop never fired in exactly the sustained-outage scenario it was built for. Past the deadline the primary is abandoned and failover runs while the session window still has room (480 + 300 failover < 1200). - Honor server retry-after hints on 429/524 (header, body field, or message text), capped at 120s so a hostile hint can't stall a session. Pure exponential jitter retried in 2-15s against a server that said 'come back in 120s', burning attempts for nothing. - Per-provider in-flight semaphores around the HTTP call (OpenAI 3, QUANT_AGENT_MAX_CONCURRENT_LLM; Anthropic 4, independent so failover never queues behind a wedged relay slot). The morning fan-out self-inflicted 'Concurrency limit exceeded' 429 storms against the relay's per-user cap (175 occurrences in the 06-16..06-29 logs). - max_retries=0 on every SDK client: both SDKs default to 2 internal retries, silently tripling each agent-loop attempt and invalidating the retry-budget/deadline math. The agent loop is the single retry owner. Degenerate responses now fail loudly instead of passing as clean no-signals: an empty 200 body raises LLMEmptyResponseError and a stream that ends without finish_reason raises LLMStreamInterruptedError (partial text discarded — a half-emitted PM decision parses like 'no trades'). Both are retryable and reach the failover. Truncation-family finish reasons (max_tokens / length / insufficient_system_resource) are exempt: an empty body there is a legit ceiling hit surfaced via truncated=True, never a retry (shared _TRUNCATION_FINISH_REASONS constant). Tests: streaming assembly + usage fallback, interrupt/empty guards on all three providers, deadline-triggered failover, hint floor + cap, SDK internal-retry disable on all constructors, semaphore leak check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQkESoSTYx2bCy7WYnTPXR
One real gpt-5.5 call through the OPENAI_BASE_URL relay with stream=True + stream_options include_usage — the load-bearing assumption of the streamed _call_openai path. Run this against the live relay BEFORE merging the streaming change (the relay was down during development, so live verification is still pending): a 400 on stream_options would mean every production call fails over to Anthropic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQkESoSTYx2bCy7WYnTPXR
Merged
3 tasks
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.
Summary
Five changes to
src/agents/base.py, each the codified fix for an observed production incident on theOPENAI_BASE_URLrelay:stream=True+include_usage). The relay sits behind Cloudflare's ~120s proxy read timeout (HTTP 524), so any non-streamed generation over 120s could never succeed through it (2026-06-08/09: every long call 524'd and two mornings died with a funded failover key sitting idle). Streaming keeps bytes flowing;_LLM_HTTP_TIMEOUTbecomes a per-chunk read timeout. A relay that ignoresinclude_usagefalls back to a loud chars/4 token estimate instead of 0/0.QUANT_AGENT_RETRY_DEADLINE_Soverride. The attempt budget alone doesn't bound time: 7 attempts × 120–380s collided with the wrapper's 1200s kill, so the Anthropic failover below the loop never fired in exactly the sustained-outage scenario it was built for. Past the deadline the primary is abandoned and failover runs while the session window still has room (480 + 300 failover < 1200).QUANT_AGENT_MAX_CONCURRENT_LLM), Anthropic 4 — independent, so failover never queues behind a wedged relay slot. The morning fan-out self-inflicted "Concurrency limit exceeded" 429 storms (175 occurrences in the 06-16..06-29 logs).max_retries=0on every SDK client — both SDKs default to 2 internal retries, silently tripling each agent-loop attempt and invalidating the retry-budget/deadline math. The agent loop is the single retry owner.Degenerate responses now fail loudly instead of passing as clean no-signals: an empty 200 body raises
LLMEmptyResponseError, a stream that ends withoutfinish_reasonraisesLLMStreamInterruptedError(partial text discarded — a half-emitted PM decision parses like "no trades"). Both are retryable and reach the failover. Truncation-family finish reasons (max_tokens/length/insufficient_system_resource) are exempt: an empty body there is a legit ceiling hit surfaced viatruncated=True, never a retry.scripts/relay_stream_smoke.pymust PASS against the live relay before merging — it makes one real streamed gpt-5.5 call and checks content /finish_reason/ usage. The relay was down (TLS handshake failure) throughout development, so the load-bearing assumption — the relay acceptingstream=True+stream_options— is still unverified. If the relay 400s onstream_options, every production call would fail over to Anthropic.Test plan
max_retries=0on all four client constructions, semaphore leak checkscripts/relay_stream_smoke.pyagainst the live relay (blocked: relay down)FAILOVER/ truncation / estimate warnings🤖 Generated with Claude Code
https://claude.ai/code/session_01PQkESoSTYx2bCy7WYnTPXR