Files
zero/internal/cli/setup_test.go
ba99fa8d48 fix(provider): stop dropping custom no-auth providers on restart (#558)
* fix(provider): stop dropping custom no-auth providers on restart

Custom OpenAI/Anthropic-compatible catalog entries hardcode
RequiresAuth as a template default for the setup wizard, not a fact
about the user's actual endpoint. Leaving the credential step blank
(e.g. a local llama.cpp server) still stamped the saved profile with
a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same
static RequiresAuth flag then made the profile look like it was
missing a required credential on the next run, silently filtering it
out of /model.

Add a Custom flag to the catalog descriptor so both the wizard
(write side) and setupMissingCredentialEnv (read side) judge these
profiles by their own explicit APIKeyEnv instead of the catalog's
static default.

Fixes #555

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): self-heal legacy custom profiles stamped by the old wizard bug

CodeRabbit flagged that profiles saved before this fix could already
carry the phantom APIKeyEnv the old wizard stamped on every custom
provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would
keep treating any non-empty APIKeyEnv as an explicit requirement —
leaving already-affected users' providers filtered out of /model even
after upgrading.

Treat an APIKeyEnv that exactly matches the catalog's own guessed
default as indistinguishable from unset, so already-broken saved
profiles self-heal without a config.json migration. A deliberately
different env var name (e.g. via --api-key-env) still means auth is
required.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): centralize custom-endpoint credential logic, fix two more surfaces

Copilot's review of the earlier fix found the same RequiresAuth-as-
authoritative bug in two more places that decide whether a saved
provider needs a credential:

- validateProviderRuntimeReady (zero providers check) would hard-error
  a no-auth custom endpoint with "requires API key".
- providerWizardCredentialLabel and providerCredentialRequired (/providers
  status, wizard summary) would still claim/warn that one was needed.

Added config.ProviderProfile.MissingCredentialEnv as the single source
of truth for this decision (custom-endpoint self-heal included), and
had setupMissingCredentialEnv and validateProviderRuntimeReady delegate
to it. providerWizardCredentialLabel and providerCredentialRequired
take the same Custom-aware fix directly, since their non-catalog
fallback path uses runtime-resolved provider-kind metadata that
shouldn't be collapsed into the canonical helper.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(ci): request pinned Go toolchain directly

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-07-08 08:54:39 +08:00

322 lines
10 KiB
Go

package cli
import (
"context"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/tui"
)
func TestSetupMissingCredentialEnv(t *testing.T) {
tests := []struct {
name string
profile config.ProviderProfile
wantEnv string
want bool
}{
{
name: "catalog provider",
profile: config.ProviderProfile{
Name: "groq",
CatalogID: "groq",
},
wantEnv: "GROQ_API_KEY",
want: true,
},
{
name: "openai compatible without catalog",
profile: config.ProviderProfile{
Name: "custom",
ProviderKind: config.ProviderKindOpenAICompatible,
},
wantEnv: "OPENAI_API_KEY",
want: true,
},
{
name: "local provider",
profile: config.ProviderProfile{
Name: "local",
CatalogID: "ollama",
},
want: false,
},
{
name: "ollama cloud provider",
profile: config.ProviderProfile{
Name: "ollama-cloud",
CatalogID: "ollama-cloud",
},
wantEnv: "OLLAMA_API_KEY",
want: true,
},
{
name: "credential resolved",
profile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
APIKey: "sk-test",
},
want: false,
},
{
// Regression for issue #555: a custom OpenAI-compatible profile saved
// with no credential (e.g. a local llama.cpp server with no auth) must
// not be reported as missing one, or it gets filtered out of /model on
// the next run even though it was configured exactly as intended.
name: "custom openai compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
},
want: false,
},
{
name: "custom openai compatible with explicit api key env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "LLAMA_CPP_API_KEY",
},
wantEnv: "LLAMA_CPP_API_KEY",
want: true,
},
{
// Self-heal regression: profiles saved by the pre-fix wizard were
// stamped with the catalog's own guessed default (OPENAI_API_KEY)
// even when the endpoint needed no auth. That stale value must be
// treated the same as unset so already-broken saved profiles start
// working again without a config.json rewrite.
name: "custom openai compatible with stale legacy default env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "OPENAI_API_KEY",
},
want: false,
},
{
name: "custom anthropic compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-proxy",
CatalogID: "custom-anthropic-compatible",
BaseURL: "http://localhost:9000/anthropic",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotEnv, got := setupMissingCredentialEnv(tt.profile)
if got != tt.want || gotEnv != tt.wantEnv {
t.Fatalf("setupMissingCredentialEnv() = (%q, %v), want (%q, %v)", gotEnv, got, tt.wantEnv, tt.want)
}
})
}
}
func TestSetupProviderOptionsUseRuntimeSupportedCatalog(t *testing.T) {
options := setupProviderOptions()
got := make([]string, 0, len(options))
for _, option := range options {
got = append(got, option.ID)
}
want := make([]string, 0)
for _, descriptor := range providercatalog.All() {
if providercatalog.RuntimeSupported(descriptor) {
want = append(want, descriptor.ID)
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("setupProviderOptions IDs = %#v, want runtime-supported catalog IDs %#v", got, want)
}
for _, excluded := range []string{"bedrock", "vertex"} {
for _, id := range got {
if id == excluded {
t.Fatalf("setupProviderOptions included unsupported provider %q in %#v", excluded, got)
}
}
}
}
func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
// Use the encrypted-file backend in the temp config dir so the test never
// touches the real OS keychain.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}, tui.SetupSelection{
CatalogID: "ollama-cloud",
Model: "qwen3-coder:480b",
APIKey: "sk-pasted-secret",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.APIKey != "sk-pasted-secret" {
t.Fatalf("Provider.APIKey = %q, want pasted key", result.Provider.APIKey)
}
if result.Provider.APIKeyEnv != "" {
t.Fatalf("Provider.APIKeyEnv = %q, want empty when API key is pasted", result.Provider.APIKeyEnv)
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "ollama-cloud" {
t.Fatalf("ActiveProvider = %q, want ollama-cloud", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
// The capture flip: config.json must NOT hold the cleartext key — it carries the
// APIKeyStored marker, and the secret lives in the co-located credential store.
if cfg.Providers[0].APIKey != "" || cfg.Providers[0].APIKeyEnv != "" {
t.Fatalf("config must not persist the key: APIKey %q APIKeyEnv %q", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatal("expected APIKeyStored marker in config")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
if key, ok, _ := store.Get("ollama-cloud"); !ok || key != "sk-pasted-secret" {
t.Fatalf("stored key = %q,%v; want sk-pasted-secret in the credential store", key, ok)
}
}
func TestSaveSetupProviderStoresCustomEndpointSelection(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}, tui.SetupSelection{
CatalogID: "custom-openai-compatible",
Name: "minimax",
BaseURL: "https://api.minimax.io/v1",
Model: "MiniMax-M3",
}, setupSaveOptions{})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.Name != "minimax" {
t.Fatalf("Provider.Name = %q, want minimax", result.Provider.Name)
}
if result.Provider.BaseURL != "https://api.minimax.io/v1" {
t.Fatalf("Provider.BaseURL = %q, want custom endpoint", result.Provider.BaseURL)
}
if result.Provider.Model != "MiniMax-M3" {
t.Fatalf("Provider.Model = %q, want typed model", result.Provider.Model)
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "minimax" {
t.Fatalf("ActiveProvider = %q, want minimax", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "minimax" || profile.CatalogID != "custom-openai-compatible" {
t.Fatalf("stored provider identity = %#v, want minimax custom-openai-compatible", profile)
}
if profile.BaseURL != "https://api.minimax.io/v1" || profile.Model != "MiniMax-M3" {
t.Fatalf("stored provider endpoint/model = %#v", profile)
}
}
func TestSaveSetupProviderCLIOptionsOverrideCustomEndpointSelection(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
userConfigPath: func() (string, error) {
return configPath, nil
},
}, tui.SetupSelection{
CatalogID: "custom-openai-compatible",
Name: "selection-name",
BaseURL: "https://selection.example/v1",
Model: "selection-model",
}, setupSaveOptions{
name: "cli-name",
baseURL: "https://cli.example/v1",
})
if err != nil {
t.Fatalf("saveSetupProvider() error = %v", err)
}
if result.Provider.Name != "cli-name" {
t.Fatalf("Provider.Name = %q, want CLI name", result.Provider.Name)
}
if result.Provider.BaseURL != "https://cli.example/v1" {
t.Fatalf("Provider.BaseURL = %q, want CLI endpoint", result.Provider.BaseURL)
}
if result.Provider.Model != "selection-model" {
t.Fatalf("Provider.Model = %q, want selection model", result.Provider.Model)
}
cfg := readFileConfig(t, configPath)
if cfg.ActiveProvider != "cli-name" {
t.Fatalf("ActiveProvider = %q, want cli-name", cfg.ActiveProvider)
}
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
profile := cfg.Providers[0]
if profile.Name != "cli-name" || profile.BaseURL != "https://cli.example/v1" || profile.Model != "selection-model" {
t.Fatalf("stored provider = %#v, want CLI name/baseURL and selection model", profile)
}
}
func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) {
// AUDIT-M1: verifying a remote provider with no key must say "no API key found",
// not probe and report "the provider rejected the API key". The probe must not run.
probed := false
deps := appDeps{probeProviderHealth: func(context.Context, providerhealth.Options) providerhealth.Result {
probed = true
return providerhealth.Result{}
}}
_, err := verifySetupProvider(deps, config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4.1",
})
if err == nil || !strings.Contains(err.Error(), "no API key found") {
t.Fatalf("missing key should report 'no API key found', got %v", err)
}
if probed {
t.Fatal("must not probe the endpoint when no key is configured")
}
// A keyless LOCAL provider still probes (no key needed).
probed = false
_, _ = verifySetupProvider(deps, config.ProviderProfile{
Name: "ollama",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "http://localhost:11434/v1",
Model: "llama3",
})
if !probed {
t.Fatal("a keyless local provider should still be probed")
}
}