mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03455ad915 | ||
|
|
0e7a1fc218 |
No files matched your search
+19
-5
@@ -402,6 +402,14 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
|||||||
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
// First-run users reach for `zero login`/`zero logout` (reported in the
|
||||||
|
// wild); point them at the real command instead of a bare usage pointer.
|
||||||
|
switch strings.ToLower(args[0]) {
|
||||||
|
case "login", "logout":
|
||||||
|
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -530,11 +538,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
|
|
||||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A resolve failure caused solely by a missing/unresolvable active provider
|
// A resolve failure the setup wizard can FIX is not fatal for the
|
||||||
// is not fatal for the interactive TUI: drop into the setup wizard with an
|
// interactive TUI: drop into the wizard with an empty config so the user
|
||||||
// empty config so the user can onboard, instead of exiting with an error.
|
// can onboard/repair, instead of exiting with an error they can only fix
|
||||||
// Any other error (malformed JSON, directory conflict, etc.) is still fatal.
|
// by hand-editing config.json. That covers a missing/unresolvable active
|
||||||
if !errors.Is(err, config.ErrNoActiveProvider) {
|
// provider and an active provider without a model (custom endpoints have
|
||||||
|
// no catalog default) — previously the second shape bricked bare `zero`
|
||||||
|
// and `zero setup`, the exact commands that could have fixed it. Any
|
||||||
|
// other error (malformed JSON, directory conflict, etc.) is still fatal,
|
||||||
|
// and headless commands (zero config / zero exec) still fail with the
|
||||||
|
// actionable message.
|
||||||
|
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||||
return writeAppError(stderr, err.Error(), 1)
|
return writeAppError(stderr, err.Error(), 1)
|
||||||
}
|
}
|
||||||
resolved = config.ResolvedConfig{}
|
resolved = config.ResolvedConfig{}
|
||||||
|
|||||||
@@ -1476,3 +1476,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
|||||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A custom endpoint saved without a model previously bricked bare `zero` and
|
||||||
|
// `zero setup` — the exact commands that could have fixed it (the resolve
|
||||||
|
// error escaped before the wizard could open). The interactive TUI now treats
|
||||||
|
// the requires-model failure as "needs onboarding", same as a missing active
|
||||||
|
// provider. The error comes from a REAL Resolve over a real config file, so
|
||||||
|
// this exercises the production sentinel wrapping, not a hand-built error.
|
||||||
|
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cwd := t.TempDir()
|
||||||
|
setCLIUserConfigRoot(t)
|
||||||
|
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
if err := os.WriteFile(brokenConfig, []byte(`{
|
||||||
|
"activeProvider": "gw",
|
||||||
|
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||||
|
}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
launched := false
|
||||||
|
var launchedOptions tui.Options
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||||
|
getwd: func() (string, error) { return cwd, nil },
|
||||||
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
||||||
|
},
|
||||||
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||||
|
t.Fatal("newProvider must not be called without a resolved provider")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
||||||
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||||
|
return noopMCPRuntime{}, nil
|
||||||
|
},
|
||||||
|
runTUI: func(_ context.Context, options tui.Options) int {
|
||||||
|
launched = true
|
||||||
|
launchedOptions = options
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
if !launched {
|
||||||
|
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
||||||
|
}
|
||||||
|
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||||
|
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
||||||
|
// users try them (reported in the wild), so the unknown-command error points at
|
||||||
|
// the real command.
|
||||||
|
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
||||||
|
if exitCode != 2 {
|
||||||
|
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
||||||
|
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
||||||
|
}
|
||||||
|
// An unrelated unknown command gets no misleading suggestion.
|
||||||
|
stderr.Reset()
|
||||||
|
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
||||||
|
if strings.Contains(stderr.String(), "did you mean") {
|
||||||
|
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,26 @@ import (
|
|||||||
// onboarding" (drop into the setup wizard) rather than a fatal config error.
|
// onboarding" (drop into the setup wizard) rather than a fatal config error.
|
||||||
var ErrNoActiveProvider = errors.New("no active provider configured")
|
var ErrNoActiveProvider = errors.New("no active provider configured")
|
||||||
|
|
||||||
|
// ErrProviderRequiresModel marks a resolve failure caused solely by the active
|
||||||
|
// provider missing a model with no catalog default to fall back on (custom
|
||||||
|
// openai-/anthropic-compatible endpoints — Zero cannot guess a gateway's model).
|
||||||
|
// Like ErrNoActiveProvider, the interactive TUI treats it as "needs onboarding"
|
||||||
|
// and drops into the setup wizard so the user can fix it; headless commands
|
||||||
|
// (zero config, zero exec) still fail with the actionable message.
|
||||||
|
var ErrProviderRequiresModel = errors.New("provider requires model")
|
||||||
|
|
||||||
|
// setupFixableError tags an error with a sentinel for errors.Is WITHOUT
|
||||||
|
// changing its message: the sentinel's own text must not prefix what the user
|
||||||
|
// sees (the wrapped message is already complete and actionable).
|
||||||
|
type setupFixableError struct {
|
||||||
|
err error
|
||||||
|
sentinel error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *setupFixableError) Error() string { return e.err.Error() }
|
||||||
|
|
||||||
|
func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel} }
|
||||||
|
|
||||||
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
|
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
|
||||||
// too low for real multi-step agentic work (agents ran out mid-task before reaching
|
// too low for real multi-step agentic work (agents ran out mid-task before reaching
|
||||||
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
|
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
|
||||||
@@ -376,6 +396,18 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogDefaultModel returns the default model of a catalog provider ("" when
|
||||||
|
// the id is unknown), used to fill a missing profile.Model for the official-API
|
||||||
|
// kinds so a hand-written profile without a model resolves instead of bricking
|
||||||
|
// every command that resolves config up front (zero config, bare zero setup).
|
||||||
|
func catalogDefaultModel(catalogID string) string {
|
||||||
|
descriptor, ok := providercatalog.Get(catalogID)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(descriptor.DefaultModel)
|
||||||
|
}
|
||||||
|
|
||||||
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
|
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
|
||||||
catalogID := providercatalog.NormalizeID(profile.CatalogID)
|
catalogID := providercatalog.NormalizeID(profile.CatalogID)
|
||||||
if catalogID == "" {
|
if catalogID == "" {
|
||||||
@@ -737,11 +769,16 @@ func unionStrings(base []string, extra []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type normalizeOptions struct {
|
type normalizeOptions struct {
|
||||||
defaultOpenAIModel bool
|
// defaultModels fills a missing profile.Model for the official-API provider
|
||||||
|
// kinds (openai from the model registry; anthropic/google from their catalog
|
||||||
|
// descriptors). Off for provider-command configs (command.go), which must
|
||||||
|
// surface exactly what the external command returned rather than inventing
|
||||||
|
// a model behind its back.
|
||||||
|
defaultModels bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
|
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||||
@@ -791,7 +828,10 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
|||||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||||
}
|
}
|
||||||
if active.Model == "" {
|
if active.Model == "" {
|
||||||
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
|
return nil, ProviderProfile{}, &setupFixableError{
|
||||||
|
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
|
||||||
|
sentinel: ErrProviderRequiresModel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized, active, nil
|
return normalized, active, nil
|
||||||
@@ -838,7 +878,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
|||||||
case ProviderKindOpenAI:
|
case ProviderKindOpenAI:
|
||||||
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
|
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
|
||||||
profile.BaseURL = OpenAIBaseURL
|
profile.BaseURL = OpenAIBaseURL
|
||||||
if options.defaultOpenAIModel && profile.Model == "" {
|
if options.defaultModels && profile.Model == "" {
|
||||||
profile.Model = modelregistry.DefaultModelID
|
profile.Model = modelregistry.DefaultModelID
|
||||||
}
|
}
|
||||||
return profile, nil
|
return profile, nil
|
||||||
@@ -855,6 +895,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
|||||||
case ProviderKindAnthropic:
|
case ProviderKindAnthropic:
|
||||||
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
|
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
|
||||||
profile.BaseURL = AnthropicBaseURL
|
profile.BaseURL = AnthropicBaseURL
|
||||||
|
if options.defaultModels && profile.Model == "" {
|
||||||
|
profile.Model = catalogDefaultModel("anthropic")
|
||||||
|
}
|
||||||
return profile, nil
|
return profile, nil
|
||||||
}
|
}
|
||||||
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
|
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
|
||||||
@@ -869,6 +912,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
|||||||
case ProviderKindGoogle:
|
case ProviderKindGoogle:
|
||||||
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
|
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
|
||||||
profile.BaseURL = GoogleBaseURL
|
profile.BaseURL = GoogleBaseURL
|
||||||
|
if options.defaultModels && profile.Model == "" {
|
||||||
|
profile.Model = catalogDefaultModel("google")
|
||||||
|
}
|
||||||
return profile, nil
|
return profile, nil
|
||||||
}
|
}
|
||||||
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
|
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
|
||||||
|
|||||||
@@ -1555,3 +1555,97 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
|
|||||||
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
|
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reported brick: a hand-written google profile with an apiKey but no
|
||||||
|
// model made EVERY resolving command fail ("provider google requires model"),
|
||||||
|
// including zero config and bare zero setup — the only commands that could
|
||||||
|
// have fixed it. Official-API kinds now fall back to their catalog default
|
||||||
|
// model, exactly like the openai kind always has.
|
||||||
|
func TestResolveDefaultsGoogleModelFromCatalog(t *testing.T) {
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "google",
|
||||||
|
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"}]
|
||||||
|
}`)
|
||||||
|
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want model defaulted from the google catalog", err)
|
||||||
|
}
|
||||||
|
if resolved.Provider.Model != "gemini-2.5-pro" {
|
||||||
|
t.Fatalf("Model = %q, want the google catalog default gemini-2.5-pro", resolved.Provider.Model)
|
||||||
|
}
|
||||||
|
if resolved.Provider.BaseURL != GoogleBaseURL {
|
||||||
|
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, GoogleBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDefaultsAnthropicModelFromCatalog(t *testing.T) {
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "anthropic",
|
||||||
|
"providers": [{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}]
|
||||||
|
}`)
|
||||||
|
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v, want model defaulted from the anthropic catalog", err)
|
||||||
|
}
|
||||||
|
if resolved.Provider.Model != "claude-sonnet-4.5" {
|
||||||
|
t.Fatalf("Model = %q, want the anthropic catalog default claude-sonnet-4.5", resolved.Provider.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicitly configured model must always win over the catalog default.
|
||||||
|
func TestResolveKeepsExplicitGoogleModel(t *testing.T) {
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "google",
|
||||||
|
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x", "model": "gemini-2.5-flash"}]
|
||||||
|
}`)
|
||||||
|
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Provider.Model != "gemini-2.5-flash" {
|
||||||
|
t.Fatalf("Model = %q, want the explicitly configured gemini-2.5-flash", resolved.Provider.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider-command configs must NOT get invented models: the without-defaults
|
||||||
|
// path surfaces exactly what the external command returned.
|
||||||
|
func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
|
||||||
|
_, _, err := normalizeProvidersWithoutModelDefaults(
|
||||||
|
[]ProviderProfile{{Name: "google", ProviderKind: ProviderKindGoogle, APIKey: "AIza-x"}},
|
||||||
|
"google", map[string]string{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("provider-command path must still require an explicit model")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "requires model") {
|
||||||
|
t.Fatalf("error = %q, want requires-model", err.Error())
|
||||||
|
}
|
||||||
|
// The residual error must tell the user how to fix it.
|
||||||
|
if !strings.Contains(err.Error(), "config.json") {
|
||||||
|
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The residual requires-model failure (custom endpoint, no catalog default)
|
||||||
|
// must be tagged setup-fixable WITHOUT changing its message: the interactive
|
||||||
|
// TUI matches the sentinel to drop into the wizard, while headless commands
|
||||||
|
// print the exact actionable text.
|
||||||
|
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "gw",
|
||||||
|
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||||
|
}`)
|
||||||
|
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected requires-model error for a custom endpoint without model")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrProviderRequiresModel) {
|
||||||
|
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
|
||||||
|
}
|
||||||
|
// The sentinel must NOT prefix the user-facing message.
|
||||||
|
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
|
||||||
|
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "sk-x") {
|
||||||
|
t.Fatalf("error leaked API key: %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,10 +66,13 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateFileMissingModelWarns(t *testing.T) {
|
func TestValidateFileMissingModelWarns(t *testing.T) {
|
||||||
|
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
|
||||||
|
// on, so a missing model is still a real issue — and the message must tell
|
||||||
|
// the user how to fix it.
|
||||||
path := writeValidateFixture(t, `{
|
path := writeValidateFixture(t, `{
|
||||||
"activeProvider": "main",
|
"activeProvider": "main",
|
||||||
"providers": [
|
"providers": [
|
||||||
{"name": "main", "provider_kind": "anthropic"}
|
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
|
||||||
]
|
]
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
@@ -80,6 +83,30 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
|
|||||||
if !strings.Contains(issues[0].Message, "requires model") {
|
if !strings.Contains(issues[0].Message, "requires model") {
|
||||||
t.Fatalf("expected requires-model issue, got %#v", issues)
|
t.Fatalf("expected requires-model issue, got %#v", issues)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(issues[0].Message, "config.json") {
|
||||||
|
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
|
||||||
|
// Official-API kinds (anthropic/google) fall back to their catalog default
|
||||||
|
// model, so a hand-written model-less profile validates clean instead of
|
||||||
|
// bricking zero config / bare zero setup — the only commands that could
|
||||||
|
// have fixed it (the reported google case).
|
||||||
|
path := writeValidateFixture(t, `{
|
||||||
|
"activeProvider": "google",
|
||||||
|
"providers": [
|
||||||
|
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
|
||||||
|
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
_, issues := ValidateFile(path)
|
||||||
|
for _, issue := range issues {
|
||||||
|
if strings.Contains(issue.Message, "requires model") {
|
||||||
|
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
|
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user