Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
28 changes: 28 additions & 0 deletions internal/cli/auth_propagation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/providermodelcatalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
8 changes: 6 additions & 2 deletions internal/providermodelcatalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
}

Expand Down
38 changes: 37 additions & 1 deletion internal/providers/openai/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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.
Expand All @@ -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
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
37 changes: 37 additions & 0 deletions internal/providers/openai/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":`)
Expand Down
Loading