feat: request-time model fallback at streamFn — honor --fallback-model (#51)#65
Conversation
#51) When the primary provider is down / rate-limited (429) / returns a 5xx/network error, transparently retry the same turn against a configured fallback model. Wires the previously-reserved --fallback-model flag. Key runtime detail: pi-ai's streamSimple returns the event stream *synchronously* and never throws on a failed request — a failure surfaces as a single `{type:'error'}` event with no preceding `start`. So the issue's `await streamSimple` + try/catch sketch can't work. Instead we peek the first event (streamFn may return a Promise<stream>): a retryable error before the first chunk advances to the fallback; otherwise we return a stream that replays the peeked events and forwards the rest, so the Agent loop (iteration + result()) is unaffected. - model-fallback.ts (new): isRetryableStreamError (429/5xx/network, NOT auth/4xx) + streamWithFallback (peek/retry/replay). Pure, injectable streamFn. - sdk.ts: factor resolveModel() + withProviderAuth() (shared by primary & fallback; cross-provider fallback resolves its own env key); add `fallback` option; wire streamWithFallback into streamFn (thin pass-through when no fallback). - main.ts / args.ts: plumb --fallback-model (+ --fallback-provider); drop the "not yet enforced" note; update help. Tests: 13 unit (stubbed streamFn) covering success/429-fallback/non-retryable/ all-fail/mid-stream-no-retry/replay/sync-throw/custom-predicate; 3 SDK integration (option plumbing + resolveModel, unknown-id throw); 2 args. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
✅ VerificationFull suite + typecheck + lint (clean)
New tests for this feature (24)End-to-end runtime check against the built binaryHelp text: Driving the built The consumer sees only the fallback's events — the primary's error is swallowed, the turn survives. Behavior confirmed
Behavior is exercised against the real pi-ai |
|
✅ PR OK — verified end-to-end (posted as comment; GitHub blocks self-approval). Addresses #51: request-time availability fallback at the Notable: the PR correctly identifies that the issue's own sketch (
The peek/retry/replay design is sound: retry only when the first event is a retryable error (clean pre-stream swap); a Refactor is behavior-preserving: Verification: build ✓ · typecheck ✓ · lint ✓ · full suite 646/646 · 24 new tests ( Excellent work — correct, well-scoped, thoroughly tested. LGTM. |
…thProviderAuth refactor from #65
Closes #51.
Summary
Implements Layer 1 / availability fallback: when the primary model's request fails before the first chunk with a retryable error (429 / 5xx / network), the current turn is transparently retried against a configured fallback model. Wires the previously-reserved
--fallback-modelflag end-to-end.The seam — and a correction to the issue's sketch
The issue proposed
try { return await streamSimple(...) } catch. That can't work: pi-ai'sstreamSimplereturns anAssistantMessageEventStreamsynchronously and does not throw on a failed request — the failure arrives as a single{ type: 'error' }event (carrying anAssistantMessagewithstopReason: 'error'+errorMessage) with no precedingstartevent (verified in pi-ai's provider code).pi-agent-core's
StreamFnmay return aPromise<stream>, so the correct approach is:for await+response.result()behave exactly as with a directstreamSimplecall.A mid-stream failure (error after a
start) is not retried — too late to swap cleanly (out of scope, as the issue notes).Changes
core/model-fallback.ts(new):isRetryableStreamError(msg)— retryable on408/409/425/429/5xx/529and network phrases (ECONNRESET, timeout, overloaded, …); not retryable on auth/4xx (401/403/400/404/422). Guards against matching a code embedded in a larger number.streamWithFallback(ctx, attempts, streamFn, isRetryable?)— peek/retry/replay.streamFnis injectable for testing. Single attempt = thin pass-through.core/sdk.ts— factoredresolveModel(provider, modelId)andwithProviderAuth(model, opts, primaryProvider)(shared by primary + fallback; a cross-provider fallback resolves its own env key). Added thefallback?: { provider?; modelId }option (provider defaults to primary).streamFnnow builds the attempt list and delegates tostreamWithFallback.cli/args.ts+cli/main.ts— plumb--fallback-model(+ optional--fallback-provider); dropped the “not yet enforced” comment; updated help text.Acceptance criteria
createAgentSession({ fallback })retries on a retryable pre-stream error and succeeds against the fallback.--fallback-modelhonored end-to-end (CLI → main.ts → createAgentSession).Tests
core/tests/model-fallback.test.ts— 13 tests (success/no-fallback, 429→fallback, non-retryable→propagate, all-fail→surface last error, mid-stream error → no retry, multi-event replay, sync-throw propagate vs retry, custom predicate, empty attempts).core/tests/sdk-fallback.test.ts— 3 integration tests (option plumbing +resolveModelfor primary & fallback; no-fallback pass-through; unknown fallback id throws eagerly).cli/tests/args-sdk-flags.test.ts—--fallback-model/--fallback-providerparsing.🤖 Generated with Claude Code