detached 0a85be3

add model rescue picker and silent transient retries

commit
0a85be3c335a18ceece037fc743c44164dd1f109
author
patriceckhart · · 2026-05-06 18:40 UTC
parent
0f332fa
signature
unsigned
refs
tag: v0.1.61
9 files · +853 -53
M README.md +12 -0
@@ -262,6 +262,18 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans
262262
263263 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
264264
265+### Model fallback (rescue)
266+
267+When a turn fails because of a recoverable provider error — expired token (`401`), permission denied (`403`), rate limit (`429`), provider outage (`502`/`503`/`504`), or a transient network failure — zot opens a **rescue** picker over the chat instead of just painting a red banner.
268+
269+The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss.
270+
271+Before the actual provider request fires, the OpenAI / Anthropic / Kimi / OpenAI-Codex clients also do up to two silent retries with short backoff (250ms, 750ms) on `502`/`503`/`504` and connection-reset / EOF-before-headers errors. Most edge-proxy blips disappear without you ever seeing the rescue picker.
272+
273+A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. The status bar tags the swap as `rescue retry: switched to <provider> / <model> (ignored --api-key / --base-url)` so the behavior is visible. Use `/model` if you want overrides to stick.
274+
275+No configuration is required — the candidate list is built dynamically from your active credentials. Bad-request / context-length / serialization errors are NOT routed to the rescue picker, because switching models won't fix them; those still surface as a normal error.
276+
265277 ### Custom models
266278
267279 Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:
@@ -262,6 +262,18 @@ Every interactive or print/json run (unless `--no-session`) writes a JSONL trans
262 262
263 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.263 The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
264 264
265+### Model fallback (rescue)
266+
267+When a turn fails because of a recoverable provider error — expired token (`401`), permission denied (`403`), rate limit (`429`), provider outage (`502`/`503`/`504`), or a transient network failure — zot opens a **rescue** picker over the chat instead of just painting a red banner.
268+
269+The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss.
270+
271+Before the actual provider request fires, the OpenAI / Anthropic / Kimi / OpenAI-Codex clients also do up to two silent retries with short backoff (250ms, 750ms) on `502`/`503`/`504` and connection-reset / EOF-before-headers errors. Most edge-proxy blips disappear without you ever seeing the rescue picker.
272+
273+A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. The status bar tags the swap as `rescue retry: switched to <provider> / <model> (ignored --api-key / --base-url)` so the behavior is visible. Use `/model` if you want overrides to stick.
274+
275+No configuration is required — the candidate list is built dynamically from your active credentials. Bad-request / context-length / serialization errors are NOT routed to the rescue picker, because switching models won't fix them; those still surface as a normal error.
276+
265 ### Custom models277 ### Custom models
266 278
267 Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:279 Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:
M internal/agent/cli.go +32 -0
@@ -475,6 +475,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
475475 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
476476 }
477477
478+ // Rebuild agent for the rescue picker after a recoverable failure.
479+ // Unlike buildAgentFor, this drops launch-time --api-key and
480+ // --base-url overrides because those are typically the cause of the
481+ // rescue (a bad key, a typo'd base URL, or a corporate gateway that
482+ // only the originally-picked provider needed). Re-resolving without
483+ // them lets the rescue retry use env vars / auth.json / provider
484+ // defaults the way zot would have without the overrides.
485+ buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
486+ next := args
487+ next.APIKey = ""
488+ next.BaseURL = ""
489+ if providerOverride != "" {
490+ next.Provider = providerOverride
491+ }
492+ if modelOverride != "" {
493+ next.Model = modelOverride
494+ }
495+ resolved, err := Resolve(next, true)
496+ if err != nil {
497+ return nil, "", "", err
498+ }
499+ resolved.UseSandbox(sharedSandbox)
500+ resolved.MergeExtensionTools(extToolAdapter)
501+ return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
502+ }
503+
478504 var ag *core.Agent
479505 if r.HasCredential() {
480506 ag = wireAgentExt(r.NewAgent())
@@ -573,6 +599,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
573599 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
574600 return wireAgentPersist(a), p, m, err
575601 }
602+ baseBuildAgentForRescue := buildAgentForRescue
603+ buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
604+ a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
605+ return wireAgentPersist(a), p, m, err
606+ }
576607
577608 // loadSession replaces the current session with the one at path and
578609 // hands its messages to the agent. Used by the /sessions picker.
@@ -687,6 +718,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
687718 BuildAgent: buildAgent,
688719 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
689720 BuildAgentFor: buildAgentFor,
721+ BuildAgentForRescue: buildAgentForRescue,
690722 LoggedInProviders: func() []string {
691723 var out []string
692724 for _, p := range []string{"anthropic", "openai", "kimi"} {
@@ -475,6 +475,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
475 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil475 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
476 }476 }
477 477
478+ // Rebuild agent for the rescue picker after a recoverable failure.
479+ // Unlike buildAgentFor, this drops launch-time --api-key and
480+ // --base-url overrides because those are typically the cause of the
481+ // rescue (a bad key, a typo'd base URL, or a corporate gateway that
482+ // only the originally-picked provider needed). Re-resolving without
483+ // them lets the rescue retry use env vars / auth.json / provider
484+ // defaults the way zot would have without the overrides.
485+ buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
486+ next := args
487+ next.APIKey = ""
488+ next.BaseURL = ""
489+ if providerOverride != "" {
490+ next.Provider = providerOverride
491+ }
492+ if modelOverride != "" {
493+ next.Model = modelOverride
494+ }
495+ resolved, err := Resolve(next, true)
496+ if err != nil {
497+ return nil, "", "", err
498+ }
499+ resolved.UseSandbox(sharedSandbox)
500+ resolved.MergeExtensionTools(extToolAdapter)
501+ return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
502+ }
503+
478 var ag *core.Agent504 var ag *core.Agent
479 if r.HasCredential() {505 if r.HasCredential() {
480 ag = wireAgentExt(r.NewAgent())506 ag = wireAgentExt(r.NewAgent())
@@ -573,6 +599,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
573 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)599 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
574 return wireAgentPersist(a), p, m, err600 return wireAgentPersist(a), p, m, err
575 }601 }
602+ baseBuildAgentForRescue := buildAgentForRescue
603+ buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
604+ a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
605+ return wireAgentPersist(a), p, m, err
606+ }
576 607
577 // loadSession replaces the current session with the one at path and608 // loadSession replaces the current session with the one at path and
578 // hands its messages to the agent. Used by the /sessions picker.609 // hands its messages to the agent. Used by the /sessions picker.
@@ -687,6 +718,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
687 BuildAgent: buildAgent,718 BuildAgent: buildAgent,
688 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,719 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
689 BuildAgentFor: buildAgentFor,720 BuildAgentFor: buildAgentFor,
721+ BuildAgentForRescue: buildAgentForRescue,
690 LoggedInProviders: func() []string {722 LoggedInProviders: func() []string {
691 var out []string723 var out []string
692 for _, p := range []string{"anthropic", "openai", "kimi"} {724 for _, p := range []string{"anthropic", "openai", "kimi"} {
M internal/agent/modes/interactive.go +152 -9
@@ -63,6 +63,15 @@ type InteractiveConfig struct {
6363 // If providerOverride is empty, the current provider is kept.
6464 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
6565
66+ // BuildAgentForRescue rebuilds the agent for the rescue picker that
67+ // opens after a recoverable provider failure. Unlike BuildAgentFor,
68+ // this builder drops launch-time --api-key and --base-url overrides
69+ // because those are usually the reason rescue triggered. Re-resolves
70+ // credentials from env vars / auth.json / provider defaults so the
71+ // retry has a real chance of succeeding. Falls back to BuildAgentFor
72+ // when nil so embedders that don't wire it keep working.
73+ BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
74+
6675 // LoggedInProviders returns the list of provider names that
6776 // currently have credentials. Used by /model to filter the
6877 // picker to only show reachable models.
@@ -268,6 +277,7 @@ type Interactive struct {
268277
269278 dialog *loginDialog
270279 modelDialog *modelDialog
280+ rescueDialog *rescueDialog
271281 sessionDialog *sessionDialog
272282 jumpDialog *jumpDialog
273283 btwDialog *btwDialog
@@ -323,6 +333,13 @@ type Interactive struct {
323333 // on a background goroutine. Keeping this off the input goroutine
324334 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
325335 sessionLoading bool
336+
337+ // pendingRescuePrompt / pendingRescueImages stash the prompt and
338+ // images that should be re-run after the user picks a model in
339+ // the rescue dialog. Cleared once applyRescueSelection consumes
340+ // them (or when the dialog is dismissed via esc).
341+ pendingRescuePrompt string
342+ pendingRescueImages []provider.ImageBlock
326343 }
327344
328345 // welcomeVersionDuration is how long the welcome banner shows the
@@ -347,6 +364,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
347364 dirty: make(chan struct{}, 8),
348365 dialog: newLoginDialog(),
349366 modelDialog: newModelDialog(),
367+ rescueDialog: newRescueDialog(),
350368 sessionDialog: newSessionDialog(),
351369 jumpDialog: newJumpDialog(),
352370 btwDialog: newBtwDialog(),
@@ -852,6 +870,8 @@ func (i *Interactive) redraw() {
852870 dialog = i.dialog.Render(i.cfg.Theme, cols)
853871 case i.modelDialog.Active():
854872 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
873+ case i.rescueDialog.Active():
874+ dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
855875 case i.sessionDialog.Active():
856876 // Reserve rows for the editor (~3), status line (1-2),
857877 // dialog chrome (header + hint + rule + indicators, ~5),
@@ -1436,6 +1456,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
14361456 }
14371457 return false
14381458 }
1459+ if i.rescueDialog.Active() {
1460+ if k.Kind == tui.KeyCtrlC {
1461+ i.rescueDialog.Close()
1462+ i.invalidate()
1463+ return false
1464+ }
1465+ act := i.rescueDialog.HandleKey(k)
1466+ if act.Select {
1467+ i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
1468+ }
1469+ i.invalidate()
1470+ return false
1471+ }
14391472 if i.sessionDialog.Active() {
14401473 if k.Kind == tui.KeyCtrlC {
14411474 i.sessionDialog.Close()
@@ -2835,6 +2868,25 @@ func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
28352868 }
28362869
28372870 func (i *Interactive) applyModelSelection(prov, model string) {
2871+ i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
2872+}
2873+
2874+// applyRescueModelSelection is like applyModelSelection but routes
2875+// through BuildAgentForRescue so launch-time --api-key / --base-url
2876+// overrides are dropped before the new agent is built. Falls back to
2877+// the regular builder when the host doesn't wire a rescue builder.
2878+func (i *Interactive) applyRescueModelSelection(prov, model string) {
2879+ builder := i.cfg.BuildAgentForRescue
2880+ if builder == nil {
2881+ builder = i.cfg.BuildAgentFor
2882+ }
2883+ i.swapModel(prov, model, builder, true)
2884+}
2885+
2886+// swapModel applies a /model selection (or a rescue selection) using
2887+// the supplied builder. rescue=true tags the success message so the
2888+// user can see that launch-time overrides were ignored.
2889+func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
28382890 if model == "" {
28392891 return
28402892 }
@@ -2845,8 +2897,11 @@ func (i *Interactive) applyModelSelection(prov, model string) {
28452897 i.mu.Unlock()
28462898 return
28472899 }
2848- // Same provider? Just swap the model on the existing agent.
2849- if i.agent != nil && m.Provider == i.cfg.Provider {
2900+ // Same provider AND not a rescue retry: just swap the model on
2901+ // the existing agent — no rebuild needed because the underlying
2902+ // client is reusable. Rescue retries always rebuild so a stale
2903+ // auth header / base URL can't carry over.
2904+ if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
28502905 i.mu.Lock()
28512906 i.cfg.Model = m.ID
28522907 i.agent.Model = m.ID
@@ -2858,8 +2913,7 @@ func (i *Interactive) applyModelSelection(prov, model string) {
28582913 }
28592914 return
28602915 }
2861- // Different provider: rebuild agent (needs credentials for target provider).
2862- if i.cfg.BuildAgentFor == nil {
2916+ if builder == nil {
28632917 i.mu.Lock()
28642918 i.statusErr = "cannot switch provider: no builder configured"
28652919 i.mu.Unlock()
@@ -2876,7 +2930,7 @@ func (i *Interactive) applyModelSelection(prov, model string) {
28762930 carryCost = i.agent.Cost()
28772931 }
28782932
2879- ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
2933+ ag, p, md, err := builder(m.Provider, m.ID)
28802934 if err != nil {
28812935 i.mu.Lock()
28822936 i.statusErr = err.Error()
@@ -2899,7 +2953,11 @@ func (i *Interactive) applyModelSelection(prov, model string) {
28992953 i.agent = ag
29002954 i.cfg.Provider = p
29012955 i.cfg.Model = md
2902- i.statusOK = "switched to " + p + " / " + md
2956+ if rescue {
2957+ i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
2958+ } else {
2959+ i.statusOK = "switched to " + p + " / " + md
2960+ }
29032961 i.statusErr = ""
29042962 // Render cache keys are width+content based, so the new agent's
29052963 // identical messages will reuse the existing entries. Nothing
@@ -3113,6 +3171,33 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
31133171 if err != nil && ctx.Err() == nil {
31143172 i.statusErr = err.Error()
31153173 }
3174+ // Decide whether to offer a model rescue picker for recoverable
3175+ // provider failures (auth/rate/temporary). The picker opens after
3176+ // the mutex is released so it can take its own locks freely.
3177+ var (
3178+ offer bool
3179+ rescueWhy string
3180+ rescueImgs []provider.ImageBlock
3181+ rescueModel string
3182+ rescueProv string
3183+ rescueFprov string
3184+ )
3185+ if err != nil && ctx.Err() == nil {
3186+ if ok, reason := classifyRescueError(err); ok {
3187+ offer = true
3188+ rescueWhy = reason
3189+ rescueImgs = images
3190+ rescueModel = i.cfg.Model
3191+ rescueProv = i.cfg.Provider
3192+ rescueFprov = extractFailedProvider(err)
3193+ if rescueFprov == "" {
3194+ rescueFprov = i.cfg.Provider
3195+ }
3196+ // Suppress the red banner — the rescue dialog already
3197+ // surfaces the failure.
3198+ i.statusErr = ""
3199+ }
3200+ }
31163201 // Detect HTTP 413 "payload too large" responses. The provider
31173202 // rejected the request because the request body exceeded its
31183203 // per-request limit. Token-based auto-compact can miss this
@@ -3162,6 +3247,8 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
31623247 switch {
31633248 case hasNext:
31643249 i.startTurn(parent, next)
3250+ case offer:
3251+ i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
31653252 case payloadTooLarge:
31663253 i.runCompact(parent, true)
31673254 case shouldAutoCompact:
@@ -3170,6 +3257,56 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
31703257 }()
31713258 }
31723259
3260+// openRescueDialog surfaces the rescue model picker after a recoverable
3261+// provider failure. The pending prompt + images are stashed on the
3262+// Interactive so a later applyRescueSelection can re-run the same turn
3263+// against the freshly-picked model. activeProvider/failedProvider are
3264+// usually the same, but some clients embed different prefixes in their
3265+// errors than the configured provider id, so we accept both.
3266+func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
3267+ if i.rescueDialog == nil {
3268+ return
3269+ }
3270+ loggedIn := []string{}
3271+ if i.cfg.LoggedInProviders != nil {
3272+ loggedIn = i.cfg.LoggedInProviders()
3273+ }
3274+ fprov := failedProvider
3275+ if fprov == "" {
3276+ fprov = activeProvider
3277+ }
3278+ i.mu.Lock()
3279+ i.pendingRescuePrompt = prompt
3280+ i.pendingRescueImages = images
3281+ i.mu.Unlock()
3282+ i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
3283+ i.invalidate()
3284+}
3285+
3286+// applyRescueSelection switches model (cross-provider if needed) and
3287+// re-runs the same prompt+images that just failed. Mirrors
3288+// applyModelSelection's transcript-carry logic so the user keeps full
3289+// session continuity across the swap.
3290+func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
3291+ if model == "" {
3292+ return
3293+ }
3294+ i.applyRescueModelSelection(prov, model)
3295+ i.mu.Lock()
3296+ images := i.pendingRescueImages
3297+ if prompt == "" {
3298+ prompt = i.pendingRescuePrompt
3299+ }
3300+ i.pendingRescuePrompt = ""
3301+ i.pendingRescueImages = nil
3302+ i.mu.Unlock()
3303+ parent := i.runCtx
3304+ if parent == nil {
3305+ parent = context.Background()
3306+ }
3307+ i.startTurnWithImages(parent, prompt, images)
3308+}
3309+
31733310 func stripAutoCompactNotes(notes []string) []string {
31743311 if len(notes) == 0 {
31753312 return notes
@@ -3365,9 +3502,15 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
33653502 i.statusOK = "cancelled"
33663503 return
33673504 }
3368- if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {
3369- i.statusErr = e.Err.Error()
3370- }
3505+ // Don't surface mid-loop stream errors as a red banner here.
3506+ // EvTurnEnd fires after every step in a multi-step tool loop,
3507+ // so a transient 503 / network blip would briefly paint a red
3508+ // banner over the still-streaming chat before the agent loop
3509+ // either retries or exits. The final error (if any) is set by
3510+ // startTurnWithImages once Prompt() returns, and recoverable
3511+ // failures are routed to the rescue picker instead — which
3512+ // keeps the chat clean while the agent is working.
3513+ _ = e.Err
33713514 }
33723515 }
33733516
@@ -63,6 +63,15 @@ type InteractiveConfig struct {
63 // If providerOverride is empty, the current provider is kept.63 // If providerOverride is empty, the current provider is kept.
64 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)64 BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
65 65
66+ // BuildAgentForRescue rebuilds the agent for the rescue picker that
67+ // opens after a recoverable provider failure. Unlike BuildAgentFor,
68+ // this builder drops launch-time --api-key and --base-url overrides
69+ // because those are usually the reason rescue triggered. Re-resolves
70+ // credentials from env vars / auth.json / provider defaults so the
71+ // retry has a real chance of succeeding. Falls back to BuildAgentFor
72+ // when nil so embedders that don't wire it keep working.
73+ BuildAgentForRescue func(providerOverride, modelOverride string) (*core.Agent, string, string, error)
74+
66 // LoggedInProviders returns the list of provider names that75 // LoggedInProviders returns the list of provider names that
67 // currently have credentials. Used by /model to filter the76 // currently have credentials. Used by /model to filter the
68 // picker to only show reachable models.77 // picker to only show reachable models.
@@ -268,6 +277,7 @@ type Interactive struct {
268 277
269 dialog *loginDialog278 dialog *loginDialog
270 modelDialog *modelDialog279 modelDialog *modelDialog
280+ rescueDialog *rescueDialog
271 sessionDialog *sessionDialog281 sessionDialog *sessionDialog
272 jumpDialog *jumpDialog282 jumpDialog *jumpDialog
273 btwDialog *btwDialog283 btwDialog *btwDialog
@@ -323,6 +333,13 @@ type Interactive struct {
323 // on a background goroutine. Keeping this off the input goroutine333 // on a background goroutine. Keeping this off the input goroutine
324 // lets ctrl+c/exit remain responsive for very large JSONL sessions.334 // lets ctrl+c/exit remain responsive for very large JSONL sessions.
325 sessionLoading bool335 sessionLoading bool
336+
337+ // pendingRescuePrompt / pendingRescueImages stash the prompt and
338+ // images that should be re-run after the user picks a model in
339+ // the rescue dialog. Cleared once applyRescueSelection consumes
340+ // them (or when the dialog is dismissed via esc).
341+ pendingRescuePrompt string
342+ pendingRescueImages []provider.ImageBlock
326 }343 }
327 344
328 // welcomeVersionDuration is how long the welcome banner shows the345 // welcomeVersionDuration is how long the welcome banner shows the
@@ -347,6 +364,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
347 dirty: make(chan struct{}, 8),364 dirty: make(chan struct{}, 8),
348 dialog: newLoginDialog(),365 dialog: newLoginDialog(),
349 modelDialog: newModelDialog(),366 modelDialog: newModelDialog(),
367+ rescueDialog: newRescueDialog(),
350 sessionDialog: newSessionDialog(),368 sessionDialog: newSessionDialog(),
351 jumpDialog: newJumpDialog(),369 jumpDialog: newJumpDialog(),
352 btwDialog: newBtwDialog(),370 btwDialog: newBtwDialog(),
@@ -852,6 +870,8 @@ func (i *Interactive) redraw() {
852 dialog = i.dialog.Render(i.cfg.Theme, cols)870 dialog = i.dialog.Render(i.cfg.Theme, cols)
853 case i.modelDialog.Active():871 case i.modelDialog.Active():
854 dialog = i.modelDialog.Render(i.cfg.Theme, cols)872 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
873+ case i.rescueDialog.Active():
874+ dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
855 case i.sessionDialog.Active():875 case i.sessionDialog.Active():
856 // Reserve rows for the editor (~3), status line (1-2),876 // Reserve rows for the editor (~3), status line (1-2),
857 // dialog chrome (header + hint + rule + indicators, ~5),877 // dialog chrome (header + hint + rule + indicators, ~5),
@@ -1436,6 +1456,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1436 }1456 }
1437 return false1457 return false
1438 }1458 }
1459+ if i.rescueDialog.Active() {
1460+ if k.Kind == tui.KeyCtrlC {
1461+ i.rescueDialog.Close()
1462+ i.invalidate()
1463+ return false
1464+ }
1465+ act := i.rescueDialog.HandleKey(k)
1466+ if act.Select {
1467+ i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
1468+ }
1469+ i.invalidate()
1470+ return false
1471+ }
1439 if i.sessionDialog.Active() {1472 if i.sessionDialog.Active() {
1440 if k.Kind == tui.KeyCtrlC {1473 if k.Kind == tui.KeyCtrlC {
1441 i.sessionDialog.Close()1474 i.sessionDialog.Close()
@@ -2835,6 +2868,25 @@ func (i *Interactive) scrollToLastTurn(msgs []provider.Message) {
2835 }2868 }
2836 2869
2837 func (i *Interactive) applyModelSelection(prov, model string) {2870 func (i *Interactive) applyModelSelection(prov, model string) {
2871+ i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
2872+}
2873+
2874+// applyRescueModelSelection is like applyModelSelection but routes
2875+// through BuildAgentForRescue so launch-time --api-key / --base-url
2876+// overrides are dropped before the new agent is built. Falls back to
2877+// the regular builder when the host doesn't wire a rescue builder.
2878+func (i *Interactive) applyRescueModelSelection(prov, model string) {
2879+ builder := i.cfg.BuildAgentForRescue
2880+ if builder == nil {
2881+ builder = i.cfg.BuildAgentFor
2882+ }
2883+ i.swapModel(prov, model, builder, true)
2884+}
2885+
2886+// swapModel applies a /model selection (or a rescue selection) using
2887+// the supplied builder. rescue=true tags the success message so the
2888+// user can see that launch-time overrides were ignored.
2889+func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
2838 if model == "" {2890 if model == "" {
2839 return2891 return
2840 }2892 }
@@ -2845,8 +2897,11 @@ func (i *Interactive) applyModelSelection(prov, model string) {
2845 i.mu.Unlock()2897 i.mu.Unlock()
2846 return2898 return
2847 }2899 }
2848- // Same provider? Just swap the model on the existing agent.2900+ // Same provider AND not a rescue retry: just swap the model on
2849- if i.agent != nil && m.Provider == i.cfg.Provider {2901+ // the existing agent — no rebuild needed because the underlying
2902+ // client is reusable. Rescue retries always rebuild so a stale
2903+ // auth header / base URL can't carry over.
2904+ if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
2850 i.mu.Lock()2905 i.mu.Lock()
2851 i.cfg.Model = m.ID2906 i.cfg.Model = m.ID
2852 i.agent.Model = m.ID2907 i.agent.Model = m.ID
@@ -2858,8 +2913,7 @@ func (i *Interactive) applyModelSelection(prov, model string) {
2858 }2913 }
2859 return2914 return
2860 }2915 }
2861- // Different provider: rebuild agent (needs credentials for target provider).2916+ if builder == nil {
2862- if i.cfg.BuildAgentFor == nil {
2863 i.mu.Lock()2917 i.mu.Lock()
2864 i.statusErr = "cannot switch provider: no builder configured"2918 i.statusErr = "cannot switch provider: no builder configured"
2865 i.mu.Unlock()2919 i.mu.Unlock()
@@ -2876,7 +2930,7 @@ func (i *Interactive) applyModelSelection(prov, model string) {
2876 carryCost = i.agent.Cost()2930 carryCost = i.agent.Cost()
2877 }2931 }
2878 2932
2879- ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)2933+ ag, p, md, err := builder(m.Provider, m.ID)
2880 if err != nil {2934 if err != nil {
2881 i.mu.Lock()2935 i.mu.Lock()
2882 i.statusErr = err.Error()2936 i.statusErr = err.Error()
@@ -2899,7 +2953,11 @@ func (i *Interactive) applyModelSelection(prov, model string) {
2899 i.agent = ag2953 i.agent = ag
2900 i.cfg.Provider = p2954 i.cfg.Provider = p
2901 i.cfg.Model = md2955 i.cfg.Model = md
2902- i.statusOK = "switched to " + p + " / " + md2956+ if rescue {
2957+ i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
2958+ } else {
2959+ i.statusOK = "switched to " + p + " / " + md
2960+ }
2903 i.statusErr = ""2961 i.statusErr = ""
2904 // Render cache keys are width+content based, so the new agent's2962 // Render cache keys are width+content based, so the new agent's
2905 // identical messages will reuse the existing entries. Nothing2963 // identical messages will reuse the existing entries. Nothing
@@ -3113,6 +3171,33 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3113 if err != nil && ctx.Err() == nil {3171 if err != nil && ctx.Err() == nil {
3114 i.statusErr = err.Error()3172 i.statusErr = err.Error()
3115 }3173 }
3174+ // Decide whether to offer a model rescue picker for recoverable
3175+ // provider failures (auth/rate/temporary). The picker opens after
3176+ // the mutex is released so it can take its own locks freely.
3177+ var (
3178+ offer bool
3179+ rescueWhy string
3180+ rescueImgs []provider.ImageBlock
3181+ rescueModel string
3182+ rescueProv string
3183+ rescueFprov string
3184+ )
3185+ if err != nil && ctx.Err() == nil {
3186+ if ok, reason := classifyRescueError(err); ok {
3187+ offer = true
3188+ rescueWhy = reason
3189+ rescueImgs = images
3190+ rescueModel = i.cfg.Model
3191+ rescueProv = i.cfg.Provider
3192+ rescueFprov = extractFailedProvider(err)
3193+ if rescueFprov == "" {
3194+ rescueFprov = i.cfg.Provider
3195+ }
3196+ // Suppress the red banner — the rescue dialog already
3197+ // surfaces the failure.
3198+ i.statusErr = ""
3199+ }
3200+ }
3116 // Detect HTTP 413 "payload too large" responses. The provider3201 // Detect HTTP 413 "payload too large" responses. The provider
3117 // rejected the request because the request body exceeded its3202 // rejected the request because the request body exceeded its
3118 // per-request limit. Token-based auto-compact can miss this3203 // per-request limit. Token-based auto-compact can miss this
@@ -3162,6 +3247,8 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3162 switch {3247 switch {
3163 case hasNext:3248 case hasNext:
3164 i.startTurn(parent, next)3249 i.startTurn(parent, next)
3250+ case offer:
3251+ i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
3165 case payloadTooLarge:3252 case payloadTooLarge:
3166 i.runCompact(parent, true)3253 i.runCompact(parent, true)
3167 case shouldAutoCompact:3254 case shouldAutoCompact:
@@ -3170,6 +3257,56 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
3170 }()3257 }()
3171 }3258 }
3172 3259
3260+// openRescueDialog surfaces the rescue model picker after a recoverable
3261+// provider failure. The pending prompt + images are stashed on the
3262+// Interactive so a later applyRescueSelection can re-run the same turn
3263+// against the freshly-picked model. activeProvider/failedProvider are
3264+// usually the same, but some clients embed different prefixes in their
3265+// errors than the configured provider id, so we accept both.
3266+func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
3267+ if i.rescueDialog == nil {
3268+ return
3269+ }
3270+ loggedIn := []string{}
3271+ if i.cfg.LoggedInProviders != nil {
3272+ loggedIn = i.cfg.LoggedInProviders()
3273+ }
3274+ fprov := failedProvider
3275+ if fprov == "" {
3276+ fprov = activeProvider
3277+ }
3278+ i.mu.Lock()
3279+ i.pendingRescuePrompt = prompt
3280+ i.pendingRescueImages = images
3281+ i.mu.Unlock()
3282+ i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
3283+ i.invalidate()
3284+}
3285+
3286+// applyRescueSelection switches model (cross-provider if needed) and
3287+// re-runs the same prompt+images that just failed. Mirrors
3288+// applyModelSelection's transcript-carry logic so the user keeps full
3289+// session continuity across the swap.
3290+func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
3291+ if model == "" {
3292+ return
3293+ }
3294+ i.applyRescueModelSelection(prov, model)
3295+ i.mu.Lock()
3296+ images := i.pendingRescueImages
3297+ if prompt == "" {
3298+ prompt = i.pendingRescuePrompt
3299+ }
3300+ i.pendingRescuePrompt = ""
3301+ i.pendingRescueImages = nil
3302+ i.mu.Unlock()
3303+ parent := i.runCtx
3304+ if parent == nil {
3305+ parent = context.Background()
3306+ }
3307+ i.startTurnWithImages(parent, prompt, images)
3308+}
3309+
3173 func stripAutoCompactNotes(notes []string) []string {3310 func stripAutoCompactNotes(notes []string) []string {
3174 if len(notes) == 0 {3311 if len(notes) == 0 {
3175 return notes3312 return notes
@@ -3365,9 +3502,15 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
3365 i.statusOK = "cancelled"3502 i.statusOK = "cancelled"
3366 return3503 return
3367 }3504 }
3368- if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") {3505+ // Don't surface mid-loop stream errors as a red banner here.
3369- i.statusErr = e.Err.Error()3506+ // EvTurnEnd fires after every step in a multi-step tool loop,
3370- }3507+ // so a transient 503 / network blip would briefly paint a red
3508+ // banner over the still-streaming chat before the agent loop
3509+ // either retries or exits. The final error (if any) is set by
3510+ // startTurnWithImages once Prompt() returns, and recoverable
3511+ // failures are routed to the rescue picker instead — which
3512+ // keeps the chat clean while the agent is working.
3513+ _ = e.Err
3371 }3514 }
3372 }3515 }
3373 3516
A internal/agent/modes/rescue_dialog.go +300 -0
@@ -0,0 +1,300 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/provider"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
11+// rescueDialog offers a quick model swap when the active turn fails
12+// with a recoverable provider error (auth/rate/temporary). It looks
13+// and behaves like modelDialog: vertical list of available models,
14+// up/down to select, enter to retry, esc to cancel. The candidate
15+// list is built dynamically from the providers the user is currently
16+// logged in to — no static fallback config.
17+type rescueDialog struct {
18+ active bool
19+ all []provider.Model // candidates, sorted
20+ view []provider.Model // filtered view (typed query)
21+ cursor int
22+ current string // currently active model id (excluded from view)
23+ query string
24+ failedAt string // failed provider/model pair, e.g. "kimi/kimi-for-coding"
25+ reason string // short human-readable reason ("token expired", "rate limited", ...)
26+ prompt string // the user prompt that should be retried on Select
27+}
28+
29+type rescueDialogAction struct {
30+ Select bool
31+ Provider string
32+ Model string
33+ Prompt string
34+ Close bool
35+}
36+
37+func newRescueDialog() *rescueDialog { return &rescueDialog{} }
38+
39+// Open shows the dialog. current is the currently active model id so
40+// it can be excluded from the candidate list. loggedInProviders is
41+// the set of provider names with usable credentials right now.
42+func (d *rescueDialog) Open(current string, loggedInProviders []string, failedProvider, failedModel, reason, prompt string) {
43+ d.active = true
44+ d.current = current
45+ d.query = ""
46+ d.reason = reason
47+ d.prompt = prompt
48+ d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel)
49+
50+ all := provider.Active()
51+ provSet := map[string]bool{}
52+ for _, p := range loggedInProviders {
53+ provSet[p] = true
54+ }
55+ var filtered []provider.Model
56+ for _, m := range all {
57+ if !provSet[m.Provider] {
58+ continue
59+ }
60+ // Drop the exact failed pair so users can't retry on the
61+ // model that just failed.
62+ if m.Provider == failedProvider && m.ID == failedModel {
63+ continue
64+ }
65+ // Drop the currently-active model id (if it differs from
66+ // the failed one for some reason). The picker is meant to
67+ // help the user move *off* the broken pair.
68+ if m.ID == current {
69+ continue
70+ }
71+ filtered = append(filtered, m)
72+ }
73+ d.all = sortedModels(filtered)
74+ d.refilter()
75+}
76+
77+func (d *rescueDialog) Close() { d.active = false }
78+func (d *rescueDialog) Active() bool { return d != nil && d.active }
79+
80+func (d *rescueDialog) refilter() {
81+ needle := normalizeModelQuery(d.query)
82+ if needle == "" {
83+ d.view = append([]provider.Model(nil), d.all...)
84+ } else {
85+ out := make([]provider.Model, 0, len(d.all))
86+ for _, m := range d.all {
87+ if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
88+ out = append(out, m)
89+ }
90+ }
91+ d.view = out
92+ }
93+ d.cursor = 0
94+}
95+
96+// Render mirrors modelDialog so a rescue prompt feels identical to
97+// every other picker in the TUI.
98+func (d *rescueDialog) Render(th tui.Theme, width int) []string {
99+ if !d.Active() {
100+ return nil
101+ }
102+ var lines []string
103+ header := "rescue turn"
104+ if d.failedAt != "" && d.failedAt != "/" {
105+ header = "rescue turn — " + d.failedAt + " failed"
106+ }
107+ lines = append(lines, frameHeader(th, header, width))
108+
109+ if d.reason != "" {
110+ lines = append(lines, th.FG256(th.Warning, " "+d.reason))
111+ }
112+
113+ hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
114+ if d.query != "" {
115+ hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
116+ if len(d.view) != 1 {
117+ hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
118+ }
119+ } else {
120+ hint += " - type to filter"
121+ }
122+ lines = append(lines, th.FG256(th.Muted, hint))
123+
124+ if len(d.view) == 0 {
125+ if len(d.all) == 0 {
126+ lines = append(lines, th.FG256(th.Muted, " no other models available — log in to another provider with /login"))
127+ } else {
128+ lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129+ }
130+ lines = append(lines, frameRule(th, width))
131+ return lines
132+ }
133+
134+ const visible = 12
135+ start := 0
136+ end := len(d.view)
137+ if end > visible {
138+ start = d.cursor - visible/2
139+ if start < 0 {
140+ start = 0
141+ }
142+ if start+visible > end {
143+ start = end - visible
144+ }
145+ end = start + visible
146+ }
147+ for i := start; i < end; i++ {
148+ m := d.view[i]
149+ reason := " "
150+ if m.Reasoning {
151+ reason = "✦"
152+ }
153+ tag := ""
154+ switch {
155+ case m.Speculative:
156+ tag = "[speculative] "
157+ case m.Source == "live":
158+ tag = "[live] "
159+ }
160+ plain := fmt.Sprintf(" %-10s %-28s %s %s%s", m.Provider, m.ID, reason, tag, m.DisplayName)
161+ if i == d.cursor {
162+ lines = append(lines, th.PadHighlight(plain, width))
163+ } else {
164+ lines = append(lines, th.FG256(th.Muted, plain))
165+ }
166+ }
167+ if start > 0 {
168+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
169+ }
170+ if end < len(d.view) {
171+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
172+ }
173+
174+ lines = append(lines, frameRule(th, width))
175+ return lines
176+}
177+
178+func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
179+ switch k.Kind {
180+ case tui.KeyUp:
181+ if d.cursor > 0 {
182+ d.cursor--
183+ }
184+ case tui.KeyDown:
185+ if d.cursor < len(d.view)-1 {
186+ d.cursor++
187+ }
188+ case tui.KeyBackspace:
189+ if len(d.query) > 0 {
190+ r := []rune(d.query)
191+ d.query = string(r[:len(r)-1])
192+ d.refilter()
193+ }
194+ case tui.KeyRune:
195+ if k.Alt || k.Ctrl {
196+ break
197+ }
198+ if k.Rune >= 0x20 && k.Rune < 0x7f {
199+ d.query += string(k.Rune)
200+ d.refilter()
201+ }
202+ case tui.KeyEsc:
203+ d.Close()
204+ return rescueDialogAction{Close: true}
205+ case tui.KeyEnter:
206+ if len(d.view) == 0 {
207+ d.Close()
208+ return rescueDialogAction{Close: true}
209+ }
210+ m := d.view[d.cursor]
211+ prompt := d.prompt
212+ d.Close()
213+ return rescueDialogAction{Select: true, Provider: m.Provider, Model: m.ID, Prompt: prompt}
214+ }
215+ return rescueDialogAction{}
216+}
217+
218+// classifyRescueError inspects an agent error and decides whether
219+// it's a recoverable provider failure that the user should be offered
220+// a rescue picker for, plus a short human-readable reason. Returns
221+// (false, "") for errors we should NOT auto-rescue (bad request,
222+// context length, transcript serialization issues, etc.) so the
223+// regular red status banner still surfaces them.
224+func classifyRescueError(err error) (bool, string) {
225+ if err == nil {
226+ return false, ""
227+ }
228+ msg := err.Error()
229+ low := strings.ToLower(msg)
230+
231+ // Don't trigger on payload-too-large; that path already has its
232+ // own auto-compact handling.
233+ if isPayloadTooLargeError(err) {
234+ return false, ""
235+ }
236+
237+ // Network failures.
238+ if strings.Contains(low, "timeout") ||
239+ strings.Contains(low, "deadline exceeded") ||
240+ strings.Contains(low, "connection refused") ||
241+ strings.Contains(low, "connection reset") ||
242+ strings.Contains(low, "no such host") ||
243+ strings.Contains(low, "tls handshake") ||
244+ strings.Contains(low, "eof") {
245+ return true, "network failure: " + shortError(msg)
246+ }
247+
248+ switch {
249+ case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"):
250+ return true, "authentication failed: " + shortError(msg)
251+ case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"):
252+ return true, "permission denied: " + shortError(msg)
253+ case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"):
254+ return true, "rate limited: " + shortError(msg)
255+ case containsAny(low, "http 500", "http 502", "http 503", "http 504", " 500:", " 502:", " 503:", " 504:", "upstream connect error", "service unavailable", "internal server error", "bad gateway", "gateway timeout"):
256+ return true, "provider unavailable: " + shortError(msg)
257+ }
258+
259+ // Anything else (400 bad request, validation errors, etc.) is
260+ // usually not fixed by switching models; let it surface as-is.
261+ return false, ""
262+}
263+
264+func containsAny(haystack string, needles ...string) bool {
265+ for _, n := range needles {
266+ if strings.Contains(haystack, n) {
267+ return true
268+ }
269+ }
270+ return false
271+}
272+
273+// shortError trims a long http-payload error to something readable in
274+// the rescue dialog header without dropping the most useful prefix.
275+func shortError(msg string) string {
276+ msg = strings.TrimSpace(msg)
277+ const max = 140
278+ if len(msg) <= max {
279+ return msg
280+ }
281+ return msg[:max] + "…"
282+}
283+
284+// extractFailedProvider tries to pull the failing provider name out
285+// of an error string ("kimi: http 401: ...", "openai: http 503: ...").
286+// Returns "" when nothing recognisable is found.
287+func extractFailedProvider(err error) string {
288+ if err == nil {
289+ return ""
290+ }
291+ msg := err.Error()
292+ if i := strings.Index(msg, ":"); i > 0 {
293+ head := strings.TrimSpace(msg[:i])
294+ switch head {
295+ case "anthropic", "openai", "openai-codex", "kimi", "ollama":
296+ return head
297+ }
298+ }
299+ return ""
300+}
@@ -0,0 +1,300 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/internal/provider"
8+ "github.com/patriceckhart/zot/internal/tui"
9+)
10+
11+// rescueDialog offers a quick model swap when the active turn fails
12+// with a recoverable provider error (auth/rate/temporary). It looks
13+// and behaves like modelDialog: vertical list of available models,
14+// up/down to select, enter to retry, esc to cancel. The candidate
15+// list is built dynamically from the providers the user is currently
16+// logged in to — no static fallback config.
17+type rescueDialog struct {
18+ active bool
19+ all []provider.Model // candidates, sorted
20+ view []provider.Model // filtered view (typed query)
21+ cursor int
22+ current string // currently active model id (excluded from view)
23+ query string
24+ failedAt string // failed provider/model pair, e.g. "kimi/kimi-for-coding"
25+ reason string // short human-readable reason ("token expired", "rate limited", ...)
26+ prompt string // the user prompt that should be retried on Select
27+}
28+
29+type rescueDialogAction struct {
30+ Select bool
31+ Provider string
32+ Model string
33+ Prompt string
34+ Close bool
35+}
36+
37+func newRescueDialog() *rescueDialog { return &rescueDialog{} }
38+
39+// Open shows the dialog. current is the currently active model id so
40+// it can be excluded from the candidate list. loggedInProviders is
41+// the set of provider names with usable credentials right now.
42+func (d *rescueDialog) Open(current string, loggedInProviders []string, failedProvider, failedModel, reason, prompt string) {
43+ d.active = true
44+ d.current = current
45+ d.query = ""
46+ d.reason = reason
47+ d.prompt = prompt
48+ d.failedAt = strings.TrimSpace(failedProvider + "/" + failedModel)
49+
50+ all := provider.Active()
51+ provSet := map[string]bool{}
52+ for _, p := range loggedInProviders {
53+ provSet[p] = true
54+ }
55+ var filtered []provider.Model
56+ for _, m := range all {
57+ if !provSet[m.Provider] {
58+ continue
59+ }
60+ // Drop the exact failed pair so users can't retry on the
61+ // model that just failed.
62+ if m.Provider == failedProvider && m.ID == failedModel {
63+ continue
64+ }
65+ // Drop the currently-active model id (if it differs from
66+ // the failed one for some reason). The picker is meant to
67+ // help the user move *off* the broken pair.
68+ if m.ID == current {
69+ continue
70+ }
71+ filtered = append(filtered, m)
72+ }
73+ d.all = sortedModels(filtered)
74+ d.refilter()
75+}
76+
77+func (d *rescueDialog) Close() { d.active = false }
78+func (d *rescueDialog) Active() bool { return d != nil && d.active }
79+
80+func (d *rescueDialog) refilter() {
81+ needle := normalizeModelQuery(d.query)
82+ if needle == "" {
83+ d.view = append([]provider.Model(nil), d.all...)
84+ } else {
85+ out := make([]provider.Model, 0, len(d.all))
86+ for _, m := range d.all {
87+ if strings.Contains(normalizeModelQuery(m.Provider+" "+m.ID+" "+m.DisplayName), needle) {
88+ out = append(out, m)
89+ }
90+ }
91+ d.view = out
92+ }
93+ d.cursor = 0
94+}
95+
96+// Render mirrors modelDialog so a rescue prompt feels identical to
97+// every other picker in the TUI.
98+func (d *rescueDialog) Render(th tui.Theme, width int) []string {
99+ if !d.Active() {
100+ return nil
101+ }
102+ var lines []string
103+ header := "rescue turn"
104+ if d.failedAt != "" && d.failedAt != "/" {
105+ header = "rescue turn — " + d.failedAt + " failed"
106+ }
107+ lines = append(lines, frameHeader(th, header, width))
108+
109+ if d.reason != "" {
110+ lines = append(lines, th.FG256(th.Warning, " "+d.reason))
111+ }
112+
113+ hint := "retry this turn with another model (↑/↓, enter, esc to cancel)"
114+ if d.query != "" {
115+ hint = fmt.Sprintf("filter: %s (%d match)", d.query, len(d.view))
116+ if len(d.view) != 1 {
117+ hint = fmt.Sprintf("filter: %s (%d matches)", d.query, len(d.view))
118+ }
119+ } else {
120+ hint += " - type to filter"
121+ }
122+ lines = append(lines, th.FG256(th.Muted, hint))
123+
124+ if len(d.view) == 0 {
125+ if len(d.all) == 0 {
126+ lines = append(lines, th.FG256(th.Muted, " no other models available — log in to another provider with /login"))
127+ } else {
128+ lines = append(lines, th.FG256(th.Muted, " no models match "+fmt.Sprintf("%q", d.query)))
129+ }
130+ lines = append(lines, frameRule(th, width))
131+ return lines
132+ }
133+
134+ const visible = 12
135+ start := 0
136+ end := len(d.view)
137+ if end > visible {
138+ start = d.cursor - visible/2
139+ if start < 0 {
140+ start = 0
141+ }
142+ if start+visible > end {
143+ start = end - visible
144+ }
145+ end = start + visible
146+ }
147+ for i := start; i < end; i++ {
148+ m := d.view[i]
149+ reason := " "
150+ if m.Reasoning {
151+ reason = "✦"
152+ }
153+ tag := ""
154+ switch {
155+ case m.Speculative:
156+ tag = "[speculative] "
157+ case m.Source == "live":
158+ tag = "[live] "
159+ }
160+ plain := fmt.Sprintf(" %-10s %-28s %s %s%s", m.Provider, m.ID, reason, tag, m.DisplayName)
161+ if i == d.cursor {
162+ lines = append(lines, th.PadHighlight(plain, width))
163+ } else {
164+ lines = append(lines, th.FG256(th.Muted, plain))
165+ }
166+ }
167+ if start > 0 {
168+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start)))
169+ }
170+ if end < len(d.view) {
171+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.view)-end)))
172+ }
173+
174+ lines = append(lines, frameRule(th, width))
175+ return lines
176+}
177+
178+func (d *rescueDialog) HandleKey(k tui.Key) rescueDialogAction {
179+ switch k.Kind {
180+ case tui.KeyUp:
181+ if d.cursor > 0 {
182+ d.cursor--
183+ }
184+ case tui.KeyDown:
185+ if d.cursor < len(d.view)-1 {
186+ d.cursor++
187+ }
188+ case tui.KeyBackspace:
189+ if len(d.query) > 0 {
190+ r := []rune(d.query)
191+ d.query = string(r[:len(r)-1])
192+ d.refilter()
193+ }
194+ case tui.KeyRune:
195+ if k.Alt || k.Ctrl {
196+ break
197+ }
198+ if k.Rune >= 0x20 && k.Rune < 0x7f {
199+ d.query += string(k.Rune)
200+ d.refilter()
201+ }
202+ case tui.KeyEsc:
203+ d.Close()
204+ return rescueDialogAction{Close: true}
205+ case tui.KeyEnter:
206+ if len(d.view) == 0 {
207+ d.Close()
208+ return rescueDialogAction{Close: true}
209+ }
210+ m := d.view[d.cursor]
211+ prompt := d.prompt
212+ d.Close()
213+ return rescueDialogAction{Select: true, Provider: m.Provider, Model: m.ID, Prompt: prompt}
214+ }
215+ return rescueDialogAction{}
216+}
217+
218+// classifyRescueError inspects an agent error and decides whether
219+// it's a recoverable provider failure that the user should be offered
220+// a rescue picker for, plus a short human-readable reason. Returns
221+// (false, "") for errors we should NOT auto-rescue (bad request,
222+// context length, transcript serialization issues, etc.) so the
223+// regular red status banner still surfaces them.
224+func classifyRescueError(err error) (bool, string) {
225+ if err == nil {
226+ return false, ""
227+ }
228+ msg := err.Error()
229+ low := strings.ToLower(msg)
230+
231+ // Don't trigger on payload-too-large; that path already has its
232+ // own auto-compact handling.
233+ if isPayloadTooLargeError(err) {
234+ return false, ""
235+ }
236+
237+ // Network failures.
238+ if strings.Contains(low, "timeout") ||
239+ strings.Contains(low, "deadline exceeded") ||
240+ strings.Contains(low, "connection refused") ||
241+ strings.Contains(low, "connection reset") ||
242+ strings.Contains(low, "no such host") ||
243+ strings.Contains(low, "tls handshake") ||
244+ strings.Contains(low, "eof") {
245+ return true, "network failure: " + shortError(msg)
246+ }
247+
248+ switch {
249+ case containsAny(low, "http 401", " 401:", "invalid_authentication", "token expired", "api key appears to be invalid"):
250+ return true, "authentication failed: " + shortError(msg)
251+ case containsAny(low, "http 403", " 403:", "permission denied", "forbidden"):
252+ return true, "permission denied: " + shortError(msg)
253+ case containsAny(low, "http 429", " 429:", "rate limit", "rate_limit", "too many requests", "quota"):
254+ return true, "rate limited: " + shortError(msg)
255+ case containsAny(low, "http 500", "http 502", "http 503", "http 504", " 500:", " 502:", " 503:", " 504:", "upstream connect error", "service unavailable", "internal server error", "bad gateway", "gateway timeout"):
256+ return true, "provider unavailable: " + shortError(msg)
257+ }
258+
259+ // Anything else (400 bad request, validation errors, etc.) is
260+ // usually not fixed by switching models; let it surface as-is.
261+ return false, ""
262+}
263+
264+func containsAny(haystack string, needles ...string) bool {
265+ for _, n := range needles {
266+ if strings.Contains(haystack, n) {
267+ return true
268+ }
269+ }
270+ return false
271+}
272+
273+// shortError trims a long http-payload error to something readable in
274+// the rescue dialog header without dropping the most useful prefix.
275+func shortError(msg string) string {
276+ msg = strings.TrimSpace(msg)
277+ const max = 140
278+ if len(msg) <= max {
279+ return msg
280+ }
281+ return msg[:max] + "…"
282+}
283+
284+// extractFailedProvider tries to pull the failing provider name out
285+// of an error string ("kimi: http 401: ...", "openai: http 503: ...").
286+// Returns "" when nothing recognisable is found.
287+func extractFailedProvider(err error) string {
288+ if err == nil {
289+ return ""
290+ }
291+ msg := err.Error()
292+ if i := strings.Index(msg, ":"); i > 0 {
293+ head := strings.TrimSpace(msg[:i])
294+ switch head {
295+ case "anthropic", "openai", "openai-codex", "kimi", "ollama":
296+ return head
297+ }
298+ }
299+ return ""
300+}
M internal/provider/anthropic.go +25 -22
@@ -453,29 +453,32 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
453453 }
454454 }
455455
456- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
457- if err != nil {
458- return nil, err
456+ newReq := func() (*http.Request, error) {
457+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
458+ if err != nil {
459+ return nil, err
460+ }
461+ httpReq.Header.Set("content-type", "application/json")
462+ httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
463+ if c.oauthTok != "" {
464+ // Claude-Code-shaped request: identical headers and values as the
465+ // official CLI. Any drift triggers Anthropic's anti-abuse check and
466+ // rate-limits (or outright blocks) the request.
467+ httpReq.Header.Set("accept", "application/json")
468+ httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
469+ httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
470+ httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
471+ httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion)
472+ httpReq.Header.Set("x-app", "cli")
473+ // Remove x-api-key entirely by NOT setting it.
474+ } else {
475+ httpReq.Header.Set("accept", "text/event-stream")
476+ httpReq.Header.Set("x-api-key", c.apiKey)
477+ }
478+ return httpReq, nil
459479 }
460- httpReq.Header.Set("content-type", "application/json")
461- httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
462- if c.oauthTok != "" {
463- // Claude-Code-shaped request: identical headers and values as the
464- // official CLI. Any drift triggers Anthropic's anti-abuse check and
465- // rate-limits (or outright blocks) the request.
466- httpReq.Header.Set("accept", "application/json")
467- httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
468- httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
469- httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
470- httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion)
471- httpReq.Header.Set("x-app", "cli")
472- // Remove x-api-key entirely by NOT setting it.
473- } else {
474- httpReq.Header.Set("accept", "text/event-stream")
475- httpReq.Header.Set("x-api-key", c.apiKey)
476- }
477-
478- resp, err := c.http.Do(httpReq)
480+
481+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
479482 if err != nil {
480483 return nil, fmt.Errorf("anthropic: %w", err)
481484 }
@@ -453,29 +453,32 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
453 }453 }
454 }454 }
455 455
456- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))456+ newReq := func() (*http.Request, error) {
457- if err != nil {457+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
458- return nil, err458+ if err != nil {
459+ return nil, err
460+ }
461+ httpReq.Header.Set("content-type", "application/json")
462+ httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
463+ if c.oauthTok != "" {
464+ // Claude-Code-shaped request: identical headers and values as the
465+ // official CLI. Any drift triggers Anthropic's anti-abuse check and
466+ // rate-limits (or outright blocks) the request.
467+ httpReq.Header.Set("accept", "application/json")
468+ httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
469+ httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
470+ httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
471+ httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion)
472+ httpReq.Header.Set("x-app", "cli")
473+ // Remove x-api-key entirely by NOT setting it.
474+ } else {
475+ httpReq.Header.Set("accept", "text/event-stream")
476+ httpReq.Header.Set("x-api-key", c.apiKey)
477+ }
478+ return httpReq, nil
459 }479 }
460- httpReq.Header.Set("content-type", "application/json")480+
461- httpReq.Header.Set("anthropic-version", anthropicAPIVersion)481+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
462- if c.oauthTok != "" {
463- // Claude-Code-shaped request: identical headers and values as the
464- // official CLI. Any drift triggers Anthropic's anti-abuse check and
465- // rate-limits (or outright blocks) the request.
466- httpReq.Header.Set("accept", "application/json")
467- httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
468- httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
469- httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
470- httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion)
471- httpReq.Header.Set("x-app", "cli")
472- // Remove x-api-key entirely by NOT setting it.
473- } else {
474- httpReq.Header.Set("accept", "text/event-stream")
475- httpReq.Header.Set("x-api-key", c.apiKey)
476- }
477-
478- resp, err := c.http.Do(httpReq)
479 if err != nil {482 if err != nil {
480 return nil, fmt.Errorf("anthropic: %w", err)483 return nil, fmt.Errorf("anthropic: %w", err)
481 }484 }
M internal/provider/openai.go +13 -10
@@ -354,18 +354,21 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
354354 if err != nil {
355355 return nil, err
356356 }
357- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))
358- if err != nil {
359- return nil, err
360- }
361- httpReq.Header.Set("content-type", "application/json")
362- httpReq.Header.Set("accept", "text/event-stream")
363- httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
364- for k, v := range c.headers {
365- httpReq.Header.Set(k, v)
357+ newReq := func() (*http.Request, error) {
358+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))
359+ if err != nil {
360+ return nil, err
361+ }
362+ httpReq.Header.Set("content-type", "application/json")
363+ httpReq.Header.Set("accept", "text/event-stream")
364+ httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
365+ for k, v := range c.headers {
366+ httpReq.Header.Set(k, v)
367+ }
368+ return httpReq, nil
366369 }
367370
368- resp, err := c.http.Do(httpReq)
371+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
369372 if err != nil {
370373 return nil, fmt.Errorf("%s: %w", c.Name(), err)
371374 }
@@ -354,18 +354,21 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
354 if err != nil {354 if err != nil {
355 return nil, err355 return nil, err
356 }356 }
357- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))357+ newReq := func() (*http.Request, error) {
358- if err != nil {358+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body))
359- return nil, err359+ if err != nil {
360- }360+ return nil, err
361- httpReq.Header.Set("content-type", "application/json")361+ }
362- httpReq.Header.Set("accept", "text/event-stream")362+ httpReq.Header.Set("content-type", "application/json")
363- httpReq.Header.Set("authorization", "Bearer "+c.apiKey)363+ httpReq.Header.Set("accept", "text/event-stream")
364- for k, v := range c.headers {364+ httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
365- httpReq.Header.Set(k, v)365+ for k, v := range c.headers {
366+ httpReq.Header.Set(k, v)
367+ }
368+ return httpReq, nil
366 }369 }
367 370
368- resp, err := c.http.Do(httpReq)371+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
369 if err != nil {372 if err != nil {
370 return nil, fmt.Errorf("%s: %w", c.Name(), err)373 return nil, fmt.Errorf("%s: %w", c.Name(), err)
371 }374 }
M internal/provider/openai_codex.go +15 -12
@@ -290,19 +290,22 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
290290 return nil, err
291291 }
292292
293- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
294- if err != nil {
295- return nil, err
293+ newReq := func() (*http.Request, error) {
294+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
295+ if err != nil {
296+ return nil, err
297+ }
298+ httpReq.Header.Set("content-type", "application/json")
299+ httpReq.Header.Set("accept", "text/event-stream")
300+ httpReq.Header.Set("authorization", "Bearer "+c.token)
301+ httpReq.Header.Set("chatgpt-account-id", c.accountID)
302+ httpReq.Header.Set("openai-beta", "responses=experimental")
303+ httpReq.Header.Set("originator", "zot")
304+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
305+ return httpReq, nil
296306 }
297- httpReq.Header.Set("content-type", "application/json")
298- httpReq.Header.Set("accept", "text/event-stream")
299- httpReq.Header.Set("authorization", "Bearer "+c.token)
300- httpReq.Header.Set("chatgpt-account-id", c.accountID)
301- httpReq.Header.Set("openai-beta", "responses=experimental")
302- httpReq.Header.Set("originator", "zot")
303- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
304-
305- resp, err := c.http.Do(httpReq)
307+
308+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
306309 if err != nil {
307310 return nil, fmt.Errorf("openai-codex: %w", err)
308311 }
@@ -290,19 +290,22 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
290 return nil, err290 return nil, err
291 }291 }
292 292
293- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))293+ newReq := func() (*http.Request, error) {
294- if err != nil {294+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
295- return nil, err295+ if err != nil {
296+ return nil, err
297+ }
298+ httpReq.Header.Set("content-type", "application/json")
299+ httpReq.Header.Set("accept", "text/event-stream")
300+ httpReq.Header.Set("authorization", "Bearer "+c.token)
301+ httpReq.Header.Set("chatgpt-account-id", c.accountID)
302+ httpReq.Header.Set("openai-beta", "responses=experimental")
303+ httpReq.Header.Set("originator", "zot")
304+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
305+ return httpReq, nil
296 }306 }
297- httpReq.Header.Set("content-type", "application/json")307+
298- httpReq.Header.Set("accept", "text/event-stream")308+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
299- httpReq.Header.Set("authorization", "Bearer "+c.token)
300- httpReq.Header.Set("chatgpt-account-id", c.accountID)
301- httpReq.Header.Set("openai-beta", "responses=experimental")
302- httpReq.Header.Set("originator", "zot")
303- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
304-
305- resp, err := c.http.Do(httpReq)
306 if err != nil {309 if err != nil {
307 return nil, fmt.Errorf("openai-codex: %w", err)310 return nil, fmt.Errorf("openai-codex: %w", err)
308 }311 }
A internal/provider/retry.go +158 -0
@@ -0,0 +1,158 @@
1+package provider
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "time"
11+)
12+
13+// streamRetryAttempts is the maximum number of silent retries for
14+// transient connect failures before the streaming request is opened.
15+// 2 retries (so up to 3 total attempts) covers the common case where
16+// the upstream provider's edge proxy briefly returns 502/503/504 or
17+// resets the connection before headers, without making the user wait
18+// noticeably long if the outage is real.
19+const streamRetryAttempts = 2
20+
21+// streamRetryBackoff returns the wait duration before retry attempt n
22+// (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer
23+// would feel like the agent froze; anything shorter starts hammering
24+// the provider when it's actually struggling.
25+func streamRetryBackoff(attempt int) time.Duration {
26+ switch attempt {
27+ case 1:
28+ return 250 * time.Millisecond
29+ default:
30+ return 750 * time.Millisecond
31+ }
32+}
33+
34+// doStreamWithRetry performs an HTTP request that begins a streaming
35+// response, with up to streamRetryAttempts silent retries for transient
36+// connect failures. Successful responses (status 200) and non-transient
37+// failures (4xx, malformed bodies, etc.) are returned immediately.
38+//
39+// Retries fire on:
40+// - net.Conn dial errors (connection refused, no such host, EOF
41+// before headers, "upstream connect error", "connection reset")
42+// - HTTP 502/503/504
43+//
44+// The handler is responsible for re-reading the request body each
45+// attempt; we re-create the request via newReq() because http.Request
46+// bodies are single-use. Callers in this package always Marshal a JSON
47+// body up-front, so newReq just rebuilds with bytes.NewReader on the
48+// captured payload.
49+//
50+// If every attempt fails, the last error/response is returned so the
51+// caller can format a normal error message. On context cancellation
52+// the loop bails out immediately with ctx.Err().
53+func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {
54+ var lastErr error
55+ for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
56+ if attempt > 0 {
57+ select {
58+ case <-ctx.Done():
59+ return nil, ctx.Err()
60+ case <-time.After(streamRetryBackoff(attempt)):
61+ }
62+ }
63+ req, err := newReq()
64+ if err != nil {
65+ return nil, err
66+ }
67+ resp, err := client.Do(req)
68+ if err != nil {
69+ lastErr = err
70+ if !isTransientConnectError(err) || ctx.Err() != nil {
71+ return nil, err
72+ }
73+ continue
74+ }
75+ if isTransientHTTPStatus(resp.StatusCode) {
76+ // Drain a bit of the body so we can include it in the
77+ // final error if every retry exhausts. We cap the read
78+ // at 4 KiB because edge proxies sometimes send pages.
79+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
80+ resp.Body.Close()
81+ lastErr = &transientHTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
82+ if attempt == streamRetryAttempts {
83+ // Wrap as a real *http.Response shape the caller
84+ // expects so it formats the error identically to a
85+ // non-retried failure.
86+ return synthesizeResponse(resp.StatusCode, body), nil
87+ }
88+ continue
89+ }
90+ return resp, nil
91+ }
92+ if lastErr == nil {
93+ lastErr = errors.New("retry loop exhausted")
94+ }
95+ return nil, lastErr
96+}
97+
98+// transientHTTPError is the placeholder error returned while we're
99+// still inside the retry loop. Callers never observe it directly;
100+// the loop converts the final exhausted attempt into a synthesized
101+// http.Response so the caller's existing "http NNN: body" formatting
102+// keeps working unchanged.
103+type transientHTTPError struct {
104+ Status int
105+ Body string
106+}
107+
108+func (e *transientHTTPError) Error() string { return "transient http error" }
109+
110+// synthesizeResponse rebuilds a closable response wrapping the captured
111+// body bytes so the caller's existing non-200 handling path keeps
112+// working without needing to know retries happened.
113+func synthesizeResponse(status int, body []byte) *http.Response {
114+ return &http.Response{
115+ StatusCode: status,
116+ Body: io.NopCloser(bytes.NewReader(body)),
117+ }
118+}
119+
120+// isTransientConnectError reports whether err looks like a transient
121+// network failure that's worth retrying. The classic "upstream connect
122+// error or disconnect/reset before headers" from edge proxies (Envoy,
123+// Cloudflare, GFE) shows up as several different concrete error types
124+// across HTTP/1, HTTP/2, and TLS handshakes — easier to match by
125+// substring on the rendered message than to enumerate every type.
126+func isTransientConnectError(err error) bool {
127+ if err == nil {
128+ return false
129+ }
130+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
131+ return false
132+ }
133+ msg := strings.ToLower(err.Error())
134+ return strings.Contains(msg, "connection reset") ||
135+ strings.Contains(msg, "connection refused") ||
136+ strings.Contains(msg, "broken pipe") ||
137+ strings.Contains(msg, "unexpected eof") ||
138+ strings.Contains(msg, "eof") ||
139+ strings.Contains(msg, "no such host") ||
140+ strings.Contains(msg, "tls handshake") ||
141+ strings.Contains(msg, "i/o timeout") ||
142+ strings.Contains(msg, "transport failure") ||
143+ strings.Contains(msg, "upstream connect error") ||
144+ strings.Contains(msg, "disconnect/reset before headers")
145+}
146+
147+// isTransientHTTPStatus reports whether a non-200 status code is
148+// likely transient (502/503/504). 5xx outside this range is surfaced
149+// immediately because it usually indicates a real backend bug.
150+func isTransientHTTPStatus(code int) bool {
151+ switch code {
152+ case http.StatusBadGateway,
153+ http.StatusServiceUnavailable,
154+ http.StatusGatewayTimeout:
155+ return true
156+ }
157+ return false
158+}
@@ -0,0 +1,158 @@
1+package provider
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "time"
11+)
12+
13+// streamRetryAttempts is the maximum number of silent retries for
14+// transient connect failures before the streaming request is opened.
15+// 2 retries (so up to 3 total attempts) covers the common case where
16+// the upstream provider's edge proxy briefly returns 502/503/504 or
17+// resets the connection before headers, without making the user wait
18+// noticeably long if the outage is real.
19+const streamRetryAttempts = 2
20+
21+// streamRetryBackoff returns the wait duration before retry attempt n
22+// (1-based). Short, fixed backoff: 250ms, then 750ms. Anything longer
23+// would feel like the agent froze; anything shorter starts hammering
24+// the provider when it's actually struggling.
25+func streamRetryBackoff(attempt int) time.Duration {
26+ switch attempt {
27+ case 1:
28+ return 250 * time.Millisecond
29+ default:
30+ return 750 * time.Millisecond
31+ }
32+}
33+
34+// doStreamWithRetry performs an HTTP request that begins a streaming
35+// response, with up to streamRetryAttempts silent retries for transient
36+// connect failures. Successful responses (status 200) and non-transient
37+// failures (4xx, malformed bodies, etc.) are returned immediately.
38+//
39+// Retries fire on:
40+// - net.Conn dial errors (connection refused, no such host, EOF
41+// before headers, "upstream connect error", "connection reset")
42+// - HTTP 502/503/504
43+//
44+// The handler is responsible for re-reading the request body each
45+// attempt; we re-create the request via newReq() because http.Request
46+// bodies are single-use. Callers in this package always Marshal a JSON
47+// body up-front, so newReq just rebuilds with bytes.NewReader on the
48+// captured payload.
49+//
50+// If every attempt fails, the last error/response is returned so the
51+// caller can format a normal error message. On context cancellation
52+// the loop bails out immediately with ctx.Err().
53+func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {
54+ var lastErr error
55+ for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
56+ if attempt > 0 {
57+ select {
58+ case <-ctx.Done():
59+ return nil, ctx.Err()
60+ case <-time.After(streamRetryBackoff(attempt)):
61+ }
62+ }
63+ req, err := newReq()
64+ if err != nil {
65+ return nil, err
66+ }
67+ resp, err := client.Do(req)
68+ if err != nil {
69+ lastErr = err
70+ if !isTransientConnectError(err) || ctx.Err() != nil {
71+ return nil, err
72+ }
73+ continue
74+ }
75+ if isTransientHTTPStatus(resp.StatusCode) {
76+ // Drain a bit of the body so we can include it in the
77+ // final error if every retry exhausts. We cap the read
78+ // at 4 KiB because edge proxies sometimes send pages.
79+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
80+ resp.Body.Close()
81+ lastErr = &transientHTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
82+ if attempt == streamRetryAttempts {
83+ // Wrap as a real *http.Response shape the caller
84+ // expects so it formats the error identically to a
85+ // non-retried failure.
86+ return synthesizeResponse(resp.StatusCode, body), nil
87+ }
88+ continue
89+ }
90+ return resp, nil
91+ }
92+ if lastErr == nil {
93+ lastErr = errors.New("retry loop exhausted")
94+ }
95+ return nil, lastErr
96+}
97+
98+// transientHTTPError is the placeholder error returned while we're
99+// still inside the retry loop. Callers never observe it directly;
100+// the loop converts the final exhausted attempt into a synthesized
101+// http.Response so the caller's existing "http NNN: body" formatting
102+// keeps working unchanged.
103+type transientHTTPError struct {
104+ Status int
105+ Body string
106+}
107+
108+func (e *transientHTTPError) Error() string { return "transient http error" }
109+
110+// synthesizeResponse rebuilds a closable response wrapping the captured
111+// body bytes so the caller's existing non-200 handling path keeps
112+// working without needing to know retries happened.
113+func synthesizeResponse(status int, body []byte) *http.Response {
114+ return &http.Response{
115+ StatusCode: status,
116+ Body: io.NopCloser(bytes.NewReader(body)),
117+ }
118+}
119+
120+// isTransientConnectError reports whether err looks like a transient
121+// network failure that's worth retrying. The classic "upstream connect
122+// error or disconnect/reset before headers" from edge proxies (Envoy,
123+// Cloudflare, GFE) shows up as several different concrete error types
124+// across HTTP/1, HTTP/2, and TLS handshakes — easier to match by
125+// substring on the rendered message than to enumerate every type.
126+func isTransientConnectError(err error) bool {
127+ if err == nil {
128+ return false
129+ }
130+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
131+ return false
132+ }
133+ msg := strings.ToLower(err.Error())
134+ return strings.Contains(msg, "connection reset") ||
135+ strings.Contains(msg, "connection refused") ||
136+ strings.Contains(msg, "broken pipe") ||
137+ strings.Contains(msg, "unexpected eof") ||
138+ strings.Contains(msg, "eof") ||
139+ strings.Contains(msg, "no such host") ||
140+ strings.Contains(msg, "tls handshake") ||
141+ strings.Contains(msg, "i/o timeout") ||
142+ strings.Contains(msg, "transport failure") ||
143+ strings.Contains(msg, "upstream connect error") ||
144+ strings.Contains(msg, "disconnect/reset before headers")
145+}
146+
147+// isTransientHTTPStatus reports whether a non-200 status code is
148+// likely transient (502/503/504). 5xx outside this range is surfaced
149+// immediately because it usually indicates a real backend bug.
150+func isTransientHTTPStatus(code int) bool {
151+ switch code {
152+ case http.StatusBadGateway,
153+ http.StatusServiceUnavailable,
154+ http.StatusGatewayTimeout:
155+ return true
156+ }
157+ return false
158+}
A internal/provider/retry_test.go +146 -0
@@ -0,0 +1,146 @@
1+package provider
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+)
11+
12+// TestOpenAIStreamRetriesOn503 verifies the OpenAI/Kimi stream client
13+// silently retries when the upstream returns 503 a couple of times,
14+// then succeeds. The user-visible error path should NOT fire because
15+// a later attempt landed on a 200.
16+func TestOpenAIStreamRetriesOn503(t *testing.T) {
17+ var attempts atomic.Int32
18+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ n := attempts.Add(1)
20+ if n < 3 {
21+ w.WriteHeader(http.StatusServiceUnavailable)
22+ _, _ = w.Write([]byte("upstream connect error"))
23+ return
24+ }
25+ w.Header().Set("content-type", "text/event-stream")
26+ w.WriteHeader(http.StatusOK)
27+ fl, _ := w.(http.Flusher)
28+ write := func(s string) {
29+ _, _ = w.Write([]byte(s))
30+ if fl != nil {
31+ fl.Flush()
32+ }
33+ }
34+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")
35+ write("data: [DONE]\n\n")
36+ }))
37+ defer srv.Close()
38+
39+ c := NewOpenAI("x", srv.URL)
40+ evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
41+ if err != nil {
42+ t.Fatalf("stream returned error: %v", err)
43+ }
44+ gotText := ""
45+ for ev := range evs {
46+ if td, ok := ev.(EventTextDelta); ok {
47+ gotText += td.Delta
48+ }
49+ }
50+ if gotText != "ok" {
51+ t.Fatalf("text=%q", gotText)
52+ }
53+ if got := attempts.Load(); got != 3 {
54+ t.Fatalf("attempts=%d want 3", got)
55+ }
56+}
57+
58+// TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when
59+// every retry attempt returns 503 the caller sees a normal "http 503"
60+// error, not a retry-internal placeholder. The body bytes must be
61+// preserved so the rescue picker / red banner can show them.
62+func TestOpenAIStreamSurfaces503AfterRetriesExhausted(t *testing.T) {
63+ var attempts atomic.Int32
64+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+ attempts.Add(1)
66+ w.WriteHeader(http.StatusServiceUnavailable)
67+ _, _ = w.Write([]byte("upstream connect error"))
68+ }))
69+ defer srv.Close()
70+
71+ c := NewOpenAI("x", srv.URL)
72+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
73+ if err == nil {
74+ t.Fatalf("expected error after exhausted retries")
75+ }
76+ if !strings.Contains(err.Error(), "503") {
77+ t.Fatalf("error %q should mention 503", err)
78+ }
79+ if got := attempts.Load(); got != streamRetryAttempts+1 {
80+ t.Fatalf("attempts=%d want %d", got, streamRetryAttempts+1)
81+ }
82+}
83+
84+// TestOpenAIStreamDoesNotRetryOn400 ensures non-transient errors are
85+// returned immediately without retrying. We don't want to amplify
86+// load on a provider that's actively rejecting our request shape.
87+func TestOpenAIStreamDoesNotRetryOn400(t *testing.T) {
88+ var attempts atomic.Int32
89+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90+ attempts.Add(1)
91+ w.WriteHeader(http.StatusBadRequest)
92+ _, _ = w.Write([]byte(`{"error":{"message":"nope"}}`))
93+ }))
94+ defer srv.Close()
95+
96+ c := NewOpenAI("x", srv.URL)
97+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
98+ if err == nil || !strings.Contains(err.Error(), "400") {
99+ t.Fatalf("want 400 err got %v", err)
100+ }
101+ if got := attempts.Load(); got != 1 {
102+ t.Fatalf("attempts=%d want 1", got)
103+ }
104+}
105+
106+func TestIsTransientHTTPStatus(t *testing.T) {
107+ for _, code := range []int{502, 503, 504} {
108+ if !isTransientHTTPStatus(code) {
109+ t.Errorf("%d should be transient", code)
110+ }
111+ }
112+ for _, code := range []int{200, 400, 401, 403, 404, 429, 500, 501} {
113+ if isTransientHTTPStatus(code) {
114+ t.Errorf("%d should NOT be transient", code)
115+ }
116+ }
117+}
118+
119+func TestIsTransientConnectError(t *testing.T) {
120+ good := []string{
121+ "read tcp: connection reset by peer",
122+ "dial tcp: connection refused",
123+ "unexpected EOF",
124+ "upstream connect error or disconnect/reset before headers",
125+ "tls handshake error: bad cert",
126+ "i/o timeout",
127+ }
128+ for _, m := range good {
129+ if !isTransientConnectError(stringErr(m)) {
130+ t.Errorf("%q should be transient", m)
131+ }
132+ }
133+ bad := []string{
134+ "context canceled",
135+ "json: cannot unmarshal",
136+ }
137+ for _, m := range bad {
138+ if isTransientConnectError(stringErr(m)) {
139+ t.Errorf("%q should NOT be transient", m)
140+ }
141+ }
142+}
143+
144+type stringErr string
145+
146+func (s stringErr) Error() string { return string(s) }
@@ -0,0 +1,146 @@
1+package provider
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+)
11+
12+// TestOpenAIStreamRetriesOn503 verifies the OpenAI/Kimi stream client
13+// silently retries when the upstream returns 503 a couple of times,
14+// then succeeds. The user-visible error path should NOT fire because
15+// a later attempt landed on a 200.
16+func TestOpenAIStreamRetriesOn503(t *testing.T) {
17+ var attempts atomic.Int32
18+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ n := attempts.Add(1)
20+ if n < 3 {
21+ w.WriteHeader(http.StatusServiceUnavailable)
22+ _, _ = w.Write([]byte("upstream connect error"))
23+ return
24+ }
25+ w.Header().Set("content-type", "text/event-stream")
26+ w.WriteHeader(http.StatusOK)
27+ fl, _ := w.(http.Flusher)
28+ write := func(s string) {
29+ _, _ = w.Write([]byte(s))
30+ if fl != nil {
31+ fl.Flush()
32+ }
33+ }
34+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")
35+ write("data: [DONE]\n\n")
36+ }))
37+ defer srv.Close()
38+
39+ c := NewOpenAI("x", srv.URL)
40+ evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
41+ if err != nil {
42+ t.Fatalf("stream returned error: %v", err)
43+ }
44+ gotText := ""
45+ for ev := range evs {
46+ if td, ok := ev.(EventTextDelta); ok {
47+ gotText += td.Delta
48+ }
49+ }
50+ if gotText != "ok" {
51+ t.Fatalf("text=%q", gotText)
52+ }
53+ if got := attempts.Load(); got != 3 {
54+ t.Fatalf("attempts=%d want 3", got)
55+ }
56+}
57+
58+// TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when
59+// every retry attempt returns 503 the caller sees a normal "http 503"
60+// error, not a retry-internal placeholder. The body bytes must be
61+// preserved so the rescue picker / red banner can show them.
62+func TestOpenAIStreamSurfaces503AfterRetriesExhausted(t *testing.T) {
63+ var attempts atomic.Int32
64+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+ attempts.Add(1)
66+ w.WriteHeader(http.StatusServiceUnavailable)
67+ _, _ = w.Write([]byte("upstream connect error"))
68+ }))
69+ defer srv.Close()
70+
71+ c := NewOpenAI("x", srv.URL)
72+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
73+ if err == nil {
74+ t.Fatalf("expected error after exhausted retries")
75+ }
76+ if !strings.Contains(err.Error(), "503") {
77+ t.Fatalf("error %q should mention 503", err)
78+ }
79+ if got := attempts.Load(); got != streamRetryAttempts+1 {
80+ t.Fatalf("attempts=%d want %d", got, streamRetryAttempts+1)
81+ }
82+}
83+
84+// TestOpenAIStreamDoesNotRetryOn400 ensures non-transient errors are
85+// returned immediately without retrying. We don't want to amplify
86+// load on a provider that's actively rejecting our request shape.
87+func TestOpenAIStreamDoesNotRetryOn400(t *testing.T) {
88+ var attempts atomic.Int32
89+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90+ attempts.Add(1)
91+ w.WriteHeader(http.StatusBadRequest)
92+ _, _ = w.Write([]byte(`{"error":{"message":"nope"}}`))
93+ }))
94+ defer srv.Close()
95+
96+ c := NewOpenAI("x", srv.URL)
97+ _, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
98+ if err == nil || !strings.Contains(err.Error(), "400") {
99+ t.Fatalf("want 400 err got %v", err)
100+ }
101+ if got := attempts.Load(); got != 1 {
102+ t.Fatalf("attempts=%d want 1", got)
103+ }
104+}
105+
106+func TestIsTransientHTTPStatus(t *testing.T) {
107+ for _, code := range []int{502, 503, 504} {
108+ if !isTransientHTTPStatus(code) {
109+ t.Errorf("%d should be transient", code)
110+ }
111+ }
112+ for _, code := range []int{200, 400, 401, 403, 404, 429, 500, 501} {
113+ if isTransientHTTPStatus(code) {
114+ t.Errorf("%d should NOT be transient", code)
115+ }
116+ }
117+}
118+
119+func TestIsTransientConnectError(t *testing.T) {
120+ good := []string{
121+ "read tcp: connection reset by peer",
122+ "dial tcp: connection refused",
123+ "unexpected EOF",
124+ "upstream connect error or disconnect/reset before headers",
125+ "tls handshake error: bad cert",
126+ "i/o timeout",
127+ }
128+ for _, m := range good {
129+ if !isTransientConnectError(stringErr(m)) {
130+ t.Errorf("%q should be transient", m)
131+ }
132+ }
133+ bad := []string{
134+ "context canceled",
135+ "json: cannot unmarshal",
136+ }
137+ for _, m := range bad {
138+ if isTransientConnectError(stringErr(m)) {
139+ t.Errorf("%q should NOT be transient", m)
140+ }
141+ }
142+}
143+
144+type stringErr string
145+
146+func (s stringErr) Error() string { return string(s) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close