diff --git a/README.md b/README.md index cdce58418..b01533225 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ zero exec --output-format stream-json < turns.jsonl ## Why Zero - **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter, - DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any + DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, Atomic Chat, or any OpenAI-/Anthropic-compatible endpoint. - **Stay in control.** File writes, shell commands, network access, and out-of-workspace writes go through Zero's permission and sandbox policy. @@ -182,8 +182,12 @@ zero providers add custom-openai-compatible \ --set-active ``` -For local models, run Ollama or LM Studio and then use `zero setup` or -`zero providers detect`. +For local models, run Ollama, LM Studio, or the [Atomic Chat](https://atomic.chat) +desktop app, then use `zero setup` or `zero providers detect`. For Atomic Chat, +load a model and enable its local OpenAI-compatible API (default +`http://127.0.0.1:1337/v1`). Choose `atomic-chat-local`; detection includes the +loaded model ID in the add command. If no usable ID is discovered, load a model +and retry. Model IDs requiring shell-specific quoting use interactive setup. ## Daily Use diff --git a/README_ZH.md b/README_ZH.md index ff5186a6d..9ff7f7b84 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -23,7 +23,7 @@ zero exec --output-format stream-json < turns.jsonl ## 为什么选择 Zero -- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。 +- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。 - **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。 - **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。 - **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。 @@ -114,7 +114,11 @@ export LONGCAT_API_KEY=... zero providers setup longcat --set-active ``` -对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。 +对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用, +然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用 +本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。 +检测生成的添加命令会包含已加载的模型 ID;如果未发现可用的 ID,请加载模型后重试。 +对于需要特定 Shell 转义的模型 ID,请使用交互式设置。 ## 日常使用 diff --git a/internal/cli/command_center.go b/internal/cli/command_center.go index a6fab33ec..b1889474b 100644 --- a/internal/cli/command_center.go +++ b/internal/cli/command_center.go @@ -407,7 +407,11 @@ func formatProviderCatalogLine(provider providerCatalogSummary) string { provider.RuntimeSupported, )) if provider.RuntimeSupported { - lines = append(lines, " setup: zero providers setup "+displayCLIValue(provider.ID, "unknown")+" --set-active") + setup := " setup: zero providers setup " + displayCLIValue(provider.ID, "unknown") + " --set-active" + if provider.ID == "atomic-chat-local" { + setup = " setup: run zero setup to select a loaded model, or zero providers detect to get an add command" + } + lines = append(lines, setup) } else { lines = append(lines, " unsupported: "+displayCLIValue(provider.RuntimeUnsupportedReason, "unknown")) } @@ -502,7 +506,7 @@ func writeProvidersHelp(w io.Writer) error { 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. +Detect probes for running local runtimes (Ollama, LM Studio, Atomic Chat) 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: diff --git a/internal/cli/command_center_test.go b/internal/cli/command_center_test.go index 430468c9f..2c1c93323 100644 --- a/internal/cli/command_center_test.go +++ b/internal/cli/command_center_test.go @@ -967,3 +967,10 @@ func TestProvidersListMarksOAuthLoginProviders(t *testing.T) { t.Fatalf("list should render the oauth login state, got:\n%s", rendered) } } + +func TestAtomicCatalogSetupRequiresLoadedModel(t *testing.T) { + out := formatProviderCatalogLine(providerCatalogSummary{ID: "atomic-chat-local", RuntimeSupported: true}) + if strings.Contains(out, "zero providers setup atomic-chat-local --set-active") || !strings.Contains(out, "zero setup") { + t.Fatalf("catalog advertises a failing setup command: %s", out) + } +} diff --git a/internal/cli/provider_adoption_test.go b/internal/cli/provider_adoption_test.go new file mode 100644 index 000000000..2955c4dd8 --- /dev/null +++ b/internal/cli/provider_adoption_test.go @@ -0,0 +1,117 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Gitlawb/zero/internal/provideronboarding" + "mvdan.cc/sh/v3/shell" +) + +func TestLocalAdoptionSavesAndReloadsEligibleModel(t *testing.T) { + const wantModel = "-loaded chat model" + requests := make(chan string, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/models": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"data":[{"id":"text-embedding-local"},{"id":%q}]}`, wantModel) + case "/v1/chat/completions": + var body struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode completion request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if r.Header.Get("Authorization") != "" { + t.Error("local adoption unexpectedly sent authorization") + } + select { + case requests <- body.Model: + default: + t.Error("unexpected extra completion requests") + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"adoption ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + detected := provideronboarding.DetectLocalRuntimes(context.Background(), provideronboarding.LocalDetectOptions{ + HTTPClient: server.Client(), + Candidates: []provideronboarding.LocalRuntime{{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model", BaseURL: server.URL + "/v1"}}, + }) + if len(detected) != 1 { + t.Fatalf("expected one detected runtime: %+v", detected) + } + args, err := shell.Fields(detected[0].SetupAction().Command, func(string) string { return "" }) + if err != nil || len(args) < 4 { + t.Fatalf("invalid adoption command: args=%q err=%v", args, err) + } + root := t.TempDir() + run := func(args ...string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + childArgs := append([]string{"-test.run=^TestLocalAdoptionCLIProcess$", "--"}, args...) + cmd := exec.CommandContext(ctx, os.Args[0], childArgs...) + cmd.Dir = root + cmd.Env = []string{ + "ZERO_TEST_LOCAL_ADOPTION=1", "ZERO_CRED_STORAGE=encrypted-file", + "HOME=" + root, "USERPROFILE=" + root, + "XDG_CONFIG_HOME=" + filepath.Join(root, "config"), + "XDG_CACHE_HOME=" + filepath.Join(root, "cache"), + "XDG_STATE_HOME=" + filepath.Join(root, "state"), + "APPDATA=" + filepath.Join(root, "config"), + "LOCALAPPDATA=" + filepath.Join(root, "cache"), + } + for _, key := range []string{"PATH", "SystemRoot", "WINDIR", "TMPDIR", "TEMP", "TMP"} { + if value, ok := os.LookupEnv(key); ok { + cmd.Env = append(cmd.Env, key+"="+value) + } + } + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI %q failed: %v\n%s", args, err, output) + } + return string(output) + } + // The mock uses a random port; only override the endpoint, retaining all + // generated arguments for the real add parser and persistence path. + run(append(args[1:], "--base-url", server.URL+"/v1")...) + if output := run("exec", "--cwd", root, "Reply with adoption ok"); !strings.Contains(output, "adoption ok") { + t.Fatalf("completion output = %q", output) + } + select { + case model := <-requests: + if model != wantModel { + t.Fatalf("fresh process sent model %q, want eligible model %q", model, wantModel) + } + default: + t.Fatal("fresh process did not send a completion request") + } +} + +func TestLocalAdoptionCLIProcess(t *testing.T) { + if os.Getenv("ZERO_TEST_LOCAL_ADOPTION") != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + os.Exit(Run(os.Args[i+1:], os.Stdout, os.Stderr)) + } + } + t.Fatal("missing helper argument separator") +} diff --git a/internal/cli/provider_detect.go b/internal/cli/provider_detect.go index 8c06667c0..556769fb5 100644 --- a/internal/cli/provider_detect.go +++ b/internal/cli/provider_detect.go @@ -40,7 +40,7 @@ type providerDetectReport struct { } // runProvidersDetect probes the machine for running local, OpenAI-compatible -// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each +// model runtimes (Ollama, LM Studio, Atomic Chat) and prints a no-key adopt command for each // one it finds, followed by the next-step actions for every already-configured // provider. It is the onboarding-advice surface — "what can I do right now?" — // and never errors on a machine with nothing running locally (it just reports an @@ -146,6 +146,8 @@ func formatProviderDetectReport(report providerDetectReport) string { } if command := strings.TrimSpace(runtime.Action.Command); command != "" { lines = append(lines, " "+runtime.Action.Label+": "+command) + } else if detail := strings.TrimSpace(runtime.Action.Detail); detail != "" { + lines = append(lines, " "+runtime.Action.Label+": "+detail) } } diff --git a/internal/cli/provider_detect_test.go b/internal/cli/provider_detect_test.go index b1801fde5..821c4286e 100644 --- a/internal/cli/provider_detect_test.go +++ b/internal/cli/provider_detect_test.go @@ -105,3 +105,13 @@ func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) { t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions) } } + +func TestProviderDetectShowsGuidanceWithoutCommand(t *testing.T) { + for _, models := range [][]string{nil, {"local-model"}, {"x&calc"}} { + runtime := provideronboarding.DetectedLocalRuntime{LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model"}, Models: models} + out := formatProviderDetectReport(buildProviderDetectReport(config.ResolvedConfig{}, []provideronboarding.DetectedLocalRuntime{runtime})) + if strings.Contains(out, "providers add") || !strings.Contains(out, runtime.SetupAction().Detail) { + t.Fatalf("missing actionable guidance: %s", out) + } + } +} diff --git a/internal/cli/provider_setup.go b/internal/cli/provider_setup.go index de13f26ce..9a71d5ab3 100644 --- a/internal/cli/provider_setup.go +++ b/internal/cli/provider_setup.go @@ -434,6 +434,14 @@ func providerProfileForAdd(options providerAddOptions) (config.ProviderProfile, catalogHeaders = aimlapi.WithResolvedPartnerHeader(catalogHeaders) } } + // Discovery fallback can pass the nonempty catalog placeholder through + // either CLI setup path. Never persist it as an Atomic Chat model. + if descriptor.ID == "atomic-chat-local" { + model := strings.TrimSpace(options.model) + if model == "" || model == "local-model" { + return config.ProviderProfile{}, fmt.Errorf("provider %q serves a locally loaded model; pass --model (run `zero providers detect` to see the served model)", descriptor.ID) + } + } profile := config.ProviderProfile{ Name: name, ProviderKind: providerKindForDescriptor(descriptor), diff --git a/internal/cli/provider_setup_test.go b/internal/cli/provider_setup_test.go index c9c293d7c..f47c234e2 100644 --- a/internal/cli/provider_setup_test.go +++ b/internal/cli/provider_setup_test.go @@ -1,9 +1,12 @@ package cli import ( + "strings" "testing" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/provideronboarding" + "mvdan.cc/sh/v3/shell" ) // Regression for issue #555's follow-up: `zero providers check` must not @@ -66,3 +69,58 @@ func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) { }) } } + +// atomic-chat-local without --model would persist the catalog placeholder +// "local-model", which the Atomic Chat server never serves, so the first +// completion fails. Adding it must require a real model instead. +func TestProviderProfileForAddRequiresModelForAtomicChatLocal(t *testing.T) { + if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local"}); err == nil { + t.Fatalf("providerProfileForAdd(atomic-chat-local, no --model) = nil error, want a require-model error") + } else if !strings.Contains(err.Error(), "--model") { + t.Fatalf("error should tell the user to pass --model, got %v", err) + } + + // The interactive wizards and the no-id detect fallback resolve the model to + // the catalog DefaultModel and pass it through as a non-empty value, so the + // placeholder itself must be rejected, not just an empty --model. + if _, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "local-model"}); err == nil { + t.Fatalf("providerProfileForAdd(atomic-chat-local, --model local-model) = nil error, want reject of the catalog placeholder") + } + + profile, err := providerProfileForAdd(providerAddOptions{catalogID: "atomic-chat-local", model: "unsloth/gemma-4-E2B-it-GGUF"}) + if err != nil { + t.Fatalf("providerProfileForAdd(atomic-chat-local, --model) returned error: %v", err) + } + if profile.Model != "unsloth/gemma-4-E2B-it-GGUF" { + t.Fatalf("profile.Model = %q, want the explicit model", profile.Model) + } + if profile.Model == "local-model" { + t.Fatalf("profile persisted the catalog placeholder") + } +} + +func TestDetectedModelActionSurvivesAddParser(t *testing.T) { + for _, catalogID := range []string{"atomic-chat-local", "lmstudio", "ollama"} { + for _, modelID := range []string{"-loaded-model", "--set-active", "--model", "-loaded model", "ordinary/model", "model with spaces"} { + t.Run(catalogID+"/"+modelID, func(t *testing.T) { + detected := provideronboarding.DetectedLocalRuntime{ + LocalRuntime: provideronboarding.LocalRuntime{CatalogID: catalogID, Name: "Local Runtime", DefaultModel: "local-model"}, + Models: []string{modelID}, + } + command := detected.SetupAction().Command + args, err := shell.Fields(command, func(string) string { return "" }) + if err != nil || len(args) < 4 { + t.Fatalf("invalid adoption command %q: %v", command, err) + } + options, help, err := parseProviderAddArgs(args[3:]) + if err != nil || help { + t.Fatalf("generated adoption command rejected by add parser: %q: %v", command, err) + } + profile, err := providerProfileForAdd(options) + if err != nil || profile.Model != modelID || !options.setActive || profile.Name != "Local Runtime" { + t.Fatalf("adoption changed model or options: model=%q name=%q active=%v err=%v", profile.Model, profile.Name, options.setActive, err) + } + }) + } + } +} diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index 905ba7f23..8ee88b8eb 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -367,3 +367,14 @@ func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) { t.Fatal("a keyless local provider should still be probed") } } + +func TestSaveSetupProviderRejectsAtomicPlaceholderBeforeConfigAccess(t *testing.T) { + for _, model := range []string{"", "local-model", " local-model "} { + accessed := false + deps := appDeps{userConfigPath: func() (string, error) { accessed = true; return filepath.Join(t.TempDir(), "config.json"), nil }} + _, err := saveSetupProvider(deps, tui.SetupSelection{CatalogID: "atomic-chat-local", Model: model}, setupSaveOptions{}) + if err == nil || !strings.Contains(err.Error(), "--model") || accessed { + t.Fatalf("invalid model %q reached config access: accessed=%v err=%v", model, accessed, err) + } + } +} diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index d627a5cb4..d0db101fb 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -257,6 +257,21 @@ func TestProviderProfileMissingCredentialEnv(t *testing.T) { profile: ProviderProfile{Name: "local", CatalogID: "ollama"}, want: false, }, + { + // A profile saved against the hosted atomic-chat preset keeps its + // remote base URL and key, so it must still be reported as missing a + // credential. The keyless local runtime is a separate catalog ID + // (atomic-chat-local) precisely so this identity is never repurposed. + name: "hosted atomic-chat profile still requires its key", + profile: ProviderProfile{Name: "atomic-chat", CatalogID: "atomic-chat", BaseURL: "https://api.atomic.chat/v1"}, + wantEnv: "ATOMIC_CHAT_API_KEY", + want: true, + }, + { + name: "local atomic chat runtime needs no credential", + profile: ProviderProfile{Name: "atomic-local", CatalogID: "atomic-chat-local"}, + want: false, + }, { name: "credential resolved via inline key", profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"}, diff --git a/internal/providercatalog/catalog.go b/internal/providercatalog/catalog.go index a29f330a5..46ac4b828 100644 --- a/internal/providercatalog/catalog.go +++ b/internal/providercatalog/catalog.go @@ -115,6 +115,7 @@ var descriptors = []Descriptor{ openAICompat("ollama-cloud", "Ollama Cloud", "https://ollama.com/v1", "qwen3-coder:480b", []string{"OLLAMA_API_KEY"}, "ollama.com", "ollama cloud"), localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"), localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"), + localOpenAI("atomic-chat-local", "Atomic Chat Local", "http://127.0.0.1:1337/v1", "local-model", "atomic chat local"), oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false), // Hugging Face Inference Providers — OpenAI-compatible router at // https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth diff --git a/internal/providercatalog/catalog_test.go b/internal/providercatalog/catalog_test.go index 7467bfe7a..43d8d4fdf 100644 --- a/internal/providercatalog/catalog_test.go +++ b/internal/providercatalog/catalog_test.go @@ -16,6 +16,7 @@ var expectedCatalogIDs = []string{ "ollama-cloud", "ollama", "lmstudio", + "atomic-chat-local", "openrouter", "huggingface", "chatgpt", @@ -259,7 +260,7 @@ func TestRemoteProvidersDeclareAuthOrExplicitPublicAccess(t *testing.T) { } func TestLocalProvidersDoNotRequireAuth(t *testing.T) { - for _, id := range []string{"ollama", "lmstudio"} { + for _, id := range []string{"ollama", "lmstudio", "atomic-chat-local"} { descriptor, err := Require(id) if err != nil { t.Fatalf("Require(%q) error = %v", id, err) @@ -316,6 +317,7 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) { "ollama cloud": "ollama-cloud", "ollama local": "ollama", "lm-studio": "lmstudio", + "atomic chat local": "atomic-chat-local", "mini_max": "minimax", "Moonshot": "moonshot", "Atlas Cloud": "atlascloud", @@ -369,7 +371,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) { TransportBedrock: {"bedrock"}, TransportVertex: {"vertex"}, TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"}, - TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"}, + TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"}, } for transport, wantIDs := range cases { diff --git a/internal/providermodelcatalog/catalog.go b/internal/providermodelcatalog/catalog.go index 42c7a1a4e..cf0e6bf7e 100644 --- a/internal/providermodelcatalog/catalog.go +++ b/internal/providermodelcatalog/catalog.go @@ -214,6 +214,8 @@ var curatedModels = map[string][]Model{ {ID: "gpt-4.1", Description: "catalog default"}, {ID: "gpt-4o-mini", Description: "fast model"}, }, + // atomic-chat-local has no curated list: discover the user-loaded model + // from the runtime's /v1/models endpoint. "opencode-go-anthropic-compatible": { {ID: "minimax-m3", Description: "MiniMax M3: default"}, {ID: "minimax-m2.7", Description: "MiniMax M2.7: coding model"}, diff --git a/internal/provideronboarding/advice.go b/internal/provideronboarding/advice.go index ce9d81069..22c9e5f6a 100644 --- a/internal/provideronboarding/advice.go +++ b/internal/provideronboarding/advice.go @@ -25,10 +25,32 @@ func (state ProviderState) Actions() []Action { } func SetupCommand(descriptor providercatalog.Descriptor, name string, setActive bool) string { + return setupCommand(descriptor, name, "", setActive) +} + +// SetupCommandWithModel is SetupCommand with an explicit --model. A local +// runtime serves whichever model the user loaded, so its catalog DefaultModel is +// only a placeholder: an adopt command that omits --model persists that +// placeholder and the first completion fails with an unknown-model response. +// An empty model falls back to SetupCommand's behaviour. Commands that cannot +// be represented safely across supported shells are omitted. +func SetupCommandWithModel(descriptor providercatalog.Descriptor, name string, model string, setActive bool) string { + return setupCommand(descriptor, name, model, setActive) +} + +func setupCommand(descriptor providercatalog.Descriptor, name string, model string, setActive bool) string { parts := []string{"zero", "providers", "add", strings.TrimSpace(descriptor.ID)} if name = strings.TrimSpace(name); name != "" { parts = append(parts, "--name", name) } + if model = strings.TrimSpace(model); model != "" { + // A separate operand beginning with '-' is rejected as an option. + if strings.HasPrefix(model, "-") { + parts = append(parts, "--model="+model) + } else { + parts = append(parts, "--model", model) + } + } if descriptor.RequiresAuth && len(descriptor.AuthEnvVars) > 0 { if env := strings.TrimSpace(descriptor.AuthEnvVars[0]); env != "" { parts = append(parts, "--api-key-env", env) @@ -37,7 +59,7 @@ func SetupCommand(descriptor providercatalog.Descriptor, name string, setActive if setActive { parts = append(parts, "--set-active") } - return joinCommand(parts) + return joinSetupCommand(parts) } func UseCommand(name string) string { @@ -161,6 +183,54 @@ func firstNonEmpty(values ...string) string { return "" } +// joinSetupCommand only emits arguments supported literally by POSIX shells, +// cmd.exe, and PowerShell. Shell-specific quoting cannot safely cover all three. +// Return no command when a value requires it; callers can offer interactive setup. +func joinSetupCommand(parts []string) string { + quoted := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part == "" { + continue + } + arg := setupCommandArg(part) + if value, inlineModel := strings.CutPrefix(part, "--model="); inlineModel { + // Validate the untrusted model separately from the fixed separator; + // '=' must not become an allowed character in model IDs. + arg = setupCommandArg(value) + if arg != "" { + arg = "--model=" + arg + } + } + if arg == "" { + return "" + } + quoted = append(quoted, arg) + } + return strings.Join(quoted, " ") +} + +func setupCommandArg(value string) string { + for i, r := range value { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + continue + } + switch r { + case '-', '_', '.', '/', ':', ' ': + continue + case '@': + // A leading @ starts splatting in PowerShell. + if i > 0 { + continue + } + } + return "" + } + if value == "" || strings.Contains(value, " ") { + return `"` + value + `"` + } + return value +} + func joinCommand(parts []string) string { quoted := make([]string, 0, len(parts)) for _, part := range parts { diff --git a/internal/provideronboarding/advice_test.go b/internal/provideronboarding/advice_test.go index 8f692ae27..7c616e131 100644 --- a/internal/provideronboarding/advice_test.go +++ b/internal/provideronboarding/advice_test.go @@ -1,6 +1,8 @@ package provideronboarding import ( + "os/exec" + "runtime" "strings" "testing" @@ -229,3 +231,33 @@ func assertNoSecretLeak(t *testing.T, actions []Action, secrets ...string) { } } } + +func TestSetupCommandWithModelShellRoundTrip(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell round-trip; portable command boundary is tested on every OS") + } + for _, id := range []string{"my loaded model", "unsloth/Qwen3-GGUF", "model:latest", "user/model@revision"} { + command := SetupCommandWithModel(providercatalog.Descriptor{ID: "atomic-chat-local"}, "Atomic Chat Local", id, true) + out, err := exec.Command("sh", "-c", `zero() { printf '%s\n' "$@"; }; `+command).CombinedOutput() + want := "providers\nadd\natomic-chat-local\n--name\nAtomic Chat Local\n--model\n" + id + "\n--set-active\n" + if err != nil || string(out) != want { + t.Fatalf("argument round-trip: command=%q output=%q err=%v", command, out, err) + } + } +} + +func TestSetupCommandWithModelOmitsUnsafeArguments(t *testing.T) { + for _, value := range []string{"x&calc", `a"&calc&"b`, "$(id)", "`id`", "%PATH%", "!PATH!", "a;b", "a|b", "a^b", "a>b", "a\nb", "@args", "a\u201db", "-x&calc", `-a"&calc&"b`, "-$(id)", "-%PATH%", "-model=value"} { + for _, field := range []string{"model", "name"} { + name, model := "local", "loaded/model" + if field == "model" { + model = value + } else { + name = value + } + if got := SetupCommandWithModel(providercatalog.Descriptor{ID: "atomic-chat-local"}, name, model, true); got != "" { + t.Fatalf("unsafe %s %q emitted as a shell command: %q", field, value, got) + } + } + } +} diff --git a/internal/provideronboarding/localruntime.go b/internal/provideronboarding/localruntime.go index 4f6889eaf..45933b645 100644 --- a/internal/provideronboarding/localruntime.go +++ b/internal/provideronboarding/localruntime.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Gitlawb/zero/internal/providercatalog" + "github.com/Gitlawb/zero/internal/providermodelcatalog" ) // LocalRuntime describes a local, OpenAI-compatible model server that ZERO can @@ -102,16 +103,65 @@ func DetectLocalRuntimes(ctx context.Context, options LocalDetectOptions) []Dete // SetupAction returns the no-key onboarding action for a detected local runtime. func (runtime DetectedLocalRuntime) SetupAction() Action { descriptor := providercatalog.Descriptor{ID: runtime.CatalogID, RequiresAuth: false} - command := SetupCommand(descriptor, runtime.Name, true) + model := runtime.AdoptModel() name := strings.TrimSpace(runtime.Name) if name == "" { name = runtime.CatalogID } + // Do not fall back to an unserved/default model when all advertised IDs + // were rejected. Preserve other runtimes' existing empty-response behavior. + if model == "" && (runtime.CatalogID == "atomic-chat-local" || len(runtime.Models) > 0) { + return Action{ + Label: "Load a chat model", + Detail: "Detected " + name + " on " + runtime.BaseURL + " but no usable chat model ID was discovered. Load a chat model in " + name + ", then run zero providers detect again.", + } + } + command := SetupCommandWithModel(descriptor, runtime.Name, model, true) + if command == "" { + return Action{ + Label: "Use interactive setup", + Detail: "A setup value cannot be safely included in a command for all supported shells. Run zero setup and select or enter the model there.", + } + } + detail := "Detected " + name + " on " + runtime.BaseURL + " — no API key required." + if model != "" { + detail = "Detected " + name + " on " + runtime.BaseURL + " serving " + model + " — no API key required." + } return Action{ Label: "Use local runtime", Command: command, - Detail: "Detected " + name + " on " + runtime.BaseURL + " — no API key required.", + Detail: detail, + } +} + +// AdoptModel returns an advertised model eligible for automatic chat adoption, +// preferring the catalog default when served. Shell syntax is handled when +// rendering the command, so eligible IDs are not silently replaced for quoting. +func (runtime DetectedLocalRuntime) AdoptModel() string { + want := strings.TrimSpace(runtime.DefaultModel) + first := "" + defaultAlias := "" + for _, raw := range runtime.Models { + id := strings.TrimSpace(raw) + if id == "" || providermodelcatalog.IsKnownNonCodingModelID(id) || runtime.CatalogID == "atomic-chat-local" && id == "local-model" { + continue + } + if id == want { + return id + } + // Ollama's untagged default is equivalent to its :latest spelling. + // Retain the advertised ID and prefer an exact match if one follows. + if runtime.CatalogID == "ollama" && want != "" && !strings.Contains(want, ":") && id == want+":latest" { + defaultAlias = id + } + if first == "" { + first = id + } + } + if defaultAlias != "" { + return defaultAlias } + return first } func probeLocalRuntime(ctx context.Context, client *http.Client, timeout time.Duration, candidate LocalRuntime) ([]string, bool) { diff --git a/internal/provideronboarding/localruntime_test.go b/internal/provideronboarding/localruntime_test.go index 8889cc1d4..8a2eb2e62 100644 --- a/internal/provideronboarding/localruntime_test.go +++ b/internal/provideronboarding/localruntime_test.go @@ -10,7 +10,7 @@ import ( "time" ) -func TestLocalRuntimeCandidatesCoverOllamaAndLMStudio(t *testing.T) { +func TestLocalRuntimeCandidatesCoverOllamaLMStudioAndAtomicChat(t *testing.T) { candidates := LocalRuntimeCandidates() if len(candidates) == 0 { t.Fatalf("LocalRuntimeCandidates() returned no candidates") @@ -39,6 +39,24 @@ func TestLocalRuntimeCandidatesCoverOllamaAndLMStudio(t *testing.T) { if lmstudio.RequiresKey { t.Fatalf("lmstudio candidate must not require an API key: %#v", lmstudio) } + atomicChat, ok := byCatalog["atomic-chat-local"] + if !ok { + t.Fatalf("expected an atomic-chat-local candidate, got %#v", candidates) + } + if atomicChat.BaseURL != "http://127.0.0.1:1337/v1" { + t.Fatalf("atomic-chat-local candidate BaseURL = %q, want http://127.0.0.1:1337/v1", atomicChat.BaseURL) + } + if atomicChat.DefaultModel != "local-model" { + t.Fatalf("atomic-chat-local candidate DefaultModel = %q, want local-model", atomicChat.DefaultModel) + } + if atomicChat.RequiresKey { + t.Fatalf("atomic-chat-local candidate must not require an API key: %#v", atomicChat) + } + // The hosted atomic-chat preset stays remote and key-gated, so it must never + // be probed as a local runtime. + if _, ok := byCatalog["atomic-chat"]; ok { + t.Fatalf("hosted atomic-chat must not be a local-runtime candidate, got %#v", candidates) + } } func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) { @@ -67,6 +85,145 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) { } } +// A local runtime serves whichever model the user loaded, so the adopt command +// must pin the id the probe saw. Atomic Chat pulls its catalog from Hugging +// Face, so the served id is an arbitrary repo id and never the "local-model" +// placeholder the catalog carries as DefaultModel. +func TestSetupActionPinsProbedModel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[{"id":"unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF"}]}`)) + })) + defer server.Close() + + detected := DetectLocalRuntimes(context.Background(), LocalDetectOptions{ + HTTPClient: server.Client(), + Candidates: []LocalRuntime{{ + CatalogID: "atomic-chat-local", + Name: "Atomic Chat Local", + BaseURL: server.URL + "/v1", + DefaultModel: "local-model", + }}, + }) + if len(detected) != 1 { + t.Fatalf("DetectLocalRuntimes() = %#v, want one reachable runtime", detected) + } + if got := detected[0].AdoptModel(); got != "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF" { + t.Fatalf("AdoptModel() = %q, want the probed id, not the catalog placeholder", got) + } + action := detected[0].SetupAction() + if !strings.Contains(action.Command, "--model unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF") { + t.Fatalf("SetupAction command must pin the probed model, got %q", action.Command) + } + if strings.Contains(action.Command, "local-model") { + t.Fatalf("SetupAction command must not persist the catalog placeholder, got %q", action.Command) + } +} + +// With no model IDs, Atomic Chat needs guidance instead of a failing command. +func TestSetupActionOmitsModelWhenProbeFoundNone(t *testing.T) { + runtime := DetectedLocalRuntime{ + LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", BaseURL: "http://127.0.0.1:1337/v1", DefaultModel: "local-model"}, + Reachable: true, + } + if got := runtime.AdoptModel(); got != "" { + t.Fatalf("AdoptModel() = %q, want empty", got) + } + action := runtime.SetupAction() + if strings.Contains(action.Command, "providers add") { + t.Fatalf("atomic-chat-local with no served model must not advertise a bare add command, got %q", action.Command) + } +} + +func TestSetupActionPreservesModelsWithSpaces(t *testing.T) { + runtime := DetectedLocalRuntime{ + LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local"}, + Models: []string{"my loaded model"}, + } + if got := runtime.AdoptModel(); got != "my loaded model" { + t.Fatalf("AdoptModel() = %q", got) + } + if got := runtime.SetupAction().Command; !strings.Contains(got, `--model "my loaded model"`) { + t.Fatalf("model with spaces not preserved: %q", got) + } +} + +func TestSetupActionOmitsUnsafeCommands(t *testing.T) { + for _, id := range []string{"$(touch pwned)", "`id`", "x&calc", "a \" & calc & \"b", "a;b", "a|b", "a'b", "a>b", "%PATH%", "!PATH!", "@args", "line\ncommand", "a\u201db"} { + t.Run(id, func(t *testing.T) { + runtime := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local"}, Models: []string{id, "safe-model"}} + if runtime.AdoptModel() != id { + t.Fatal("discovered model must not be silently changed") + } + action := runtime.SetupAction() + if action.Command != "" || !strings.Contains(action.Detail, "zero setup") { + t.Fatalf("unsafe command must be replaced with interactive guidance: %#v", action) + } + }) + } +} + +func TestSetupActionRejectsAtomicPlaceholder(t *testing.T) { + runtime := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: "atomic-chat-local", DefaultModel: "local-model"}, Models: []string{"local-model"}} + if runtime.AdoptModel() != "" || runtime.SetupAction().Command != "" { + t.Fatal("placeholder must not be advertised as adoptable") + } + runtime.Models = append(runtime.Models, "loaded/model") + if runtime.AdoptModel() != "loaded/model" { + t.Fatal("real model must be selected instead of placeholder") + } +} + +// A server that advertises the catalog default alongside other ids keeps the +// default, so an existing Ollama setup does not silently switch models. +func TestAdoptModelPrefersCatalogDefaultWhenServed(t *testing.T) { + runtime := DetectedLocalRuntime{ + LocalRuntime: LocalRuntime{CatalogID: "ollama", Name: "Ollama Local", DefaultModel: "llama3.1"}, + Reachable: true, + Models: []string{"qwen3:8b", "llama3.1"}, + } + if got := runtime.AdoptModel(); got != "llama3.1" { + t.Fatalf("AdoptModel() = %q, want the catalog default when the server serves it", got) + } +} + +func TestAdoptModelChatEligibilityAndOllamaDefault(t *testing.T) { + tests := []struct { + name, provider, defaultModel string + models []string + want string + }{ + {"skip embedding", "atomic-chat-local", "local-model", []string{"text-embedding-local", "qwen3-coder-30b"}, "qwen3-coder-30b"}, + {"reject nonchat default", "lmstudio", "text-embedding-local", []string{"text-embedding-local", "chat-model"}, "chat-model"}, + {"latest alias", "ollama", "llama3.1", []string{"qwen3:8b", "llama3.1:latest"}, "llama3.1:latest"}, + {"exact before alias", "ollama", "llama3.1", []string{"llama3.1:latest", "llama3.1"}, "llama3.1"}, + {"absent default", "ollama", "llama3.1", []string{"text-embedding-local", "qwen3:8b", "other-chat"}, "qwen3:8b"}, + {"different tag", "ollama", "llama3.1", []string{"qwen3:8b", "llama3.1:custom"}, "qwen3:8b"}, + {"explicit default tag", "ollama", "llama3.1:custom", []string{"qwen3:8b", "llama3.1:latest"}, "qwen3:8b"}, + {"other runtime", "lmstudio", "llama3.1", []string{"qwen3:8b", "llama3.1:latest"}, "qwen3:8b"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detected := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: tt.provider, DefaultModel: tt.defaultModel}, Models: tt.models} + if got := detected.AdoptModel(); got != tt.want { + t.Fatalf("AdoptModel() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSetupActionOmitsKnownNonChatOnlyModels(t *testing.T) { + for _, provider := range []string{"atomic-chat-local", "lmstudio", "ollama"} { + for _, defaultModel := range []string{"local-model", "text-embedding-local"} { + detected := DetectedLocalRuntime{LocalRuntime: LocalRuntime{CatalogID: provider, DefaultModel: defaultModel}, Models: []string{"text-embedding-local", "whisper-1"}} + action := detected.SetupAction() + if detected.AdoptModel() != "" || action.Command != "" || !strings.Contains(action.Detail, "zero providers detect") { + t.Fatalf("%s must offer guidance without adopting non-chat models: %+v", provider, action) + } + } + } +} + func TestDetectLocalRuntimesSkipsUnreachableRuntime(t *testing.T) { // A client whose transport always fails simulates a closed local port. failing := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { @@ -152,3 +309,14 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestAtomicDetectionWithoutUsableModelsOmitsAdoption(t *testing.T) { + for _, body := range []string{`{"data":[]}`, `not json`, strings.Repeat(" ", 256*1024) + `{"data":[{"id":"real"}]}`, `{"data":[{"id":"local-model"}]}`} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })) + detected := DetectLocalRuntimes(context.Background(), LocalDetectOptions{HTTPClient: server.Client(), Candidates: []LocalRuntime{{CatalogID: "atomic-chat-local", BaseURL: server.URL + "/v1", DefaultModel: "local-model"}}}) + server.Close() + if len(detected) != 1 || detected[0].SetupAction().Command != "" || detected[0].SetupAction().Detail == "" { + t.Fatalf("model-less detection advertised a failing action: %+v", detected) + } + } +} diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 8f4ee8bb3..bb1ab4359 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -1246,6 +1246,13 @@ func (m model) applyProviderWizard() (model, tea.Cmd) { preserveExistingCredentialReference = strings.TrimSpace(profile.APIKeyEnv) != "" || profile.APIKeyStored } + // This wizard saves directly, bypassing the CLI add/setup validation. + // Refuse Atomic Chat's fallback before constructing or persisting a provider. + if isAtomicLocalPlaceholderModel(provider.ID, profile.Model, provider.DefaultModel) { + wizard.err = "Atomic Chat serves a locally loaded model. Load a model in Atomic Chat, then pick it here; the 'local-model' placeholder will not work." + return m, nil + } + // Build and persist into LOCALS first, committing live state only once BOTH // succeed. A persist failure (read-only config, disk full) must not leave the // chat running on the new provider while the status line, m.providerProfile, @@ -2073,6 +2080,15 @@ func maskedProviderWizardKey(value string) string { return strings.Repeat("*", count) } +// isAtomicLocalPlaceholderModel rejects Atomic Chat's nonfunctional fallback. +func isAtomicLocalPlaceholderModel(providerID string, model string, defaultModel string) bool { + if providerID != "atomic-chat-local" { + return false + } + resolved := strings.TrimSpace(model) + return resolved == "" || resolved == strings.TrimSpace(defaultModel) +} + func providerWizardProfile(provider providercatalog.Descriptor, model string, apiKey string, baseURL string, profileName string) config.ProviderProfile { resolvedBaseURL := firstProviderDisplayValue(strings.TrimSpace(baseURL), provider.DefaultBaseURL) profile := config.ProviderProfile{ diff --git a/internal/tui/provider_wizard_atomic_test.go b/internal/tui/provider_wizard_atomic_test.go new file mode 100644 index 000000000..e2e9e6bb2 --- /dev/null +++ b/internal/tui/provider_wizard_atomic_test.go @@ -0,0 +1,84 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/providercatalog" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// The in-session /provider wizard persists straight through config.UpsertProvider, +// bypassing the CLI providers add/setup guard, so it re-applies the same rule via +// isAtomicLocalPlaceholderModel before saving. Persisting the "local-model" +// placeholder (or an empty model) for atomic-chat-local yields a profile that +// fails on its first request. +func TestIsAtomicLocalPlaceholderModel(t *testing.T) { + cases := []struct { + name string + providerID string + model string + defaultModel string + want bool + }{ + {"placeholder", "atomic-chat-local", "local-model", "local-model", true}, + {"empty", "atomic-chat-local", "", "local-model", true}, + {"whitespace only", "atomic-chat-local", " ", "local-model", true}, + {"real served model", "atomic-chat-local", "unsloth/gemma-4-E2B-it-GGUF", "local-model", false}, + {"other local provider is untouched", "lmstudio", "local-model", "local-model", false}, + {"remote atomic-chat is untouched", "atomic-chat", "gpt-4.1", "gpt-4.1", false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := isAtomicLocalPlaceholderModel(tt.providerID, tt.model, tt.defaultModel); got != tt.want { + t.Fatalf("isAtomicLocalPlaceholderModel(%q, %q, %q) = %v, want %v", + tt.providerID, tt.model, tt.defaultModel, got, tt.want) + } + }) + } +} + +func TestApplyProviderWizardRejectsAtomicPlaceholderBeforePersist(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_CONFIG_HOME", root) + t.Setenv("XDG_CACHE_HOME", root) + t.Setenv("APPDATA", root) + t.Setenv("LOCALAPPDATA", root) + t.Setenv("ZERO_PROVIDER", "original") + descriptor, err := providercatalog.Require("atomic-chat-local") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "config.json") + original := []byte(`{"providers":[]}`) + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + built := false + m := model{ + userConfigPath: path, + providerName: "original", + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + built = true + return nil, nil + }, + providerWizard: &providerWizardState{ + step: providerWizardStepDone, + providers: []providercatalog.Descriptor{descriptor}, + models: []providerWizardModel{{ID: "local-model"}}, + modelSource: "live", + }, + } + next, _ := m.applyProviderWizard() + if next.providerWizard == nil || !strings.Contains(next.providerWizard.err, "placeholder") || built { + t.Fatalf("placeholder reached provider construction or persistence: built=%v wizard=%+v", built, next.providerWizard) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != string(original) || next.providerName != "original" || os.Getenv("ZERO_PROVIDER") != "original" { + t.Fatalf("rejected setup mutated configuration: data=%q err=%v", data, err) + } +}