diff --git a/internal/cli/app.go b/internal/cli/app.go index 6395715ac..e8b479f98 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -554,6 +554,11 @@ func fillAppDeps(deps appDeps) appDeps { deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) { return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath)) } + baseProbeProviderHealth := deps.probeProviderHealth + deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result { + options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath) + return baseProbeProviderHealth(ctx, options) + } return deps } diff --git a/internal/cli/auth_propagation_test.go b/internal/cli/auth_propagation_test.go index 8c9375bf9..4d3634ff3 100644 --- a/internal/cli/auth_propagation_test.go +++ b/internal/cli/auth_propagation_test.go @@ -2,12 +2,14 @@ package cli import ( "bytes" + "context" "errors" "os" "path/filepath" "testing" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/providerhealth" "github.com/Gitlawb/zero/internal/zeroruntime" ) @@ -72,6 +74,32 @@ func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) { } } +// fillAppDeps also wraps probeProviderHealth, the code path zero providers +// check --connectivity, zero doctor --connectivity, and the TUI doctor panel +// all go through. Passing the raw resolved profile previously sent an +// unauthenticated (keyless) health probe for apiKeyStored profiles, same +// regression class as TestFillAppDepsWrapsNewProviderWithStoredKey but for +// health checks instead of the runtime provider build. +func TestFillAppDepsWrapsProbeProviderHealthWithStoredKey(t *testing.T) { + configPath := seedStoredProviderKey(t, "echo", "sk-stored-health") + + var captured config.ProviderProfile + deps := fillAppDeps(appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + probeProviderHealth: func(_ context.Context, options providerhealth.Options) providerhealth.Result { + captured = options.Profile + return providerhealth.Result{Status: providerhealth.StatusPass} + }, + }) + + deps.probeProviderHealth(context.Background(), providerhealth.Options{ + Profile: config.ProviderProfile{Name: "echo", APIKeyStored: true}, + }) + if captured.APIKey != "sk-stored-health" { + t.Fatalf("wrapped probeProviderHealth ran with APIKey = %q, want the stored key (keyless health-check regression)", captured.APIKey) + } +} + // buildProvider (the TUI/exec STARTUP construction site) must export // ZERO_PROVIDER so children spawned at any point in the run are pinned to the // parent's provider from launch — not only after an in-session switch. Without diff --git a/internal/providermodelcatalog/catalog.go b/internal/providermodelcatalog/catalog.go index a933b7172..7f214ca30 100644 --- a/internal/providermodelcatalog/catalog.go +++ b/internal/providermodelcatalog/catalog.go @@ -145,13 +145,20 @@ var curatedModels = map[string][]Model{ // upstreams expose, so users can also type an id the picker doesn't list. "gitlawb-opengateway": { {ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"}, + {ID: "xiaomi/mimo-v2.5-pro", Description: "Xiaomi MiMo V2.5 Pro"}, {ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"}, + {ID: "xiaomi/mimo-v2.5", Description: "multimodal model (Xiaomi)"}, {ID: "tencent/hy3", Description: "free Tencent HY3 model"}, {ID: "MiniMax-M3", Description: "MiniMax model"}, + {ID: "minimax/minimax-m3", Description: "MiniMax M3 model"}, {ID: "qwen-plus", Description: "Qwen model"}, + {ID: "qwen/qwen3.7-max", Description: "Qwen flagship coding model"}, {ID: "gemini-2.5-pro", Description: "long-context model (Google)"}, + {ID: "google/gemini-3.1-flash-lite", Description: "Google Gemini 3.1 Flash Lite"}, {ID: "glm-4.6", Description: "Z.ai model"}, + {ID: "z-ai/glm-5.2", Description: "GLM coding & reasoning model"}, {ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"}, + {ID: "nvidia/nemotron-3-ultra-550b-a55b:free", Description: "free Nemotron 3 Ultra reasoning MoE"}, }, "atomic-chat": { {ID: "gpt-4.1", Description: "catalog default"}, diff --git a/internal/providermodelcatalog/catalog_test.go b/internal/providermodelcatalog/catalog_test.go index ddd799910..17673249b 100644 --- a/internal/providermodelcatalog/catalog_test.go +++ b/internal/providermodelcatalog/catalog_test.go @@ -49,8 +49,12 @@ func TestModelsAreProviderScoped(t *testing.T) { }, { provider: "gitlawb-opengateway", - want: []string{"mimo-v2.5-pro", "tencent/hy3"}, - notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"}, + want: []string{ + "mimo-v2.5-pro", "tencent/hy3", + "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "minimax/minimax-m3", "qwen/qwen3.7-max", + "google/gemini-3.1-flash-lite", "z-ai/glm-5.2", "nvidia/nemotron-3-ultra-550b-a55b:free", + }, + notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"}, }, } diff --git a/internal/providers/openai/provider.go b/internal/providers/openai/provider.go index 06420e7a4..1e19eab01 100644 --- a/internal/providers/openai/provider.go +++ b/internal/providers/openai/provider.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "time" @@ -248,6 +249,24 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- } } +// openAIStreamErrorStatusByCode maps a streamed error payload's "code" field +// to the HTTP status classifiedError expects, covering both the numeric-string +// codes some providers send ("429") and the semantic string codes OpenAI- +// compatible providers commonly send instead (rate_limit_exceeded). Both +// forms of the same condition must classify identically, or retry/backoff +// logic downstream would only kick in for whichever form a given provider +// happens to use. insufficient_quota maps to 429 (Too Many Requests) to match +// OpenAI's own API, which returns that code with a 429 status when a caller +// has exceeded their billing quota. +var openAIStreamErrorStatusByCode = map[string]int{ + "429": http.StatusTooManyRequests, + "401": http.StatusUnauthorized, + "403": http.StatusForbidden, + "rate_limit_exceeded": http.StatusTooManyRequests, + "insufficient_quota": http.StatusTooManyRequests, + "invalid_api_key": http.StatusUnauthorized, +} + // emitPayload handles one accumulated SSE data payload ([DONE]/blank lines are // already filtered by the shared reader). It returns false to abort the stream // after emitting a terminal error. @@ -266,9 +285,26 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t if chunk.Error != nil { state.flushContent(ctx, events) state.closeOpen(ctx, events) + statusCode := http.StatusInternalServerError + if chunk.Error.Code != nil { + switch c := chunk.Error.Code.(type) { + case string: + if code, ok := openAIStreamErrorStatusByCode[c]; ok { + statusCode = code + } + case float64: + if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(int(c))]; ok { + statusCode = code + } + case int: + if code, ok := openAIStreamErrorStatusByCode[strconv.Itoa(c)]; ok { + statusCode = code + } + } + } sendEvent(ctx, events, zeroruntime.StreamEvent{ Type: zeroruntime.StreamEventError, - Error: provider.classifiedError(http.StatusInternalServerError, chunk.Error.Message), + Error: provider.classifiedError(statusCode, chunk.Error.Message), }) state.done = true return false diff --git a/internal/providers/openai/provider_test.go b/internal/providers/openai/provider_test.go index 0fc980558..2a473f51a 100644 --- a/internal/providers/openai/provider_test.go +++ b/internal/providers/openai/provider_test.go @@ -455,6 +455,43 @@ func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) { } } +func TestStreamCompletionClassifiesStreamErrorCode(t *testing.T) { + // The error arrives inside a 200 OK SSE payload's "code" field, not the + // HTTP status, so this exercises openAIStreamErrorStatusByCode directly: + // both the numeric-string codes some providers send and the semantic + // string codes OpenAI-compatible providers commonly send instead must + // classify identically. + cases := []struct { + name string + code string + wantPrefix string + }{ + {"numeric 429", `"429"`, "rate limit error:"}, + {"numeric 401", `"401"`, "auth error:"}, + {"numeric 403", `"403"`, "auth error:"}, + {"json number 429", `429`, "rate limit error:"}, + {"semantic rate_limit_exceeded", `"rate_limit_exceeded"`, "rate limit error:"}, + {"semantic insufficient_quota", `"insufficient_quota"`, "rate limit error:"}, + {"semantic invalid_api_key", `"invalid_api_key"`, "auth error:"}, + {"unknown code", `"server_error"`, "provider error:"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, `{"error":{"message":"failed","code":`+tc.code+`}}`) + }) + + events := collectProviderEvents(t, provider) + if len(events) != 1 || events[0].Type != zeroruntime.StreamEventError { + t.Fatalf("events = %#v, want one error", events) + } + if !strings.HasPrefix(events[0].Error, tc.wantPrefix) { + t.Fatalf("error = %q, want prefix %q", events[0].Error, tc.wantPrefix) + } + }) + } +} + func TestStreamCompletionEmitsErrorForMalformedJSON(t *testing.T) { provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { writeSSE(w, `{"choices":`)