detached cb9de10

feat: add ollama as first-class provider

Adds --provider ollama with auto-detection of local ollama at localhost:11434. No API key required for local models. Optional --api-key and --base-url for remote/authenticated instances. Uses the OpenAI chat completions client internally. Unknown models are accepted without catalog entries. Updated README with ollama documentation.
commit
cb9de10ec6e363fa35d769578e4c90fd024f0404
author
patriceckhart · · 2026-04-24 17:13 UTC
parent
b3a935f
signature
unsigned
4 files · +108 -24
M README.md +42 -2
@@ -282,15 +282,55 @@ Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/
282282 }
283283 ```
284284
285-Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
285+Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
286286
287287 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.
288288
289289 User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored.
290290
291+### Local models with ollama
292+
293+zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
294+
295+Quick start:
296+
297+```bash
298+ollama pull qwen3.5:4b
299+zot --provider ollama --model qwen3.5:4b
300+```
301+
302+That's it. No API key needed for local models. zot defaults to `http://localhost:11434`.
303+
304+For a remote ollama instance or one behind auth:
305+
306+```bash
307+zot --provider ollama --model llama3 --base-url https://my-server.com/v1 --api-key my-token
308+```
309+
310+You can also add models to your `models.json` so you don't need flags every time:
311+
312+```json
313+{
314+ "providers": {
315+ "ollama": {
316+ "models": [
317+ {
318+ "id": "qwen3.5:4b",
319+ "name": "Qwen 3.5 4B",
320+ "contextWindow": 32768,
321+ "maxTokens": 8192
322+ }
323+ ]
324+ }
325+ }
326+}
327+```
328+
329+The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.).
330+
291331 ## Inline images
292332
293-When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **iTerm2**, **WezTerm**, **Kitty**, **Ghostty**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
333+When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
294334
295335 | Value | Effect |
296336 |---|---|
@@ -282,15 +282,55 @@ Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/
282 }282 }
283 ```283 ```
284 284
285-Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.285+Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
286 286
287 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.287 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.
288 288
289 User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored.289 User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored.
290 290
291+### Local models with ollama
292+
293+zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
294+
295+Quick start:
296+
297+```bash
298+ollama pull qwen3.5:4b
299+zot --provider ollama --model qwen3.5:4b
300+```
301+
302+That's it. No API key needed for local models. zot defaults to `http://localhost:11434`.
303+
304+For a remote ollama instance or one behind auth:
305+
306+```bash
307+zot --provider ollama --model llama3 --base-url https://my-server.com/v1 --api-key my-token
308+```
309+
310+You can also add models to your `models.json` so you don't need flags every time:
311+
312+```json
313+{
314+ "providers": {
315+ "ollama": {
316+ "models": [
317+ {
318+ "id": "qwen3.5:4b",
319+ "name": "Qwen 3.5 4B",
320+ "contextWindow": 32768,
321+ "maxTokens": 8192
322+ }
323+ ]
324+ }
325+ }
326+}
327+```
328+
329+The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.).
330+
291 ## Inline images331 ## Inline images
292 332
293-When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **iTerm2**, **WezTerm**, **Kitty**, **Ghostty**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:333+When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
294 334
295 | Value | Effect |335 | Value | Effect |
296 |---|---|336 |---|---|
M internal/agent/args.go +1 -1
@@ -328,7 +328,7 @@ func PrintHelp(version string) {
328328 row{"zot tg ...", "short alias for telegram-bot"},
329329 )
330330 section("provider and model flags",
331- row{"--provider", "provider to use (anthropic|openai)"},
331+ row{"--provider", "provider to use (anthropic|openai|ollama)"},
332332 row{"--model ID", "model id (see --list-models)"},
333333 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
334334 row{"--base-url URL", "override provider api base url"},
@@ -328,7 +328,7 @@ func PrintHelp(version string) {
328 row{"zot tg ...", "short alias for telegram-bot"},328 row{"zot tg ...", "short alias for telegram-bot"},
329 )329 )
330 section("provider and model flags",330 section("provider and model flags",
331- row{"--provider", "provider to use (anthropic|openai)"},331+ row{"--provider", "provider to use (anthropic|openai|ollama)"},
332 row{"--model ID", "model id (see --list-models)"},332 row{"--model ID", "model id (see --list-models)"},
333 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},333 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
334 row{"--base-url URL", "override provider api base url"},334 row{"--base-url URL", "override provider api base url"},
M internal/agent/build.go +53 -18
@@ -127,19 +127,32 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
127127
128128 // User-requested provider (explicit > config > default).
129129 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
130- if provName != "anthropic" && provName != "openai" {
131- return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName)
130+ if provName != "anthropic" && provName != "openai" && provName != "ollama" {
131+ return Resolved{}, fmt.Errorf("provider must be anthropic, openai, or ollama (got %q)", provName)
132132 }
133133
134- // Try the requested provider first.
135- cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey)
134+ var (
135+ cred string
136+ method string
137+ accountID string
138+ credErr error
139+ )
140+ if provName == "ollama" {
141+ if args.BaseURL == "" {
142+ args.BaseURL = "http://localhost:11434"
143+ }
144+ cred = firstNonEmpty(args.APIKey, "ollama")
145+ method = "apikey"
146+ } else {
147+ cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
148+ }
136149
137150 // If the user did NOT explicitly pick a provider and the default one
138151 // has no credentials, auto-fall-back to whichever provider is actually
139152 // logged in. That way running plain `zot` after `/login` (any provider)
140153 // never shows a "not logged in" banner.
141154 userPickedProvider := args.Provider != ""
142- if credErr != nil && !userPickedProvider {
155+ if credErr != nil && !userPickedProvider && provName != "ollama" {
143156 other := "openai"
144157 if provName == "openai" {
145158 other = "anthropic"
@@ -152,25 +165,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
152165
153166 model := firstNonEmpty(args.Model, cfg.Model)
154167 if model == "" {
155- if provName == "openai" {
168+ switch provName {
169+ case "openai":
156170 model = "gpt-5"
157- } else {
171+ case "ollama":
172+ return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
173+ default:
158174 model = provider.DefaultModel.ID
159175 }
160176 }
161177 // If the resolved model belongs to a different provider (e.g. config
162178 // says gpt-5 but we fell back to anthropic), pick that provider's default.
163- if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
164- if provName == "openai" {
165- model = "gpt-5"
166- } else {
167- model = provider.DefaultModel.ID
179+ if provName != "ollama" {
180+ if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
181+ if provName == "openai" {
182+ model = "gpt-5"
183+ } else {
184+ model = provider.DefaultModel.ID
185+ }
168186 }
169187 }
170188 resolvedModel, err := provider.FindModel(provName, model)
171- if err != nil {
189+ if err != nil && provName != "ollama" {
172190 return Resolved{}, err
173191 }
192+ if err != nil && provName == "ollama" {
193+ resolvedModel = provider.Model{
194+ Provider: "ollama",
195+ ID: model,
196+ DisplayName: model,
197+ ContextWindow: 32768,
198+ MaxOutput: 8192,
199+ BaseURL: args.BaseURL,
200+ Source: "ollama",
201+ }
202+ }
174203
175204 // If the model defines a base URL (e.g. local ollama) and the
176205 // user didn't pass --base-url, use the model's URL.
@@ -303,16 +332,20 @@ func (r Resolved) NewClient() provider.Client {
303332 if !r.HasCredential() {
304333 panic("NewClient called without credential; check HasCredential first")
305334 }
306- if r.Provider == "openai" {
335+ switch r.Provider {
336+ case "ollama":
337+ return provider.NewOpenAI(r.Credential, r.BaseURL)
338+ case "openai":
307339 if r.AuthMethod == "oauth" {
308340 return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
309341 }
310342 return provider.NewOpenAI(r.Credential, r.BaseURL)
343+ default:
344+ if r.AuthMethod == "oauth" {
345+ return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
346+ }
347+ return provider.NewAnthropic(r.Credential, r.BaseURL)
311348 }
312- if r.AuthMethod == "oauth" {
313- return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
314- }
315- return provider.NewAnthropic(r.Credential, r.BaseURL)
316349 }
317350
318351 // UseSandbox replaces the sandbox pointer that every tool in r's
@@ -395,6 +428,8 @@ func envVarName(provider string) string {
395428 switch provider {
396429 case "openai":
397430 return "OPENAI"
431+ case "ollama":
432+ return "OLLAMA"
398433 default:
399434 return "ANTHROPIC"
400435 }
@@ -127,19 +127,32 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
127 127
128 // User-requested provider (explicit > config > default).128 // User-requested provider (explicit > config > default).
129 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")129 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
130- if provName != "anthropic" && provName != "openai" {130+ if provName != "anthropic" && provName != "openai" && provName != "ollama" {
131- return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName)131+ return Resolved{}, fmt.Errorf("provider must be anthropic, openai, or ollama (got %q)", provName)
132 }132 }
133 133
134- // Try the requested provider first.134+ var (
135- cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey)135+ cred string
136+ method string
137+ accountID string
138+ credErr error
139+ )
140+ if provName == "ollama" {
141+ if args.BaseURL == "" {
142+ args.BaseURL = "http://localhost:11434"
143+ }
144+ cred = firstNonEmpty(args.APIKey, "ollama")
145+ method = "apikey"
146+ } else {
147+ cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
148+ }
136 149
137 // If the user did NOT explicitly pick a provider and the default one150 // If the user did NOT explicitly pick a provider and the default one
138 // has no credentials, auto-fall-back to whichever provider is actually151 // has no credentials, auto-fall-back to whichever provider is actually
139 // logged in. That way running plain `zot` after `/login` (any provider)152 // logged in. That way running plain `zot` after `/login` (any provider)
140 // never shows a "not logged in" banner.153 // never shows a "not logged in" banner.
141 userPickedProvider := args.Provider != ""154 userPickedProvider := args.Provider != ""
142- if credErr != nil && !userPickedProvider {155+ if credErr != nil && !userPickedProvider && provName != "ollama" {
143 other := "openai"156 other := "openai"
144 if provName == "openai" {157 if provName == "openai" {
145 other = "anthropic"158 other = "anthropic"
@@ -152,25 +165,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
152 165
153 model := firstNonEmpty(args.Model, cfg.Model)166 model := firstNonEmpty(args.Model, cfg.Model)
154 if model == "" {167 if model == "" {
155- if provName == "openai" {168+ switch provName {
169+ case "openai":
156 model = "gpt-5"170 model = "gpt-5"
157- } else {171+ case "ollama":
172+ return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
173+ default:
158 model = provider.DefaultModel.ID174 model = provider.DefaultModel.ID
159 }175 }
160 }176 }
161 // If the resolved model belongs to a different provider (e.g. config177 // If the resolved model belongs to a different provider (e.g. config
162 // says gpt-5 but we fell back to anthropic), pick that provider's default.178 // says gpt-5 but we fell back to anthropic), pick that provider's default.
163- if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {179+ if provName != "ollama" {
164- if provName == "openai" {180+ if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
165- model = "gpt-5"181+ if provName == "openai" {
166- } else {182+ model = "gpt-5"
167- model = provider.DefaultModel.ID183+ } else {
184+ model = provider.DefaultModel.ID
185+ }
168 }186 }
169 }187 }
170 resolvedModel, err := provider.FindModel(provName, model)188 resolvedModel, err := provider.FindModel(provName, model)
171- if err != nil {189+ if err != nil && provName != "ollama" {
172 return Resolved{}, err190 return Resolved{}, err
173 }191 }
192+ if err != nil && provName == "ollama" {
193+ resolvedModel = provider.Model{
194+ Provider: "ollama",
195+ ID: model,
196+ DisplayName: model,
197+ ContextWindow: 32768,
198+ MaxOutput: 8192,
199+ BaseURL: args.BaseURL,
200+ Source: "ollama",
201+ }
202+ }
174 203
175 // If the model defines a base URL (e.g. local ollama) and the204 // If the model defines a base URL (e.g. local ollama) and the
176 // user didn't pass --base-url, use the model's URL.205 // user didn't pass --base-url, use the model's URL.
@@ -303,16 +332,20 @@ func (r Resolved) NewClient() provider.Client {
303 if !r.HasCredential() {332 if !r.HasCredential() {
304 panic("NewClient called without credential; check HasCredential first")333 panic("NewClient called without credential; check HasCredential first")
305 }334 }
306- if r.Provider == "openai" {335+ switch r.Provider {
336+ case "ollama":
337+ return provider.NewOpenAI(r.Credential, r.BaseURL)
338+ case "openai":
307 if r.AuthMethod == "oauth" {339 if r.AuthMethod == "oauth" {
308 return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)340 return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
309 }341 }
310 return provider.NewOpenAI(r.Credential, r.BaseURL)342 return provider.NewOpenAI(r.Credential, r.BaseURL)
343+ default:
344+ if r.AuthMethod == "oauth" {
345+ return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
346+ }
347+ return provider.NewAnthropic(r.Credential, r.BaseURL)
311 }348 }
312- if r.AuthMethod == "oauth" {
313- return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
314- }
315- return provider.NewAnthropic(r.Credential, r.BaseURL)
316 }349 }
317 350
318 // UseSandbox replaces the sandbox pointer that every tool in r's351 // UseSandbox replaces the sandbox pointer that every tool in r's
@@ -395,6 +428,8 @@ func envVarName(provider string) string {
395 switch provider {428 switch provider {
396 case "openai":429 case "openai":
397 return "OPENAI"430 return "OPENAI"
431+ case "ollama":
432+ return "OLLAMA"
398 default:433 default:
399 return "ANTHROPIC"434 return "ANTHROPIC"
400 }435 }
M internal/provider/openai.go +12 -3
@@ -111,9 +111,18 @@ type oaiRequest struct {
111111 // ---- request building ----
112112
113113 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
114- m, err := FindModel("openai", req.Model)
114+ // Look up the model by id across all providers (not just openai)
115+ // because the OpenAI client is also used for ollama and other
116+ // OpenAI-compatible backends.
117+ m, err := FindModel("", req.Model)
115118 if err != nil {
116- return nil, err
119+ // Unknown model: use sensible defaults so local/custom
120+ // models still work without a catalog entry.
121+ m = Model{
122+ ID: req.Model,
123+ ContextWindow: 32768,
124+ MaxOutput: 8192,
125+ }
117126 }
118127 out := &oaiRequest{
119128 Model: req.Model,
@@ -311,7 +320,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
311320 defer close(out)
312321 defer resp.Body.Close()
313322
314- model, _ := FindModel("openai", req.Model)
323+ model, _ := FindModel("", req.Model)
315324 out <- EventStart{Model: req.Model, Provider: "openai"}
316325
317326 raw := make(chan sseEvent, 16)
@@ -111,9 +111,18 @@ type oaiRequest struct {
111 // ---- request building ----111 // ---- request building ----
112 112
113 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {113 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
114- m, err := FindModel("openai", req.Model)114+ // Look up the model by id across all providers (not just openai)
115+ // because the OpenAI client is also used for ollama and other
116+ // OpenAI-compatible backends.
117+ m, err := FindModel("", req.Model)
115 if err != nil {118 if err != nil {
116- return nil, err119+ // Unknown model: use sensible defaults so local/custom
120+ // models still work without a catalog entry.
121+ m = Model{
122+ ID: req.Model,
123+ ContextWindow: 32768,
124+ MaxOutput: 8192,
125+ }
117 }126 }
118 out := &oaiRequest{127 out := &oaiRequest{
119 Model: req.Model,128 Model: req.Model,
@@ -311,7 +320,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
311 defer close(out)320 defer close(out)
312 defer resp.Body.Close()321 defer resp.Body.Close()
313 322
314- model, _ := FindModel("openai", req.Model)323+ model, _ := FindModel("", req.Model)
315 out <- EventStart{Model: req.Model, Provider: "openai"}324 out <- EventStart{Model: req.Model, Provider: "openai"}
316 325
317 raw := make(chan sseEvent, 16)326 raw := make(chan sseEvent, 16)

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