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/ | |||
| 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 | 287 | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`. | |
| 288 | 288 | ||
| 289 | 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 | 331 | ## 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 | 335 | | Value | Effect | | |
| 296 | 336 | |---|---| | |
| @@ -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 images | 331 | ## 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) { | |||
| 328 | 328 | row{"zot tg ...", "short alias for telegram-bot"}, | |
| 329 | 329 | ) | |
| 330 | 330 | section("provider and model flags", | |
| 331 | - | row{"--provider", "provider to use (anthropic|openai)"}, | |
| 331 | + | row{"--provider", "provider to use (anthropic|openai|ollama)"}, | |
| 332 | 332 | row{"--model ID", "model id (see --list-models)"}, | |
| 333 | 333 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 334 | 334 | 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) { | |||
| 127 | 127 | ||
| 128 | 128 | // User-requested provider (explicit > config > default). | |
| 129 | 129 | 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) | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 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 | + | } | |
| 136 | 149 | ||
| 137 | 150 | // If the user did NOT explicitly pick a provider and the default one | |
| 138 | 151 | // has no credentials, auto-fall-back to whichever provider is actually | |
| 139 | 152 | // logged in. That way running plain `zot` after `/login` (any provider) | |
| 140 | 153 | // never shows a "not logged in" banner. | |
| 141 | 154 | userPickedProvider := args.Provider != "" | |
| 142 | - | if credErr != nil && !userPickedProvider { | |
| 155 | + | if credErr != nil && !userPickedProvider && provName != "ollama" { | |
| 143 | 156 | other := "openai" | |
| 144 | 157 | if provName == "openai" { | |
| 145 | 158 | other = "anthropic" | |
| @@ -152,25 +165,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 152 | 165 | ||
| 153 | 166 | model := firstNonEmpty(args.Model, cfg.Model) | |
| 154 | 167 | if model == "" { | |
| 155 | - | if provName == "openai" { | |
| 168 | + | switch provName { | |
| 169 | + | case "openai": | |
| 156 | 170 | model = "gpt-5" | |
| 157 | - | } else { | |
| 171 | + | case "ollama": | |
| 172 | + | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | |
| 173 | + | default: | |
| 158 | 174 | model = provider.DefaultModel.ID | |
| 159 | 175 | } | |
| 160 | 176 | } | |
| 161 | 177 | // If the resolved model belongs to a different provider (e.g. config | |
| 162 | 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 { | |
| 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 | + | } | |
| 168 | 186 | } | |
| 169 | 187 | } | |
| 170 | 188 | resolvedModel, err := provider.FindModel(provName, model) | |
| 171 | - | if err != nil { | |
| 189 | + | if err != nil && provName != "ollama" { | |
| 172 | 190 | 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 | 204 | // If the model defines a base URL (e.g. local ollama) and the | |
| 176 | 205 | // user didn't pass --base-url, use the model's URL. | |
| @@ -303,16 +332,20 @@ func (r Resolved) NewClient() provider.Client { | |||
| 303 | 332 | if !r.HasCredential() { | |
| 304 | 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 | 339 | if r.AuthMethod == "oauth" { | |
| 308 | 340 | return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| 309 | 341 | } | |
| 310 | 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 | 351 | // UseSandbox replaces the sandbox pointer that every tool in r's | |
| @@ -395,6 +428,8 @@ func envVarName(provider string) string { | |||
| 395 | 428 | switch provider { | |
| 396 | 429 | case "openai": | |
| 397 | 430 | return "OPENAI" | |
| 431 | + | case "ollama": | |
| 432 | + | return "OLLAMA" | |
| 398 | 433 | default: | |
| 399 | 434 | return "ANTHROPIC" | |
| 400 | 435 | } | |
| @@ -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 one | 150 | // If the user did NOT explicitly pick a provider and the default one | ||
| 138 | // has no credentials, auto-fall-back to whichever provider is actually | 151 | // 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.ID | 174 | model = provider.DefaultModel.ID | ||
| 159 | } | 175 | } | ||
| 160 | } | 176 | } | ||
| 161 | // If the resolved model belongs to a different provider (e.g. config | 177 | // 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.ID | 183 | + | } 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{}, err | 190 | 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 the | 204 | // 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's | 351 | // 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 { | |||
| 111 | 111 | // ---- request building ---- | |
| 112 | 112 | ||
| 113 | 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 | 118 | 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 | + | } | |
| 117 | 126 | } | |
| 118 | 127 | out := &oaiRequest{ | |
| 119 | 128 | Model: req.Model, | |
| @@ -311,7 +320,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 311 | 320 | defer close(out) | |
| 312 | 321 | defer resp.Body.Close() | |
| 313 | 322 | ||
| 314 | - | model, _ := FindModel("openai", req.Model) | |
| 323 | + | model, _ := FindModel("", req.Model) | |
| 315 | 324 | out <- EventStart{Model: req.Model, Provider: "openai"} | |
| 316 | 325 | ||
| 317 | 326 | 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, 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 | + | } | |||
| 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) | ||