detached 076e2bb

fix(ollama): discover cloud models (#217)

commit
076e2bbda00480443b8aa0671ffa3b6580c21baf
author
bnema · · 2026-09-19 13:28 UTC
committer
GitHub · · 2026-09-19 13:28 UTC
parent
d9fd43a
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.40.0
5 files · +141 -2
M packages/agent/build.go +1 -1
@@ -833,7 +833,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
833833 // Explicit --base-url and model-level baseUrl (applied above)
834834 // both win over this default.
835835 if hasRealOllamaCred {
836- args.BaseURL = "https://ollama.com/v1"
836+ args.BaseURL = provider.OllamaCloudBaseURL
837837 } else {
838838 args.BaseURL = "http://localhost:11434"
839839 }
@@ -833,7 +833,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
833 // Explicit --base-url and model-level baseUrl (applied above)833 // Explicit --base-url and model-level baseUrl (applied above)
834 // both win over this default.834 // both win over this default.
835 if hasRealOllamaCred {835 if hasRealOllamaCred {
836- args.BaseURL = "https://ollama.com/v1"836+ args.BaseURL = provider.OllamaCloudBaseURL
837 } else {837 } else {
838 args.BaseURL = "http://localhost:11434"838 args.BaseURL = "http://localhost:11434"
839 }839 }
M packages/agent/modelsync.go +27 -1
@@ -16,6 +16,7 @@ import (
1616
1717 var (
1818 discoverOpenCodeGoFn = provider.DiscoverOpenCodeGo
19+ discoverOllamaFn = provider.DiscoverOllama
1920 modelCatalogMu sync.Mutex
2021 )
2122
@@ -115,6 +116,9 @@ func currentModelProviderScopesForBaseURL(openCodeGoBaseURL string) map[string]s
115116 if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOpenCodeGo, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
116117 scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(key, openCodeGoBaseURL)
117118 }
119+ if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOllama, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
120+ scopes[provider.ProviderOllama] = credentialScopeForEndpoint(key, provider.OllamaCloudBaseURL)
121+ }
118122 return scopes
119123 }
120124
@@ -140,7 +144,10 @@ func modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL strin
140144 }
141145 scopes := currentModelProviderScopesForBaseURL(openCodeGoBaseURL)
142146 if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
143- scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(explicitAPIKey, explicitBaseURL)
147+ scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(explicitAPIKey, firstNonEmpty(explicitBaseURL, provider.OpenCodeGoDefaultBaseURL))
148+ }
149+ if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
150+ scopes[provider.ProviderOllama] = credentialScopeForEndpoint(explicitAPIKey, firstNonEmpty(explicitBaseURL, provider.OllamaCloudBaseURL))
144151 }
145152 return scopes
146153 }
@@ -498,6 +505,25 @@ func refreshModelsWithContext(parent context.Context, explicitProvider, explicit
498505 }
499506 }
500507 }
508+ if onlyProvider == "" {
509+ var ollamaKey string
510+ if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
511+ ollamaKey = explicitAPIKey
512+ } else if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOllama, commandMode); err == nil && method == "apikey" {
513+ ollamaKey = cred
514+ }
515+ if ollamaKey != "" {
516+ markEligible(provider.ProviderOllama)
517+ baseURL := provider.OllamaCloudBaseURL
518+ if explicitProvider == provider.ProviderOllama && explicitBaseURL != "" {
519+ baseURL = explicitBaseURL
520+ }
521+ if live, err := discoverOllamaFn(ctx, ollamaKey, baseURL); err == nil {
522+ scope := credentialScopeForEndpoint(ollamaKey, baseURL)
523+ recordDiscovery(provider.ProviderOllama, live, false, scope)
524+ }
525+ }
526+ }
501527 if onlyProvider == "" || onlyProvider == provider.ProviderOpenAICodex {
502528 if cred, method, accountID, err := resolveCredentialFull(ctx, provider.ProviderOpenAICodex, "", commandMode); err == nil && method == "oauth" {
503529 markEligible(provider.ProviderOpenAICodex)
@@ -16,6 +16,7 @@ import (
16 16
17 var (17 var (
18 discoverOpenCodeGoFn = provider.DiscoverOpenCodeGo18 discoverOpenCodeGoFn = provider.DiscoverOpenCodeGo
19+ discoverOllamaFn = provider.DiscoverOllama
19 modelCatalogMu sync.Mutex20 modelCatalogMu sync.Mutex
20 )21 )
21 22
@@ -115,6 +116,9 @@ func currentModelProviderScopesForBaseURL(openCodeGoBaseURL string) map[string]s
115 if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOpenCodeGo, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {116 if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOpenCodeGo, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
116 scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(key, openCodeGoBaseURL)117 scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(key, openCodeGoBaseURL)
117 }118 }
119+ if key, method, _, err := resolveCredentialFull(context.Background(), provider.ProviderOllama, "", apiKeyCommandSkip); err == nil && method == "apikey" && key != "" {
120+ scopes[provider.ProviderOllama] = credentialScopeForEndpoint(key, provider.OllamaCloudBaseURL)
121+ }
118 return scopes122 return scopes
119 }123 }
120 124
@@ -140,7 +144,10 @@ func modelProviderScopes(explicitProvider, explicitAPIKey, explicitBaseURL strin
140 }144 }
141 scopes := currentModelProviderScopesForBaseURL(openCodeGoBaseURL)145 scopes := currentModelProviderScopesForBaseURL(openCodeGoBaseURL)
142 if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {146 if explicitProvider == provider.ProviderOpenCodeGo && explicitAPIKey != "" {
143- scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(explicitAPIKey, explicitBaseURL)147+ scopes[provider.ProviderOpenCodeGo] = credentialScopeForEndpoint(explicitAPIKey, firstNonEmpty(explicitBaseURL, provider.OpenCodeGoDefaultBaseURL))
148+ }
149+ if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
150+ scopes[provider.ProviderOllama] = credentialScopeForEndpoint(explicitAPIKey, firstNonEmpty(explicitBaseURL, provider.OllamaCloudBaseURL))
144 }151 }
145 return scopes152 return scopes
146 }153 }
@@ -498,6 +505,25 @@ func refreshModelsWithContext(parent context.Context, explicitProvider, explicit
498 }505 }
499 }506 }
500 }507 }
508+ if onlyProvider == "" {
509+ var ollamaKey string
510+ if explicitProvider == provider.ProviderOllama && explicitAPIKey != "" {
511+ ollamaKey = explicitAPIKey
512+ } else if cred, method, err := resolveCredentialForCatalog(ctx, provider.ProviderOllama, commandMode); err == nil && method == "apikey" {
513+ ollamaKey = cred
514+ }
515+ if ollamaKey != "" {
516+ markEligible(provider.ProviderOllama)
517+ baseURL := provider.OllamaCloudBaseURL
518+ if explicitProvider == provider.ProviderOllama && explicitBaseURL != "" {
519+ baseURL = explicitBaseURL
520+ }
521+ if live, err := discoverOllamaFn(ctx, ollamaKey, baseURL); err == nil {
522+ scope := credentialScopeForEndpoint(ollamaKey, baseURL)
523+ recordDiscovery(provider.ProviderOllama, live, false, scope)
524+ }
525+ }
526+ }
501 if onlyProvider == "" || onlyProvider == provider.ProviderOpenAICodex {527 if onlyProvider == "" || onlyProvider == provider.ProviderOpenAICodex {
502 if cred, method, accountID, err := resolveCredentialFull(ctx, provider.ProviderOpenAICodex, "", commandMode); err == nil && method == "oauth" {528 if cred, method, accountID, err := resolveCredentialFull(ctx, provider.ProviderOpenAICodex, "", commandMode); err == nil && method == "oauth" {
503 markEligible(provider.ProviderOpenAICodex)529 markEligible(provider.ProviderOpenAICodex)
M packages/agent/modelsync_test.go +37 -0
@@ -139,6 +139,43 @@ func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T)
139139 }
140140 }
141141
142+func TestRefreshModelsDiscoversOllamaWithExplicitKeyAndEndpointScope(t *testing.T) {
143+ home := t.TempDir()
144+ t.Setenv("ZUT_HOME", home)
145+ t.Setenv("OLLAMA_API_KEY", "")
146+ provider.SetLiveModels(nil)
147+ t.Cleanup(func() { provider.SetLiveModels(nil) })
148+
149+ previous := discoverOllamaFn
150+ t.Cleanup(func() { discoverOllamaFn = previous })
151+ var gotKey, gotBaseURL string
152+ discoverOllamaFn = func(_ context.Context, key, baseURL string) ([]provider.Model, error) {
153+ gotKey, gotBaseURL = key, baseURL
154+ return []provider.Model{{Provider: provider.ProviderOllama, ID: "cloud-model", Source: "live"}}, nil
155+ }
156+
157+ baseURL := "https://private.example/v4"
158+ refreshModels(provider.ProviderOllama, "explicit-key", baseURL, "")
159+ if gotKey != "explicit-key" || gotBaseURL != baseURL {
160+ t.Fatalf("discovery key/baseURL = %q, %q", gotKey, gotBaseURL)
161+ }
162+ model, err := provider.FindModel(provider.ProviderOllama, "cloud-model")
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ if model.Provider != provider.ProviderOllama {
167+ t.Fatalf("model = %+v", model)
168+ }
169+ cache, err := provider.LoadCache(filepath.Join(home, "models-cache.json"))
170+ if err != nil {
171+ t.Fatal(err)
172+ }
173+ wantScope := credentialScopeForEndpoint("explicit-key", baseURL)
174+ if cache.ProviderScopes[provider.ProviderOllama] != wantScope {
175+ t.Fatalf("ollama scope = %q, want %q", cache.ProviderScopes[provider.ProviderOllama], wantScope)
176+ }
177+}
178+
142179 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
143180 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144181 if r.URL.Path != "/models" {
@@ -139,6 +139,43 @@ func TestFilterCacheByProviderScopesDropsLegacyAuthoritativeCodex(t *testing.T)
139 }139 }
140 }140 }
141 141
142+func TestRefreshModelsDiscoversOllamaWithExplicitKeyAndEndpointScope(t *testing.T) {
143+ home := t.TempDir()
144+ t.Setenv("ZUT_HOME", home)
145+ t.Setenv("OLLAMA_API_KEY", "")
146+ provider.SetLiveModels(nil)
147+ t.Cleanup(func() { provider.SetLiveModels(nil) })
148+
149+ previous := discoverOllamaFn
150+ t.Cleanup(func() { discoverOllamaFn = previous })
151+ var gotKey, gotBaseURL string
152+ discoverOllamaFn = func(_ context.Context, key, baseURL string) ([]provider.Model, error) {
153+ gotKey, gotBaseURL = key, baseURL
154+ return []provider.Model{{Provider: provider.ProviderOllama, ID: "cloud-model", Source: "live"}}, nil
155+ }
156+
157+ baseURL := "https://private.example/v4"
158+ refreshModels(provider.ProviderOllama, "explicit-key", baseURL, "")
159+ if gotKey != "explicit-key" || gotBaseURL != baseURL {
160+ t.Fatalf("discovery key/baseURL = %q, %q", gotKey, gotBaseURL)
161+ }
162+ model, err := provider.FindModel(provider.ProviderOllama, "cloud-model")
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ if model.Provider != provider.ProviderOllama {
167+ t.Fatalf("model = %+v", model)
168+ }
169+ cache, err := provider.LoadCache(filepath.Join(home, "models-cache.json"))
170+ if err != nil {
171+ t.Fatal(err)
172+ }
173+ wantScope := credentialScopeForEndpoint("explicit-key", baseURL)
174+ if cache.ProviderScopes[provider.ProviderOllama] != wantScope {
175+ t.Fatalf("ollama scope = %q, want %q", cache.ProviderScopes[provider.ProviderOllama], wantScope)
176+ }
177+}
178+
142 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {179 func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
143 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {180 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144 if r.URL.Path != "/models" {181 if r.URL.Path != "/models" {
M packages/provider/discover.go +41 -0
@@ -114,6 +114,47 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error
114114 return out, nil
115115 }
116116
117+const OllamaCloudBaseURL = "https://ollama.com/v1"
118+
119+// DiscoverOllama lists the public Ollama Cloud catalog. Ollama model IDs do
120+// not follow OpenAI's gpt-/o* naming convention, so discovery keeps every ID.
121+func DiscoverOllama(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
122+ if baseURL == "" {
123+ baseURL = OllamaCloudBaseURL
124+ }
125+ client := &http.Client{Timeout: 15 * time.Second}
126+ baseURL = strings.TrimRight(baseURL, "/")
127+ url := baseURL + "/v1/models"
128+ if versionSegmentSuffix.MatchString(baseURL) {
129+ url = baseURL + "/models"
130+ }
131+ body, err := fetchDiscoveryJSON(ctx, client, url, "Bearer "+apiKey)
132+ if err != nil {
133+ return nil, fmt.Errorf("ollama discover: %w", err)
134+ }
135+ var page struct {
136+ Data []struct {
137+ ID string `json:"id"`
138+ } `json:"data"`
139+ }
140+ if err := json.Unmarshal(body, &page); err != nil {
141+ return nil, fmt.Errorf("ollama discover parse: %w", err)
142+ }
143+ out := make([]Model, 0, len(page.Data))
144+ for _, item := range page.Data {
145+ if item.ID == "" {
146+ continue
147+ }
148+ out = append(out, Model{
149+ Provider: "ollama",
150+ ID: item.ID,
151+ DisplayName: item.ID,
152+ Source: "live",
153+ })
154+ }
155+ return out, nil
156+}
157+
117158 const (
118159 modelsDevAPIURL = "https://models.dev/api.json"
119160 OpenCodeGoDefaultBaseURL = "https://opencode.ai/zen/go/v1"
@@ -114,6 +114,47 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error
114 return out, nil114 return out, nil
115 }115 }
116 116
117+const OllamaCloudBaseURL = "https://ollama.com/v1"
118+
119+// DiscoverOllama lists the public Ollama Cloud catalog. Ollama model IDs do
120+// not follow OpenAI's gpt-/o* naming convention, so discovery keeps every ID.
121+func DiscoverOllama(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
122+ if baseURL == "" {
123+ baseURL = OllamaCloudBaseURL
124+ }
125+ client := &http.Client{Timeout: 15 * time.Second}
126+ baseURL = strings.TrimRight(baseURL, "/")
127+ url := baseURL + "/v1/models"
128+ if versionSegmentSuffix.MatchString(baseURL) {
129+ url = baseURL + "/models"
130+ }
131+ body, err := fetchDiscoveryJSON(ctx, client, url, "Bearer "+apiKey)
132+ if err != nil {
133+ return nil, fmt.Errorf("ollama discover: %w", err)
134+ }
135+ var page struct {
136+ Data []struct {
137+ ID string `json:"id"`
138+ } `json:"data"`
139+ }
140+ if err := json.Unmarshal(body, &page); err != nil {
141+ return nil, fmt.Errorf("ollama discover parse: %w", err)
142+ }
143+ out := make([]Model, 0, len(page.Data))
144+ for _, item := range page.Data {
145+ if item.ID == "" {
146+ continue
147+ }
148+ out = append(out, Model{
149+ Provider: "ollama",
150+ ID: item.ID,
151+ DisplayName: item.ID,
152+ Source: "live",
153+ })
154+ }
155+ return out, nil
156+}
157+
117 const (158 const (
118 modelsDevAPIURL = "https://models.dev/api.json"159 modelsDevAPIURL = "https://models.dev/api.json"
119 OpenCodeGoDefaultBaseURL = "https://opencode.ai/zen/go/v1"160 OpenCodeGoDefaultBaseURL = "https://opencode.ai/zen/go/v1"
M packages/provider/discover_test.go +35 -0
@@ -53,6 +53,41 @@ func TestDiscoverOpenAICodex(t *testing.T) {
5353 }
5454 }
5555
56+func TestDiscoverOllamaKeepsCloudModelIDs(t *testing.T) {
57+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+ if r.URL.Path != "/v1/models" {
59+ t.Errorf("path = %q, want /v1/models", r.URL.Path)
60+ }
61+ if got := r.Header.Get("authorization"); got != "Bearer ollama-key" {
62+ t.Errorf("authorization = %q", got)
63+ }
64+ _, _ = io.WriteString(w, `{"data":[{"id":"qwen3.5:397b-cloud"},{"id":"gemma3:27b"}]}`)
65+ }))
66+ defer srv.Close()
67+
68+ got, err := DiscoverOllama(context.Background(), "ollama-key", srv.URL)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if len(got) != 2 || got[0].Provider != "ollama" || got[0].ID != "qwen3.5:397b-cloud" || got[1].ID != "gemma3:27b" {
73+ t.Fatalf("models = %+v", got)
74+ }
75+}
76+
77+func TestDiscoverOllamaAcceptsVersionedBaseURL(t *testing.T) {
78+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+ if r.URL.Path != "/v1/models" {
80+ t.Errorf("path = %q, want /v1/models", r.URL.Path)
81+ }
82+ _, _ = io.WriteString(w, `{"data":[]}`)
83+ }))
84+ defer srv.Close()
85+
86+ if _, err := DiscoverOllama(context.Background(), "ollama-key", srv.URL+"/v1"); err != nil {
87+ t.Fatal(err)
88+ }
89+}
90+
5691 func TestDiscoverOpenAICodexRejectsHTTPError(t *testing.T) {
5792 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5893 http.Error(w, "denied", http.StatusUnauthorized)
@@ -53,6 +53,41 @@ func TestDiscoverOpenAICodex(t *testing.T) {
53 }53 }
54 }54 }
55 55
56+func TestDiscoverOllamaKeepsCloudModelIDs(t *testing.T) {
57+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+ if r.URL.Path != "/v1/models" {
59+ t.Errorf("path = %q, want /v1/models", r.URL.Path)
60+ }
61+ if got := r.Header.Get("authorization"); got != "Bearer ollama-key" {
62+ t.Errorf("authorization = %q", got)
63+ }
64+ _, _ = io.WriteString(w, `{"data":[{"id":"qwen3.5:397b-cloud"},{"id":"gemma3:27b"}]}`)
65+ }))
66+ defer srv.Close()
67+
68+ got, err := DiscoverOllama(context.Background(), "ollama-key", srv.URL)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if len(got) != 2 || got[0].Provider != "ollama" || got[0].ID != "qwen3.5:397b-cloud" || got[1].ID != "gemma3:27b" {
73+ t.Fatalf("models = %+v", got)
74+ }
75+}
76+
77+func TestDiscoverOllamaAcceptsVersionedBaseURL(t *testing.T) {
78+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79+ if r.URL.Path != "/v1/models" {
80+ t.Errorf("path = %q, want /v1/models", r.URL.Path)
81+ }
82+ _, _ = io.WriteString(w, `{"data":[]}`)
83+ }))
84+ defer srv.Close()
85+
86+ if _, err := DiscoverOllama(context.Background(), "ollama-key", srv.URL+"/v1"); err != nil {
87+ t.Fatal(err)
88+ }
89+}
90+
56 func TestDiscoverOpenAICodexRejectsHTTPError(t *testing.T) {91 func TestDiscoverOpenAICodexRejectsHTTPError(t *testing.T) {
57 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {92 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58 http.Error(w, "denied", http.StatusUnauthorized)93 http.Error(w, "denied", http.StatusUnauthorized)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close