Compare commits

..
Author SHA1 Message Date
Gnanam 03455ad915 fix(cli): enter setup on a requires-model config and hint zero auth login
Two residual first-run gaps after the catalog model-defaulting fix:

- An active provider whose model has NO catalog default to fall back on
  (custom openai-/anthropic-compatible endpoints) still bricked bare
  `zero` and `zero setup` — the exact commands that could have repaired
  the config. Tag that resolve failure with ErrProviderRequiresModel and
  extend the interactive TUI's fixable-error fallback (previously only
  ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel
  is attached via a message-preserving wrapper (multi-target Unwrap), so
  headless commands (zero config / zero exec) print the exact actionable
  text unchanged and every other resolve error stays fatal.

- `zero login` / `zero logout` don't exist (it's `zero auth login`);
  first-run users try them and got only "unknown command" (reported in
  the wild). The unknown-command error now answers: did you mean
  "zero auth login"? Unrelated unknown commands get no suggestion.

Tests: the sentinel comes from a REAL Resolve over a real config file
(message-prefix asserted, no key leak); the TUI fallback is red-green
(reverting the condition reproduces the exact brick with the old error);
the existing no-active-provider fallback and other-errors-stay-fatal
tests still pass; login/logout hint with a no-false-positive check.
2026-07-02 18:25:42 +05:30
Gnanam 0e7a1fc218 fix(config): default missing google/anthropic models from the catalog
A hand-written provider entry like {name: "google", provider_kind:
"google", apiKey: ...} with no model bricked Zero: config resolution
failed with "provider google requires model", and since zero config and
bare zero setup both resolve up front, the only commands that could have
fixed the config died with the same error (reported in the wild — the
one working escape, zero setup google --model <m>, is undiscoverable
from that message).

The openai kind has always defaulted a missing model
(modelregistry.DefaultModelID); google and anthropic were missed. Fall
back to their provider-catalog default models (gemini-2.5-pro /
claude-sonnet-4.5) the same way, behind the same gate: provider-command
configs (normalizeProvidersWithoutModelDefaults) still surface exactly
what the external command returned. An explicitly configured model
always wins, and profiles with a catalogID already got this via
applyCatalogDescriptor — this covers the catalog-less hand-written case.

The residual requires-model error (openai-compatible customs, which
have no catalog default) now carries an actionable hint: add "model" in
config.json or re-run zero setup with --model.

Tests: google/anthropic defaulting (red-green: reverting the google
fallback reproduces the exact reported error), explicit model wins,
provider-command gate still errors with the hint, and validation treats
official-kind model-less profiles as clean while still flagging customs.
2026-07-02 18:13:30 +05:30
KRATOS 39e7100674 fix(gemini): strip unsupported JSON Schema fields from tool declarations (#374)
Every exec against the Google/Gemini provider failed: Zero emits tool
parameter schemas in OpenAI's JSON Schema dialect, which always includes
`additionalProperties` (schemaToRuntimeMap writes it unconditionally).
Google's functionDeclarations schema doesn't recognize that keyword and
400s the whole request — "Unknown name \"additionalProperties\" …
Cannot find field" — so no tool-using call ever reached the model.

Sanitize tool parameters in the Gemini mapper (only there — the field is
valid and wanted for OpenAI strict mode and harmless for Anthropic):

- sanitizeGeminiSchema keeps only the keywords Gemini's Schema type
  supports (type, description, enum, properties, required, items, anyOf,
  default, nullable, format, and the min/max/length constraints) via an
  allowlist, so any future OpenAI-ism or MCP-supplied field can't leak.
- Recurses through properties, items, and anyOf so nested schemas are
  cleaned at every depth, not just the top level.

Tests: a unit test on the sanitizer (nesting, legit fields preserved,
nil-safe) and an end-to-end test asserting the outgoing request body's
functionDeclarations[].parameters carry no additionalProperties while the
real schema survives. Bypassing the sanitizer reproduces the reported
payload, so the coverage is red-green.

Fixes #373
2026-07-02 20:20:25 +08:00
smallnestandsmallnest 027a8f2768 fix(config): use ~/.config on macOS and enter setup when no provider (#371) (#372)
On macOS, DefaultUserConfigPath used Go's os.UserConfigDir which returns
~/Library/Application Support, while users (and Claude Code) place config
at ~/.config/zero/config.json. The user config was never read, leaving
activeProvider empty and the launch failing with `active provider "" not
found` on a fresh `go run cmd/zero setup`.

- Add config.UserConfigDir(): on darwin, resolve to ~/.config (honoring
  XDG_CONFIG_HOME), matching Linux and Claude Code. DefaultUserConfigPath,
  the TUI user-commands dir, and specialist manifests all route through it.
- Add config.ErrNoActiveProvider sentinel; the interactive TUI now drops
  into the setup wizard when resolve fails solely due to a missing/
  unresolvable active provider, instead of exiting fatally. Other resolve
  errors (malformed JSON, directory conflicts) remain fatal.

Note: `zero setup`/`zero auth` already MkdirAll the config dir before
writing, so the directory is auto-created on first write.

Co-authored-by: smallnest <smallnest@gmail>
2026-07-02 20:03:44 +08:00
Kevin Codex bd89a1f451 feat: require manual approval before npm publish + drop release-as pin (#369)
The publish-npm job now targets the npm-publish environment, whose
required-reviewer rule pauses the job for maintainer approval in the
Actions UI before anything reaches the registry. Also removes the
release-as 0.1.0 pin from release-please-config.json now that v0.1.0
has shipped — leaving it would stamp every future release 0.1.0.
2026-07-02 16:15:22 +08:00
github-actions[bot] c807f92b72 chore(main): release 0.1.0 (#368)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-02 15:20:45 +08:00
19 changed files with 642 additions and 355 deletions

No files matched your search

+6
View File
@@ -112,6 +112,12 @@ jobs:
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
# Manual approval gate: this environment carries a required-reviewer rule
# (Settings -> Environments -> npm-publish), so the job pauses until a
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
+1 -1
View File
@@ -1 +1 @@
{}
{".":"0.1.0"}
+13
View File
@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## 0.1.0 (2026-07-02)
### Features
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
### Bug Fixes
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
## [Unreleased]
### Added
+51 -35
View File
@@ -22,7 +22,6 @@ import (
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -54,33 +53,32 @@ type appDeps struct {
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -136,9 +134,8 @@ func defaultAppDeps() appDeps {
OAuthLoginKey: loginKey,
})
},
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
probeProviderHealth: providerhealth.Probe,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -405,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 {
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 {
return 1
}
@@ -435,9 +440,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.probeProviderHealth == nil {
deps.probeProviderHealth = defaults.probeProviderHealth
}
if deps.discoverProviderModels == nil {
deps.discoverProviderModels = defaults.discoverProviderModels
}
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
@@ -536,7 +538,21 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), 1)
// A resolve failure the setup wizard can FIX is not fatal for the
// interactive TUI: drop into the wizard with an empty config so the user
// can onboard/repair, instead of exiting with an error they can only fix
// by hand-editing config.json. That covers a missing/unresolvable active
// 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)
}
resolved = config.ResolvedConfig{}
forceSetup = true
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
+147
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -157,6 +158,80 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider should not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx 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; expected fallback into setup instead of a fatal error")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
if launchedOptions.Provider != nil {
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
},
runTUI: func(ctx context.Context, options tui.Options) int {
t.Fatal("TUI must not launch on a non-provider resolve error")
return 0
},
})
if exitCode == 0 {
t.Fatal("exit code = 0, want non-zero for fatal config error")
}
if !strings.Contains(stderr.String(), "invalid config JSON") {
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
}
}
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1401,3 +1476,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
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())
}
}
-5
View File
@@ -81,9 +81,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "detect" {
return runProvidersDetect(args, stdout, stderr, deps)
}
if command == "models" {
return runProvidersModels(args, stdout, stderr, deps)
}
if command != "list" && command != "current" && command != "catalog" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
}
@@ -468,11 +465,9 @@ func writeProvidersHelp(w io.Writer) error {
zero providers use <name> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
Flags:
--json Print JSON summary
-148
View File
@@ -1,148 +0,0 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
type providerModelsOptions struct {
name string
json bool
}
// runProvidersModels lists the models a saved provider actually serves by probing
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
// models no longer needs a config object per model — configure the provider once,
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
// model ids through to the provider).
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderModelsArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
profile, err := selectProviderForCheck(resolved, options.name)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
ctx, stop := signalContext()
defer stop()
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
entry := map[string]any{"id": model.ID}
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
items = append(items, entry)
}
payload := map[string]any{
"provider": profile.Name,
"count": len(items),
"models": items,
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
name := strings.TrimSpace(profile.Name)
if name == "" {
name = "provider"
}
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
return exitCrash
}
for _, model := range models {
line := strings.TrimSpace(model.ID)
if description := strings.TrimSpace(model.Description); description != "" {
line += " — " + description
}
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
suffix := "s"
if len(models) == 1 {
suffix = ""
}
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
return exitCrash
}
if len(models) > 0 {
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
return exitCrash
}
}
return exitSuccess
}
// discoveryCredentialProfile resolves the profile's API key the same way the
// runtime does — inline, then the stored credential, then the configured env var —
// so a `providers models` probe authenticates exactly like a real request. Mirrors
// discoveredModelContextWindow's credential resolution.
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
authed := profile
if strings.TrimSpace(authed.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
}
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
}
return authed
}
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
// the provider's model-listing endpoint with no curated-catalog merge or
// coding-model filtering, so a custom provider's full model list is returned.
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
}
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
options := providerModelsOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.name != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.name = arg
}
}
return options, false, nil
}
-151
View File
@@ -1,151 +0,0 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{
{ID: "team/model-a", Description: "first"},
{ID: "team/model-b"},
}, nil
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
// No name given → the active provider is probed, with its resolved credential.
if probed.Name != "work" {
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
}
out := stdout.String()
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
}
func TestRunProvidersModelsJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Provider string `json:"provider"`
Count int `json:"count"`
Models []struct {
ID string `json:"id"`
Description string `json:"description"`
} `json:"models"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode json: %v\n%s", err, stdout.String())
}
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
t.Fatalf("payload = %#v", payload)
}
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
t.Fatalf("model[0] = %#v", payload.Models[0])
}
// A model with no description omits the field rather than emitting an empty string.
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
t.Fatalf("model[1] = %#v", payload.Models[1])
}
}
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
custom := config.ProviderProfile{
Name: "self-hosted",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://models.internal/v1",
APIKey: "sk-internal",
Model: "house-model",
}
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
}
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
t.Fatalf("probed = %#v, want the named custom provider", probed)
}
}
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
called := false
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
called = true
return nil, nil
}
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
}
if called {
t.Fatal("discovery must not run for an unknown provider")
}
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
}
}
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, errors.New("models endpoint returned 404")
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
}
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
}
}
+3 -1
View File
@@ -8,6 +8,8 @@ import (
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -326,7 +328,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
configRoot, err := config.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
+21 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -33,13 +34,32 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
}
func DefaultUserConfigPath() (string, error) {
userConfigDir, err := os.UserConfigDir()
userConfigDir, err := UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config directory: %w", err)
}
return filepath.Join(userConfigDir, "zero", "config.json"), nil
}
// UserConfigDir returns the base directory Zero stores per-user config under.
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
// matching Linux and Claude Code) so a single config path works cross-platform.
// $XDG_CONFIG_HOME still wins on macOS when set.
func UserConfigDir() (string, error) {
if runtime.GOOS == "darwin" {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return xdg, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".config"), nil
}
return os.UserConfigDir()
}
func existingConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
+19 -1
View File
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
configRoot, err := UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
@@ -130,3 +130,21 @@ func mkdirAll(t *testing.T, path string) {
t.Fatalf("create directory %s: %v", path, err)
}
}
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("macOS-specific config path behavior")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatalf("DefaultUserConfigPath() error = %v", err)
}
want := filepath.Join(home, ".config", "zero", "config.json")
if path != want {
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
}
}
+58 -6
View File
@@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
@@ -14,6 +15,31 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// ErrNoActiveProvider marks a resolve failure caused solely by a missing or
// unresolvable active provider. The interactive TUI treats this as "needs
// onboarding" (drop into the setup wizard) rather than a fatal config error.
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
// 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.
@@ -370,6 +396,18 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
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) {
catalogID := providercatalog.NormalizeID(profile.CatalogID)
if catalogID == "" {
@@ -731,11 +769,16 @@ func unionStrings(base []string, extra []string) []string {
}
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) {
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) {
@@ -750,7 +793,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if len(providers) == 0 {
if activeName != "" {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
return []ProviderProfile{}, ProviderProfile{}, nil
}
@@ -782,10 +825,13 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
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
@@ -832,7 +878,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
if options.defaultOpenAIModel && profile.Model == "" {
if options.defaultModels && profile.Model == "" {
profile.Model = modelregistry.DefaultModelID
}
return profile, nil
@@ -849,6 +895,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindAnthropic:
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
profile.BaseURL = AnthropicBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("anthropic")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
@@ -863,6 +912,9 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindGoogle:
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
profile.BaseURL = GoogleBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("google")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
+98
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
@@ -898,6 +899,9 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
t.Fatalf("error = %q, want active provider missing message", err.Error())
}
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if resolved.ActiveProvider != "" {
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
}
@@ -1551,3 +1555,97 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
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())
}
}
+28 -1
View File
@@ -66,10 +66,13 @@ func TestValidateFileRedactsSecretInIssue(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, `{
"activeProvider": "main",
"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") {
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) {
+66 -1
View File
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
declarations = append(declarations, geminiFunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Parameters: sanitizeGeminiSchema(tool.Parameters),
})
}
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
@@ -350,6 +350,71 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
return mapped, nil
}
// geminiSchemaFields is the subset of JSON Schema keywords Google's
// functionDeclarations[].parameters accepts (the Generative AI Schema type).
// Anything outside it — most notably OpenAI's `additionalProperties`, which Zero
// emits on every tool's parameters and which Gemini 400s on ("Unknown name
// \"additionalProperties\" … Cannot find field") — must be dropped before the
// request goes out. Kept as an allowlist rather than a denylist so a schema
// keyword Zero (or an MCP server) adds later can't silently leak an unsupported
// field into the Gemini payload.
var geminiSchemaFields = map[string]bool{
"type": true, "format": true, "title": true, "description": true,
"nullable": true, "enum": true, "items": true, "properties": true,
"required": true, "anyOf": true, "propertyOrdering": true, "default": true,
"minimum": true, "maximum": true, "minItems": true, "maxItems": true,
"minLength": true, "maxLength": true, "minProperties": true,
"maxProperties": true, "pattern": true, "example": true,
}
// sanitizeGeminiSchema returns a copy of a JSON-Schema map keeping only the
// keywords Gemini supports (geminiSchemaFields), recursing through the nested
// schemas under `properties`, `items`, and `anyOf`. Returns nil for a nil input
// so a parameterless tool stays parameterless.
func sanitizeGeminiSchema(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
out := make(map[string]any, len(schema))
for key, value := range schema {
if !geminiSchemaFields[key] {
continue
}
switch key {
case "properties":
if props, ok := value.(map[string]any); ok {
cleaned := make(map[string]any, len(props))
for name, sub := range props {
if subMap, ok := sub.(map[string]any); ok {
cleaned[name] = sanitizeGeminiSchema(subMap)
} else {
cleaned[name] = sub
}
}
value = cleaned
}
case "items":
if subMap, ok := value.(map[string]any); ok {
value = sanitizeGeminiSchema(subMap)
}
case "anyOf":
if variants, ok := value.([]any); ok {
cleaned := make([]any, len(variants))
for i, variant := range variants {
if subMap, ok := variant.(map[string]any); ok {
cleaned[i] = sanitizeGeminiSchema(subMap)
} else {
cleaned[i] = variant
}
}
value = cleaned
}
}
out[key] = value
}
return out
}
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
systemParts := []geminiPart{}
contents := []geminiContent{}
+127
View File
@@ -3,6 +3,7 @@ package gemini
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
@@ -504,3 +505,129 @@ func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.Stream
}
return matching
}
// TestSanitizeGeminiSchemaStripsUnsupportedFields: the sanitizer keeps only
// Gemini-supported keywords and recurses into properties/items/anyOf, so no
// OpenAI-ism (additionalProperties, $schema, patternProperties) survives at any
// depth while legitimate fields (type/description/enum/required/default) stay.
func TestSanitizeGeminiSchemaStripsUnsupportedFields(t *testing.T) {
in := map[string]any{
"type": "object",
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": []string{"path"},
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "a path",
"default": ".",
},
"nested": map[string]any{
"type": "object",
"additionalProperties": false, // must be stripped at depth too
"properties": map[string]any{
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string", "additionalProperties": true},
},
},
},
},
}
out := sanitizeGeminiSchema(in)
assertNoAdditionalProps(t, out, "root")
if out["additionalProperties"] != nil {
t.Fatal("top-level additionalProperties must be stripped")
}
if out["$schema"] != nil {
t.Fatal("$schema must be stripped")
}
// Legitimate fields survive.
if out["type"] != "object" {
t.Fatalf("type should survive, got %v", out["type"])
}
props := out["properties"].(map[string]any)
pathProp := props["path"].(map[string]any)
if pathProp["description"] != "a path" || pathProp["default"] != "." {
t.Fatalf("legitimate property fields must survive: %#v", pathProp)
}
// nil input stays nil (parameterless tool).
if sanitizeGeminiSchema(nil) != nil {
t.Fatal("nil schema should map to nil")
}
}
// assertNoAdditionalProps fails if additionalProperties appears anywhere in the
// (recursively walked) schema.
func assertNoAdditionalProps(t *testing.T, node any, path string) {
t.Helper()
switch v := node.(type) {
case map[string]any:
if _, ok := v["additionalProperties"]; ok {
t.Fatalf("additionalProperties leaked at %s", path)
}
for k, sub := range v {
assertNoAdditionalProps(t, sub, path+"."+k)
}
case []any:
for i, sub := range v {
assertNoAdditionalProps(t, sub, fmt.Sprintf("%s[%d]", path, i))
}
}
}
// TestGeminiRequestOmitsAdditionalPropertiesInToolSchema: end-to-end, the tool
// parameters Zero emits (schemaToRuntimeMap always writes additionalProperties)
// must not reach Gemini — otherwise every functionDeclaration is 400-rejected
// ("Unknown name additionalProperties"), which broke all tool-using exec calls
// against Google (issue #373).
func TestGeminiRequestOmitsAdditionalPropertiesInToolSchema(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"usageMetadata":{"promptTokenCount":1}}`)
}))
defer server.Close()
provider, err := New(Options{APIKey: "k", BaseURL: server.URL + "/", Model: "models/gemini-2.5-flash"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "grep",
Description: "search",
Parameters: map[string]any{ // exactly what schemaToRuntimeMap produces
"type": "object",
"additionalProperties": false,
"required": []string{"pattern"},
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "regex"},
},
},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
tools := gotBody["tools"].([]any)
decls := tools[0].(map[string]any)["functionDeclarations"].([]any)
params := decls[0].(map[string]any)["parameters"].(map[string]any)
if _, ok := params["additionalProperties"]; ok {
t.Fatalf("additionalProperties must not be sent to Gemini: %#v", params)
}
// The tool is still usable: its real schema survived.
if params["type"] != "object" {
t.Fatalf("tool parameters type should survive, got %v", params["type"])
}
props := params["properties"].(map[string]any)
if _, ok := props["pattern"]; !ok {
t.Fatalf("tool property must survive sanitization: %#v", props)
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
)
@@ -120,7 +121,7 @@ var knownToolNames = map[string]bool{
}
func DefaultPaths(workspaceRoot string) (Paths, error) {
userConfigDir, err := os.UserConfigDir()
userConfigDir, err := config.UserConfigDir()
if err != nil {
return Paths{}, fmt.Errorf("resolve user config directory: %w", err)
}
+1 -1
View File
@@ -635,7 +635,7 @@ func newModel(ctx context.Context, options Options) model {
}
}
userConfigDir, _ := os.UserConfigDir()
userConfigDir, _ := config.UserConfigDir()
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
registry := options.Registry
+1 -2
View File
@@ -5,8 +5,7 @@
"bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a",
"packages": {
".": {
"package-name": "@gitlawb/zero",
"release-as": "0.1.0"
"package-name": "@gitlawb/zero"
}
}
}