fix(subagents): use atomics for test workspace capture flags (#216)
* fix(subagents): use atomics for test workspace capture flags Capture/Cleanup run on the resident child goroutine while tests read the flags after polling manager state, racing under -race (macos CI failure in TestResidentManagerCapturesFailedWorktreePatch). Test-only change, no production code touched. * feat(ollama): support OLLAMA_API_KEY with automatic cloud endpoint (#215) * feat(ollama): support OLLAMA_API_KEY with automatic cloud endpoint Read OLLAMA_API_KEY in credential resolution, default to https://ollama.com/v1 when a real key exists and no endpoint is given, keep localhost default without a key, and expose ollama in /login with a cloud model-list probe. * fix(ollama): validate cloud keys via auth-gated probe, drop duplicate env case
- commit
- d9fd43a957014a6d0714f5eaef8854da712d6307
- author
- bnema · · 2026-09-19 12:46 UTC
- committer
- GitHub · · 2026-09-19 12:46 UTC
- parent
- 7e95a47
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
9 files · +263 -15
M README.md +18 -0
| @@ -792,6 +792,24 @@ zut --provider ollama --model qwen3.5:4b | |||
| 792 | 792 | ||
| 793 | 793 | That's it. No API key needed for local models. zut defaults to `http://localhost:11434`. | |
| 794 | 794 | ||
| 795 | + | ### Ollama Cloud with OLLAMA_API_KEY | |
| 796 | + | ||
| 797 | + | Set `OLLAMA_API_KEY` (from [ollama.com/settings/keys](https://ollama.com/settings/keys)) and zut uses Ollama Cloud automatically — no `--base-url` needed: | |
| 798 | + | ||
| 799 | + | ```bash | |
| 800 | + | export OLLAMA_API_KEY=... | |
| 801 | + | zut --provider ollama --model gemma4:31b | |
| 802 | + | ``` | |
| 803 | + | ||
| 804 | + | With a real key and no explicit endpoint, zut defaults to `https://ollama.com/v1`. Precedence: | |
| 805 | + | ||
| 806 | + | 1. `--base-url` always wins — pass `--base-url http://localhost:11434` to force local while the key is set. | |
| 807 | + | 2. A `baseUrl` in `models.json` (model- or provider-level) wins when no `--base-url` is given. | |
| 808 | + | 3. Otherwise a real key (`--api-key`, `OLLAMA_API_KEY`, or `/login`) selects the cloud endpoint. | |
| 809 | + | 4. With no key, zut stays local with no auth. | |
| 810 | + | ||
| 811 | + | You can also save the key with `/login` → api key → Ollama (stored in `auth.json`). `--api-key` overrides env and stored credentials. | |
| 812 | + | ||
| 795 | 813 | For a remote ollama instance or one behind auth: | |
| 796 | 814 | ||
| 797 | 815 | ```bash | |
| @@ -792,6 +792,24 @@ zut --provider ollama --model qwen3.5:4b | |||||
| 792 | 792 | ||||
| 793 | That's it. No API key needed for local models. zut defaults to `http://localhost:11434`. | 793 | That's it. No API key needed for local models. zut defaults to `http://localhost:11434`. | ||
| 794 | 794 | ||||
| 795 | + | ### Ollama Cloud with OLLAMA_API_KEY | |||
| 796 | + | ||||
| 797 | + | Set `OLLAMA_API_KEY` (from [ollama.com/settings/keys](https://ollama.com/settings/keys)) and zut uses Ollama Cloud automatically — no `--base-url` needed: | |||
| 798 | + | ||||
| 799 | + | ```bash | |||
| 800 | + | export OLLAMA_API_KEY=... | |||
| 801 | + | zut --provider ollama --model gemma4:31b | |||
| 802 | + | ``` | |||
| 803 | + | ||||
| 804 | + | With a real key and no explicit endpoint, zut defaults to `https://ollama.com/v1`. Precedence: | |||
| 805 | + | ||||
| 806 | + | 1. `--base-url` always wins — pass `--base-url http://localhost:11434` to force local while the key is set. | |||
| 807 | + | 2. A `baseUrl` in `models.json` (model- or provider-level) wins when no `--base-url` is given. | |||
| 808 | + | 3. Otherwise a real key (`--api-key`, `OLLAMA_API_KEY`, or `/login`) selects the cloud endpoint. | |||
| 809 | + | 4. With no key, zut stays local with no auth. | |||
| 810 | + | ||||
| 811 | + | You can also save the key with `/login` → api key → Ollama (stored in `auth.json`). `--api-key` overrides env and stored credentials. | |||
| 812 | + | ||||
| 795 | For a remote ollama instance or one behind auth: | 813 | For a remote ollama instance or one behind auth: | ||
| 796 | 814 | ||||
| 797 | ```bash | 815 | ```bash | ||
M docs/providers.md +9 -0
| @@ -104,6 +104,7 @@ show instructions and should be configured with environment variables. | |||
| 104 | 104 | | Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` | | |
| 105 | 105 | | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` | | |
| 106 | 106 | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | | |
| 107 | + | | Ollama Cloud | `OLLAMA_API_KEY` | `ollama` | | |
| 107 | 108 | | Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` | | |
| 108 | 109 | | Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` | | |
| 109 | 110 | | Groq | `GROQ_API_KEY` | `groq` | | |
| @@ -136,6 +137,14 @@ export OPENROUTER_API_KEY=... | |||
| 136 | 137 | zut --provider openrouter | |
| 137 | 138 | ``` | |
| 138 | 139 | ||
| 140 | + | ### Ollama local vs Cloud | |
| 141 | + | ||
| 142 | + | The `ollama` provider serves both local and Cloud models over the OpenAI-compatible `/v1` API. Secrets are sent only as `Authorization: Bearer` headers, never in URLs or logs. | |
| 143 | + | ||
| 144 | + | - No key: `http://localhost:11434`, no auth (local models). | |
| 145 | + | - Real key (`--api-key`, `OLLAMA_API_KEY`, or `/login` → Ollama) with no explicit endpoint: `https://ollama.com/v1` (Cloud models). | |
| 146 | + | - `--base-url` always wins; a `baseUrl` in `models.json` (model- or provider-level) wins over the automatic default. | |
| 147 | + | ||
| 139 | 148 | ### OpenCode Go model catalog | |
| 140 | 149 | ||
| 141 | 150 | OpenCode Go is synchronized instead of using a hand-maintained model list. When | |
| @@ -104,6 +104,7 @@ show instructions and should be configured with environment variables. | |||||
| 104 | | Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` | | 104 | | Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` | | ||
| 105 | | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` | | 105 | | Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` | | ||
| 106 | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | | 106 | | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | | ||
| 107 | + | | Ollama Cloud | `OLLAMA_API_KEY` | `ollama` | | |||
| 107 | | Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` | | 108 | | Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` | | ||
| 108 | | Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` | | 109 | | Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` | | ||
| 109 | | Groq | `GROQ_API_KEY` | `groq` | | 110 | | Groq | `GROQ_API_KEY` | `groq` | | ||
| @@ -136,6 +137,14 @@ export OPENROUTER_API_KEY=... | |||||
| 136 | zut --provider openrouter | 137 | zut --provider openrouter | ||
| 137 | ``` | 138 | ``` | ||
| 138 | 139 | ||||
| 140 | + | ### Ollama local vs Cloud | |||
| 141 | + | ||||
| 142 | + | The `ollama` provider serves both local and Cloud models over the OpenAI-compatible `/v1` API. Secrets are sent only as `Authorization: Bearer` headers, never in URLs or logs. | |||
| 143 | + | ||||
| 144 | + | - No key: `http://localhost:11434`, no auth (local models). | |||
| 145 | + | - Real key (`--api-key`, `OLLAMA_API_KEY`, or `/login` → Ollama) with no explicit endpoint: `https://ollama.com/v1` (Cloud models). | |||
| 146 | + | - `--base-url` always wins; a `baseUrl` in `models.json` (model- or provider-level) wins over the automatic default. | |||
| 147 | + | ||||
| 139 | ### OpenCode Go model catalog | 148 | ### OpenCode Go model catalog | ||
| 140 | 149 | ||||
| 141 | OpenCode Go is synchronized instead of using a hand-maintained model list. When | 150 | OpenCode Go is synchronized instead of using a hand-maintained model list. When | ||
M packages/agent/build.go +22 -3
| @@ -593,10 +593,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 593 | 593 | method string | |
| 594 | 594 | accountID string | |
| 595 | 595 | credErr error | |
| 596 | + | // hasRealOllamaCred tracks whether ResolveCredentialFull returned a | |
| 597 | + | // genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json). | |
| 598 | + | // It drives the cloud-vs-local endpoint default below; the dummy | |
| 599 | + | // "ollama" credential never triggers the cloud default. | |
| 600 | + | hasRealOllamaCred bool | |
| 596 | 601 | ) | |
| 597 | 602 | if provName == "ollama" { | |
| 598 | - | cred = firstNonEmpty(args.APIKey, "ollama") | |
| 599 | - | method = "apikey" | |
| 603 | + | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | |
| 604 | + | if credErr != nil { | |
| 605 | + | // Local ollama needs no key: fall back to the dummy | |
| 606 | + | // credential instead of failing resolution. | |
| 607 | + | cred, method, accountID, credErr = "ollama", "apikey", "", nil | |
| 608 | + | } else { | |
| 609 | + | hasRealOllamaCred = true | |
| 610 | + | } | |
| 600 | 611 | } else { | |
| 601 | 612 | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | |
| 602 | 613 | } | |
| @@ -817,7 +828,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 817 | 828 | args.BaseURL = resolvedModel.BaseURL | |
| 818 | 829 | } | |
| 819 | 830 | if args.BaseURL == "" && provName == "ollama" { | |
| 820 | - | args.BaseURL = "http://localhost:11434" | |
| 831 | + | // A real ollama credential with no explicit endpoint means | |
| 832 | + | // Ollama Cloud; without one, stay on the local server. | |
| 833 | + | // Explicit --base-url and model-level baseUrl (applied above) | |
| 834 | + | // both win over this default. | |
| 835 | + | if hasRealOllamaCred { | |
| 836 | + | args.BaseURL = "https://ollama.com/v1" | |
| 837 | + | } else { | |
| 838 | + | args.BaseURL = "http://localhost:11434" | |
| 839 | + | } | |
| 821 | 840 | } | |
| 822 | 841 | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | |
| 823 | 842 | managementURL, _, configErr := ResolveLlamaCPPConfig() | |
| @@ -593,10 +593,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 593 | method string | 593 | method string | ||
| 594 | accountID string | 594 | accountID string | ||
| 595 | credErr error | 595 | credErr error | ||
| 596 | + | // hasRealOllamaCred tracks whether ResolveCredentialFull returned a | |||
| 597 | + | // genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json). | |||
| 598 | + | // It drives the cloud-vs-local endpoint default below; the dummy | |||
| 599 | + | // "ollama" credential never triggers the cloud default. | |||
| 600 | + | hasRealOllamaCred bool | |||
| 596 | ) | 601 | ) | ||
| 597 | if provName == "ollama" { | 602 | if provName == "ollama" { | ||
| 598 | - | cred = firstNonEmpty(args.APIKey, "ollama") | 603 | + | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) |
| 599 | - | method = "apikey" | 604 | + | if credErr != nil { |
| 605 | + | // Local ollama needs no key: fall back to the dummy | |||
| 606 | + | // credential instead of failing resolution. | |||
| 607 | + | cred, method, accountID, credErr = "ollama", "apikey", "", nil | |||
| 608 | + | } else { | |||
| 609 | + | hasRealOllamaCred = true | |||
| 610 | + | } | |||
| 600 | } else { | 611 | } else { | ||
| 601 | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | 612 | cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) | ||
| 602 | } | 613 | } | ||
| @@ -817,7 +828,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 817 | args.BaseURL = resolvedModel.BaseURL | 828 | args.BaseURL = resolvedModel.BaseURL | ||
| 818 | } | 829 | } | ||
| 819 | if args.BaseURL == "" && provName == "ollama" { | 830 | if args.BaseURL == "" && provName == "ollama" { | ||
| 820 | - | args.BaseURL = "http://localhost:11434" | 831 | + | // A real ollama credential with no explicit endpoint means |
| 832 | + | // Ollama Cloud; without one, stay on the local server. | |||
| 833 | + | // Explicit --base-url and model-level baseUrl (applied above) | |||
| 834 | + | // both win over this default. | |||
| 835 | + | if hasRealOllamaCred { | |||
| 836 | + | args.BaseURL = "https://ollama.com/v1" | |||
| 837 | + | } else { | |||
| 838 | + | args.BaseURL = "http://localhost:11434" | |||
| 839 | + | } | |||
| 821 | } | 840 | } | ||
| 822 | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | 841 | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | ||
| 823 | managementURL, _, configErr := ResolveLlamaCPPConfig() | 842 | managementURL, _, configErr := ResolveLlamaCPPConfig() | ||
M packages/agent/config.go +3 -0
| @@ -543,6 +543,9 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||
| 543 | 543 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | |
| 544 | 544 | return v, "apikey", "", nil | |
| 545 | 545 | } | |
| 546 | + | // ollama needs no explicit case: the generic | |
| 547 | + | // normalizeCustomProviderEnvVar fallback below already maps | |
| 548 | + | // provider "ollama" to OLLAMA_API_KEY before auth.json. | |
| 546 | 549 | case "llama.cpp": | |
| 547 | 550 | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | |
| 548 | 551 | if resolveErr != nil { | |
| @@ -543,6 +543,9 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||||
| 543 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | 543 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | ||
| 544 | return v, "apikey", "", nil | 544 | return v, "apikey", "", nil | ||
| 545 | } | 545 | } | ||
| 546 | + | // ollama needs no explicit case: the generic | |||
| 547 | + | // normalizeCustomProviderEnvVar fallback below already maps | |||
| 548 | + | // provider "ollama" to OLLAMA_API_KEY before auth.json. | |||
| 546 | case "llama.cpp": | 549 | case "llama.cpp": | ||
| 547 | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | 550 | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | ||
| 548 | if resolveErr != nil { | 551 | if resolveErr != nil { | ||
A packages/agent/ollama_test.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | providerauth "github.com/bnema/zut/packages/provider/auth" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestResolveCredentialOllamaEnv(t *testing.T) { | |
| 13 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 14 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |
| 15 | + | ||
| 16 | + | cred, method, _, err := ResolveCredentialFull("ollama", "") | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatalf("ResolveCredentialFull failed: %v", err) | |
| 19 | + | } | |
| 20 | + | if cred != "ollama-cloud-key" { | |
| 21 | + | t.Errorf("cred = %q, want %q", cred, "ollama-cloud-key") | |
| 22 | + | } | |
| 23 | + | if method != "apikey" { | |
| 24 | + | t.Errorf("method = %q, want apikey", method) | |
| 25 | + | } | |
| 26 | + | if !CredentialAvailable("ollama") { | |
| 27 | + | t.Error("CredentialAvailable(ollama) = false, want true with OLLAMA_API_KEY set") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestResolveCredentialOllamaExplicitWinsOverEnv(t *testing.T) { | |
| 32 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 33 | + | t.Setenv("OLLAMA_API_KEY", "env-key") | |
| 34 | + | ||
| 35 | + | cred, _, _, err := ResolveCredentialFull("ollama", "explicit-key") | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatalf("ResolveCredentialFull failed: %v", err) | |
| 38 | + | } | |
| 39 | + | if cred != "explicit-key" { | |
| 40 | + | t.Errorf("cred = %q, want explicit-key", cred) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestResolveOllamaCloudDefaultWithKey(t *testing.T) { | |
| 45 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 46 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |
| 47 | + | ||
| 48 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | if resolved.Credential != "ollama-cloud-key" { | |
| 53 | + | t.Errorf("credential = %q, want real key", resolved.Credential) | |
| 54 | + | } | |
| 55 | + | if resolved.BaseURL != "https://ollama.com/v1" { | |
| 56 | + | t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestResolveOllamaLocalDefaultWithoutKey(t *testing.T) { | |
| 61 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 62 | + | t.Setenv("OLLAMA_API_KEY", "") | |
| 63 | + | ||
| 64 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b"}, false) | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | if resolved.Credential != "ollama" { | |
| 69 | + | t.Errorf("credential = %q, want dummy ollama", resolved.Credential) | |
| 70 | + | } | |
| 71 | + | if resolved.BaseURL != "http://localhost:11434" { | |
| 72 | + | t.Errorf("baseURL = %q, want http://localhost:11434", resolved.BaseURL) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestResolveOllamaExplicitBaseURLWinsOverCloud(t *testing.T) { | |
| 77 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 78 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |
| 79 | + | ||
| 80 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b", BaseURL: "http://localhost:11434"}, false) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if resolved.BaseURL != "http://localhost:11434" { | |
| 85 | + | t.Errorf("baseURL = %q, want explicit local URL", resolved.BaseURL) | |
| 86 | + | } | |
| 87 | + | if resolved.Credential != "ollama-cloud-key" { | |
| 88 | + | t.Errorf("credential = %q, want real key (local server ignores it)", resolved.Credential) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestResolveOllamaExplicitKeyTriggersCloud(t *testing.T) { | |
| 93 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 94 | + | t.Setenv("OLLAMA_API_KEY", "") | |
| 95 | + | ||
| 96 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b", APIKey: "explicit-key"}, false) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | if resolved.Credential != "explicit-key" { | |
| 101 | + | t.Errorf("credential = %q, want explicit-key", resolved.Credential) | |
| 102 | + | } | |
| 103 | + | if resolved.BaseURL != "https://ollama.com/v1" { | |
| 104 | + | t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestResolveOllamaModelBaseURLWinsOverCloud(t *testing.T) { | |
| 109 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 110 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |
| 111 | + | provider.SetUserModels([]provider.Model{{ | |
| 112 | + | Provider: "ollama", ID: "custom-local", DisplayName: "custom-local", | |
| 113 | + | ContextWindow: 32768, MaxOutput: 8192, | |
| 114 | + | BaseURL: "http://mylocal:11434", Source: "user", | |
| 115 | + | }}) | |
| 116 | + | t.Cleanup(func() { provider.SetUserModels(nil) }) | |
| 117 | + | ||
| 118 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "custom-local"}, false) | |
| 119 | + | if err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | if resolved.BaseURL != "http://mylocal:11434" { | |
| 123 | + | t.Errorf("baseURL = %q, want model-level URL", resolved.BaseURL) | |
| 124 | + | } | |
| 125 | + | if resolved.Credential != "ollama-cloud-key" { | |
| 126 | + | t.Errorf("credential = %q, want real key", resolved.Credential) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestResolveOllamaStoredCredTriggersCloud(t *testing.T) { | |
| 131 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 132 | + | t.Setenv("OLLAMA_API_KEY", "") | |
| 133 | + | encoded, err := json.Marshal(providerauth.Credentials{AdditionalAPIKeyCreds: map[string]providerauth.ProviderCreds{ | |
| 134 | + | "ollama": {APIKey: "stored-key"}, | |
| 135 | + | }}) | |
| 136 | + | if err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil { | |
| 140 | + | t.Fatal(err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | cred, _, _, err := ResolveCredentialFull("ollama", "") | |
| 144 | + | if err != nil { | |
| 145 | + | t.Fatalf("ResolveCredentialFull failed: %v", err) | |
| 146 | + | } | |
| 147 | + | if cred != "stored-key" { | |
| 148 | + | t.Errorf("cred = %q, want stored-key", cred) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false) | |
| 152 | + | if err != nil { | |
| 153 | + | t.Fatal(err) | |
| 154 | + | } | |
| 155 | + | if resolved.Credential != "stored-key" { | |
| 156 | + | t.Errorf("credential = %q, want stored-key", resolved.Credential) | |
| 157 | + | } | |
| 158 | + | if resolved.BaseURL != "https://ollama.com/v1" { | |
| 159 | + | t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL) | |
| 160 | + | } | |
| 161 | + | } | |
| @@ -0,0 +1,161 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "os" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | providerauth "github.com/bnema/zut/packages/provider/auth" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestResolveCredentialOllamaEnv(t *testing.T) { | |||
| 13 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 14 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |||
| 15 | + | ||||
| 16 | + | cred, method, _, err := ResolveCredentialFull("ollama", "") | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("ResolveCredentialFull failed: %v", err) | |||
| 19 | + | } | |||
| 20 | + | if cred != "ollama-cloud-key" { | |||
| 21 | + | t.Errorf("cred = %q, want %q", cred, "ollama-cloud-key") | |||
| 22 | + | } | |||
| 23 | + | if method != "apikey" { | |||
| 24 | + | t.Errorf("method = %q, want apikey", method) | |||
| 25 | + | } | |||
| 26 | + | if !CredentialAvailable("ollama") { | |||
| 27 | + | t.Error("CredentialAvailable(ollama) = false, want true with OLLAMA_API_KEY set") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestResolveCredentialOllamaExplicitWinsOverEnv(t *testing.T) { | |||
| 32 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 33 | + | t.Setenv("OLLAMA_API_KEY", "env-key") | |||
| 34 | + | ||||
| 35 | + | cred, _, _, err := ResolveCredentialFull("ollama", "explicit-key") | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatalf("ResolveCredentialFull failed: %v", err) | |||
| 38 | + | } | |||
| 39 | + | if cred != "explicit-key" { | |||
| 40 | + | t.Errorf("cred = %q, want explicit-key", cred) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestResolveOllamaCloudDefaultWithKey(t *testing.T) { | |||
| 45 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 46 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |||
| 47 | + | ||||
| 48 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | if resolved.Credential != "ollama-cloud-key" { | |||
| 53 | + | t.Errorf("credential = %q, want real key", resolved.Credential) | |||
| 54 | + | } | |||
| 55 | + | if resolved.BaseURL != "https://ollama.com/v1" { | |||
| 56 | + | t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestResolveOllamaLocalDefaultWithoutKey(t *testing.T) { | |||
| 61 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 62 | + | t.Setenv("OLLAMA_API_KEY", "") | |||
| 63 | + | ||||
| 64 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b"}, false) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | if resolved.Credential != "ollama" { | |||
| 69 | + | t.Errorf("credential = %q, want dummy ollama", resolved.Credential) | |||
| 70 | + | } | |||
| 71 | + | if resolved.BaseURL != "http://localhost:11434" { | |||
| 72 | + | t.Errorf("baseURL = %q, want http://localhost:11434", resolved.BaseURL) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestResolveOllamaExplicitBaseURLWinsOverCloud(t *testing.T) { | |||
| 77 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 78 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |||
| 79 | + | ||||
| 80 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "qwen3.5:4b", BaseURL: "http://localhost:11434"}, false) | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | if resolved.BaseURL != "http://localhost:11434" { | |||
| 85 | + | t.Errorf("baseURL = %q, want explicit local URL", resolved.BaseURL) | |||
| 86 | + | } | |||
| 87 | + | if resolved.Credential != "ollama-cloud-key" { | |||
| 88 | + | t.Errorf("credential = %q, want real key (local server ignores it)", resolved.Credential) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestResolveOllamaExplicitKeyTriggersCloud(t *testing.T) { | |||
| 93 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 94 | + | t.Setenv("OLLAMA_API_KEY", "") | |||
| 95 | + | ||||
| 96 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b", APIKey: "explicit-key"}, false) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | if resolved.Credential != "explicit-key" { | |||
| 101 | + | t.Errorf("credential = %q, want explicit-key", resolved.Credential) | |||
| 102 | + | } | |||
| 103 | + | if resolved.BaseURL != "https://ollama.com/v1" { | |||
| 104 | + | t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestResolveOllamaModelBaseURLWinsOverCloud(t *testing.T) { | |||
| 109 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 110 | + | t.Setenv("OLLAMA_API_KEY", "ollama-cloud-key") | |||
| 111 | + | provider.SetUserModels([]provider.Model{{ | |||
| 112 | + | Provider: "ollama", ID: "custom-local", DisplayName: "custom-local", | |||
| 113 | + | ContextWindow: 32768, MaxOutput: 8192, | |||
| 114 | + | BaseURL: "http://mylocal:11434", Source: "user", | |||
| 115 | + | }}) | |||
| 116 | + | t.Cleanup(func() { provider.SetUserModels(nil) }) | |||
| 117 | + | ||||
| 118 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "custom-local"}, false) | |||
| 119 | + | if err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | if resolved.BaseURL != "http://mylocal:11434" { | |||
| 123 | + | t.Errorf("baseURL = %q, want model-level URL", resolved.BaseURL) | |||
| 124 | + | } | |||
| 125 | + | if resolved.Credential != "ollama-cloud-key" { | |||
| 126 | + | t.Errorf("credential = %q, want real key", resolved.Credential) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestResolveOllamaStoredCredTriggersCloud(t *testing.T) { | |||
| 131 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 132 | + | t.Setenv("OLLAMA_API_KEY", "") | |||
| 133 | + | encoded, err := json.Marshal(providerauth.Credentials{AdditionalAPIKeyCreds: map[string]providerauth.ProviderCreds{ | |||
| 134 | + | "ollama": {APIKey: "stored-key"}, | |||
| 135 | + | }}) | |||
| 136 | + | if err != nil { | |||
| 137 | + | t.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil { | |||
| 140 | + | t.Fatal(err) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | cred, _, _, err := ResolveCredentialFull("ollama", "") | |||
| 144 | + | if err != nil { | |||
| 145 | + | t.Fatalf("ResolveCredentialFull failed: %v", err) | |||
| 146 | + | } | |||
| 147 | + | if cred != "stored-key" { | |||
| 148 | + | t.Errorf("cred = %q, want stored-key", cred) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | resolved, err := Resolve(Args{Provider: "ollama", Model: "gemma4:31b"}, false) | |||
| 152 | + | if err != nil { | |||
| 153 | + | t.Fatal(err) | |||
| 154 | + | } | |||
| 155 | + | if resolved.Credential != "stored-key" { | |||
| 156 | + | t.Errorf("credential = %q, want stored-key", resolved.Credential) | |||
| 157 | + | } | |||
| 158 | + | if resolved.BaseURL != "https://ollama.com/v1" { | |||
| 159 | + | t.Errorf("baseURL = %q, want https://ollama.com/v1", resolved.BaseURL) | |||
| 160 | + | } | |||
| 161 | + | } | |||
M packages/agent/subagents/resident_manager_test.go +20 -11
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "os/exec" | |
| 11 | 11 | "path/filepath" | |
| 12 | 12 | "strings" | |
| 13 | + | "sync/atomic" | |
| 13 | 14 | "testing" | |
| 14 | 15 | "time" | |
| 15 | 16 | ||
| @@ -517,7 +518,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) { | |||
| 517 | 518 | } | |
| 518 | 519 | time.Sleep(time.Millisecond) | |
| 519 | 520 | } | |
| 520 | - | if workspace.cleaned { | |
| 521 | + | if workspace.Cleaned() { | |
| 521 | 522 | t.Fatal("idle child worktree was removed before explicit close") | |
| 522 | 523 | } | |
| 523 | 524 | result, err := manager.Result("capture-child") | |
| @@ -534,7 +535,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) { | |||
| 534 | 535 | if err := manager.Stop(context.Background(), "capture-child"); err != nil { | |
| 535 | 536 | t.Fatal(err) | |
| 536 | 537 | } | |
| 537 | - | if !workspace.cleaned { | |
| 538 | + | if !workspace.Cleaned() { | |
| 538 | 539 | t.Fatal("successful worktree was not cleaned on stop") | |
| 539 | 540 | } | |
| 540 | 541 | } | |
| @@ -560,7 +561,7 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) { | |||
| 560 | 561 | } | |
| 561 | 562 | // A non-canceled failure still captures partial work, but the turn | |
| 562 | 563 | // stays failed: capture never converts failure to success. | |
| 563 | - | if !workspace.captured { | |
| 564 | + | if !workspace.Captured() { | |
| 564 | 565 | t.Fatal("failed worktree was not captured") | |
| 565 | 566 | } | |
| 566 | 567 | snapshot := manager.Snapshot()[0] | |
| @@ -597,8 +598,8 @@ func TestResidentManagerRetainsFailedWorktree(t *testing.T) { | |||
| 597 | 598 | } | |
| 598 | 599 | // Non-canceled failures capture partial work for recovery, but the | |
| 599 | 600 | // worktree is retained (never cleaned) until an explicit stop or close. | |
| 600 | - | if !workspace.captured || workspace.cleaned { | |
| 601 | - | t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned) | |
| 601 | + | if !workspace.Captured() || workspace.Cleaned() { | |
| 602 | + | t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.Captured(), workspace.Cleaned()) | |
| 602 | 603 | } | |
| 603 | 604 | } | |
| 604 | 605 | ||
| @@ -646,21 +647,29 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) { | |||
| 646 | 647 | } | |
| 647 | 648 | } | |
| 648 | 649 | ||
| 650 | + | // testResidentWorkspace flags are atomic because Capture/Cleanup run on | |
| 651 | + | // the resident child's goroutine while tests read them after polling | |
| 652 | + | // manager state (no happens-before edge the race detector can see). | |
| 649 | 653 | type testResidentWorkspace struct { | |
| 650 | - | dir string | |
| 651 | - | mode WorkspaceMode | |
| 652 | - | capture WorkspaceCapture | |
| 653 | - | captured, cleaned bool | |
| 654 | + | dir string | |
| 655 | + | mode WorkspaceMode | |
| 656 | + | capture WorkspaceCapture | |
| 657 | + | ||
| 658 | + | captured atomic.Bool | |
| 659 | + | cleaned atomic.Bool | |
| 654 | 660 | } | |
| 655 | 661 | ||
| 662 | + | func (w *testResidentWorkspace) Captured() bool { return w.captured.Load() } | |
| 663 | + | func (w *testResidentWorkspace) Cleaned() bool { return w.cleaned.Load() } | |
| 664 | + | ||
| 656 | 665 | func (w *testResidentWorkspace) Dir() string { return w.dir } | |
| 657 | 666 | func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" } | |
| 658 | 667 | func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode } | |
| 659 | 668 | func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) { | |
| 660 | - | w.captured = true | |
| 669 | + | w.captured.Store(true) | |
| 661 | 670 | return w.capture, nil | |
| 662 | 671 | } | |
| 663 | - | func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned = true; return nil } | |
| 672 | + | func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store(true); return nil } | |
| 664 | 673 | ||
| 665 | 674 | func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) { | |
| 666 | 675 | root := t.TempDir() | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "os/exec" | 10 | "os/exec" | ||
| 11 | "path/filepath" | 11 | "path/filepath" | ||
| 12 | "strings" | 12 | "strings" | ||
| 13 | + | "sync/atomic" | |||
| 13 | "testing" | 14 | "testing" | ||
| 14 | "time" | 15 | "time" | ||
| 15 | 16 | ||||
| @@ -517,7 +518,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) { | |||||
| 517 | } | 518 | } | ||
| 518 | time.Sleep(time.Millisecond) | 519 | time.Sleep(time.Millisecond) | ||
| 519 | } | 520 | } | ||
| 520 | - | if workspace.cleaned { | 521 | + | if workspace.Cleaned() { |
| 521 | t.Fatal("idle child worktree was removed before explicit close") | 522 | t.Fatal("idle child worktree was removed before explicit close") | ||
| 522 | } | 523 | } | ||
| 523 | result, err := manager.Result("capture-child") | 524 | result, err := manager.Result("capture-child") | ||
| @@ -534,7 +535,7 @@ func TestResidentManagerCapturesSuccessfulWorktreeBeforeCleanup(t *testing.T) { | |||||
| 534 | if err := manager.Stop(context.Background(), "capture-child"); err != nil { | 535 | if err := manager.Stop(context.Background(), "capture-child"); err != nil { | ||
| 535 | t.Fatal(err) | 536 | t.Fatal(err) | ||
| 536 | } | 537 | } | ||
| 537 | - | if !workspace.cleaned { | 538 | + | if !workspace.Cleaned() { |
| 538 | t.Fatal("successful worktree was not cleaned on stop") | 539 | t.Fatal("successful worktree was not cleaned on stop") | ||
| 539 | } | 540 | } | ||
| 540 | } | 541 | } | ||
| @@ -560,7 +561,7 @@ func TestResidentManagerCapturesFailedWorktreePatch(t *testing.T) { | |||||
| 560 | } | 561 | } | ||
| 561 | // A non-canceled failure still captures partial work, but the turn | 562 | // A non-canceled failure still captures partial work, but the turn | ||
| 562 | // stays failed: capture never converts failure to success. | 563 | // stays failed: capture never converts failure to success. | ||
| 563 | - | if !workspace.captured { | 564 | + | if !workspace.Captured() { |
| 564 | t.Fatal("failed worktree was not captured") | 565 | t.Fatal("failed worktree was not captured") | ||
| 565 | } | 566 | } | ||
| 566 | snapshot := manager.Snapshot()[0] | 567 | snapshot := manager.Snapshot()[0] | ||
| @@ -597,8 +598,8 @@ func TestResidentManagerRetainsFailedWorktree(t *testing.T) { | |||||
| 597 | } | 598 | } | ||
| 598 | // Non-canceled failures capture partial work for recovery, but the | 599 | // Non-canceled failures capture partial work for recovery, but the | ||
| 599 | // worktree is retained (never cleaned) until an explicit stop or close. | 600 | // worktree is retained (never cleaned) until an explicit stop or close. | ||
| 600 | - | if !workspace.captured || workspace.cleaned { | 601 | + | if !workspace.Captured() || workspace.Cleaned() { |
| 601 | - | t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.captured, workspace.cleaned) | 602 | + | t.Fatalf("failed worktree captured=%t cleaned=%t", workspace.Captured(), workspace.Cleaned()) |
| 602 | } | 603 | } | ||
| 603 | } | 604 | } | ||
| 604 | 605 | ||||
| @@ -646,21 +647,29 @@ func TestResidentManagerCapturesRealWorktreePatch(t *testing.T) { | |||||
| 646 | } | 647 | } | ||
| 647 | } | 648 | } | ||
| 648 | 649 | ||||
| 650 | + | // testResidentWorkspace flags are atomic because Capture/Cleanup run on | |||
| 651 | + | // the resident child's goroutine while tests read them after polling | |||
| 652 | + | // manager state (no happens-before edge the race detector can see). | |||
| 649 | type testResidentWorkspace struct { | 653 | type testResidentWorkspace struct { | ||
| 650 | - | dir string | 654 | + | dir string |
| 651 | - | mode WorkspaceMode | 655 | + | mode WorkspaceMode |
| 652 | - | capture WorkspaceCapture | 656 | + | capture WorkspaceCapture |
| 653 | - | captured, cleaned bool | 657 | + | |
| 658 | + | captured atomic.Bool | |||
| 659 | + | cleaned atomic.Bool | |||
| 654 | } | 660 | } | ||
| 655 | 661 | ||||
| 662 | + | func (w *testResidentWorkspace) Captured() bool { return w.captured.Load() } | |||
| 663 | + | func (w *testResidentWorkspace) Cleaned() bool { return w.cleaned.Load() } | |||
| 664 | + | ||||
| 656 | func (w *testResidentWorkspace) Dir() string { return w.dir } | 665 | func (w *testResidentWorkspace) Dir() string { return w.dir } | ||
| 657 | func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" } | 666 | func (w *testResidentWorkspace) RepositoryRoot() string { return "/repo" } | ||
| 658 | func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode } | 667 | func (w *testResidentWorkspace) Mode() WorkspaceMode { return w.mode } | ||
| 659 | func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) { | 668 | func (w *testResidentWorkspace) Capture(context.Context) (WorkspaceCapture, error) { | ||
| 660 | - | w.captured = true | 669 | + | w.captured.Store(true) |
| 661 | return w.capture, nil | 670 | return w.capture, nil | ||
| 662 | } | 671 | } | ||
| 663 | - | func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned = true; return nil } | 672 | + | func (w *testResidentWorkspace) Cleanup(context.Context) error { w.cleaned.Store(true); return nil } |
| 664 | 673 | ||||
| 665 | func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) { | 674 | func TestResidentManagerRejectsDuplicateBeforeSecondJournalAcceptance(t *testing.T) { | ||
| 666 | root := t.TempDir() | 675 | root := t.TempDir() | ||
M packages/provider/auth/auth_test.go +13 -0
| @@ -11,6 +11,19 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | func TestAPIKeyProvidersIncludesOllama(t *testing.T) { | |
| 15 | + | found := false | |
| 16 | + | for _, p := range APIKeyProviders() { | |
| 17 | + | if p == "ollama" { | |
| 18 | + | found = true | |
| 19 | + | break | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | if !found { | |
| 23 | + | t.Fatalf("APIKeyProviders() = %v, want ollama listed", APIKeyProviders()) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 14 | 27 | func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) { | |
| 15 | 28 | SetExtraAPIKeyProviders([]string{"my-company"}) | |
| 16 | 29 | t.Cleanup(func() { SetExtraAPIKeyProviders(nil) }) | |
| @@ -11,6 +11,19 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | func TestAPIKeyProvidersIncludesOllama(t *testing.T) { | |||
| 15 | + | found := false | |||
| 16 | + | for _, p := range APIKeyProviders() { | |||
| 17 | + | if p == "ollama" { | |||
| 18 | + | found = true | |||
| 19 | + | break | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | if !found { | |||
| 23 | + | t.Fatalf("APIKeyProviders() = %v, want ollama listed", APIKeyProviders()) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 14 | func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) { | 27 | func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) { | ||
| 15 | SetExtraAPIKeyProviders([]string{"my-company"}) | 28 | SetExtraAPIKeyProviders([]string{"my-company"}) | ||
| 16 | t.Cleanup(func() { SetExtraAPIKeyProviders(nil) }) | 29 | t.Cleanup(func() { SetExtraAPIKeyProviders(nil) }) | ||
M packages/provider/auth/probe.go +16 -0
| @@ -45,6 +45,17 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||
| 45 | 45 | return err | |
| 46 | 46 | } | |
| 47 | 47 | req.Header.Set("authorization", "Bearer "+key) | |
| 48 | + | case "ollama": | |
| 49 | + | // GET /v1/models is public on ollama.com (200 without a key), | |
| 50 | + | // so it cannot validate one. POST /v1/chat/completions is | |
| 51 | + | // auth-gated: 401/403 means the key was rejected. The empty | |
| 52 | + | // body is enough — auth runs before request validation. | |
| 53 | + | req, err = http.NewRequestWithContext(ctx, "POST", "https://ollama.com/v1/chat/completions", strings.NewReader("{}")) | |
| 54 | + | if err != nil { | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | req.Header.Set("authorization", "Bearer "+key) | |
| 58 | + | req.Header.Set("content-type", "application/json") | |
| 48 | 59 | case "google": | |
| 49 | 60 | // Google Generative Language: list models with the API key. | |
| 50 | 61 | // Accepts the key via x-goog-api-key header (preferred over | |
| @@ -212,6 +223,11 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||
| 212 | 223 | if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { | |
| 213 | 224 | return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode) | |
| 214 | 225 | } | |
| 226 | + | if provider == "ollama" && resp.StatusCode == http.StatusBadRequest { | |
| 227 | + | // Chat-completions probe with an empty body: auth passed, | |
| 228 | + | // request validation failed. Key is valid. | |
| 229 | + | return nil | |
| 230 | + | } | |
| 215 | 231 | if resp.StatusCode >= 400 { | |
| 216 | 232 | return fmt.Errorf("%s http %d", provider, resp.StatusCode) | |
| 217 | 233 | } | |
| @@ -45,6 +45,17 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 45 | return err | 45 | return err | ||
| 46 | } | 46 | } | ||
| 47 | req.Header.Set("authorization", "Bearer "+key) | 47 | req.Header.Set("authorization", "Bearer "+key) | ||
| 48 | + | case "ollama": | |||
| 49 | + | // GET /v1/models is public on ollama.com (200 without a key), | |||
| 50 | + | // so it cannot validate one. POST /v1/chat/completions is | |||
| 51 | + | // auth-gated: 401/403 means the key was rejected. The empty | |||
| 52 | + | // body is enough — auth runs before request validation. | |||
| 53 | + | req, err = http.NewRequestWithContext(ctx, "POST", "https://ollama.com/v1/chat/completions", strings.NewReader("{}")) | |||
| 54 | + | if err != nil { | |||
| 55 | + | return err | |||
| 56 | + | } | |||
| 57 | + | req.Header.Set("authorization", "Bearer "+key) | |||
| 58 | + | req.Header.Set("content-type", "application/json") | |||
| 48 | case "google": | 59 | case "google": | ||
| 49 | // Google Generative Language: list models with the API key. | 60 | // Google Generative Language: list models with the API key. | ||
| 50 | // Accepts the key via x-goog-api-key header (preferred over | 61 | // Accepts the key via x-goog-api-key header (preferred over | ||
| @@ -212,6 +223,11 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 212 | if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { | 223 | if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { | ||
| 213 | return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode) | 224 | return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode) | ||
| 214 | } | 225 | } | ||
| 226 | + | if provider == "ollama" && resp.StatusCode == http.StatusBadRequest { | |||
| 227 | + | // Chat-completions probe with an empty body: auth passed, | |||
| 228 | + | // request validation failed. Key is valid. | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 215 | if resp.StatusCode >= 400 { | 231 | if resp.StatusCode >= 400 { | ||
| 216 | return fmt.Errorf("%s http %d", provider, resp.StatusCode) | 232 | return fmt.Errorf("%s http %d", provider, resp.StatusCode) | ||
| 217 | } | 233 | } | ||
M packages/provider/auth/server.go +1 -1
| @@ -136,7 +136,7 @@ func apiKeyProviderMessage() string { | |||
| 136 | 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | |
| 137 | 137 | func APIKeyProviders() []string { | |
| 138 | 138 | return []string{ | |
| 139 | - | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", | |
| 139 | + | "anthropic", "openai", "kimi", "deepseek", "google", "ollama", "llama.cpp", | |
| 140 | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | |
| 141 | 141 | "huggingface", "openrouter", "mistral", "zai", | |
| 142 | 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | |
| @@ -136,7 +136,7 @@ func apiKeyProviderMessage() string { | |||||
| 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | ||
| 137 | func APIKeyProviders() []string { | 137 | func APIKeyProviders() []string { | ||
| 138 | return []string{ | 138 | return []string{ | ||
| 139 | - | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", | 139 | + | "anthropic", "openai", "kimi", "deepseek", "google", "ollama", "llama.cpp", |
| 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | ||
| 141 | "huggingface", "openrouter", "mistral", "zai", | 141 | "huggingface", "openrouter", "mistral", "zai", | ||
| 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | ||