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.
This commit is contained in:
patriceckhart 2026-04-24 19:13:45 +02:00
parent b3a935fbe7
commit cb9de10ec6
4 changed files with 108 additions and 24 deletions

View file

@ -282,15 +282,55 @@ Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/
}
```
Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`.
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.
### Local models with ollama
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.
Quick start:
```bash
ollama pull qwen3.5:4b
zot --provider ollama --model qwen3.5:4b
```
That's it. No API key needed for local models. zot defaults to `http://localhost:11434`.
For a remote ollama instance or one behind auth:
```bash
zot --provider ollama --model llama3 --base-url https://my-server.com/v1 --api-key my-token
```
You can also add models to your `models.json` so you don't need flags every time:
```json
{
"providers": {
"ollama": {
"models": [
{
"id": "qwen3.5:4b",
"name": "Qwen 3.5 4B",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}
```
The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.).
## Inline images
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:
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:
| Value | Effect |
|---|---|

View file

@ -328,7 +328,7 @@ func PrintHelp(version string) {
row{"zot tg ...", "short alias for telegram-bot"},
)
section("provider and model flags",
row{"--provider", "provider to use (anthropic|openai)"},
row{"--provider", "provider to use (anthropic|openai|ollama)"},
row{"--model ID", "model id (see --list-models)"},
row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
row{"--base-url URL", "override provider api base url"},

View file

@ -127,19 +127,32 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
// User-requested provider (explicit > config > default).
provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
if provName != "anthropic" && provName != "openai" {
return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName)
if provName != "anthropic" && provName != "openai" && provName != "ollama" {
return Resolved{}, fmt.Errorf("provider must be anthropic, openai, or ollama (got %q)", provName)
}
// Try the requested provider first.
cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey)
var (
cred string
method string
accountID string
credErr error
)
if provName == "ollama" {
if args.BaseURL == "" {
args.BaseURL = "http://localhost:11434"
}
cred = firstNonEmpty(args.APIKey, "ollama")
method = "apikey"
} else {
cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
}
// If the user did NOT explicitly pick a provider and the default one
// has no credentials, auto-fall-back to whichever provider is actually
// logged in. That way running plain `zot` after `/login` (any provider)
// never shows a "not logged in" banner.
userPickedProvider := args.Provider != ""
if credErr != nil && !userPickedProvider {
if credErr != nil && !userPickedProvider && provName != "ollama" {
other := "openai"
if provName == "openai" {
other = "anthropic"
@ -152,25 +165,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
model := firstNonEmpty(args.Model, cfg.Model)
if model == "" {
if provName == "openai" {
switch provName {
case "openai":
model = "gpt-5"
} else {
case "ollama":
return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
default:
model = provider.DefaultModel.ID
}
}
// If the resolved model belongs to a different provider (e.g. config
// says gpt-5 but we fell back to anthropic), pick that provider's default.
if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
if provName == "openai" {
model = "gpt-5"
} else {
model = provider.DefaultModel.ID
if provName != "ollama" {
if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
if provName == "openai" {
model = "gpt-5"
} else {
model = provider.DefaultModel.ID
}
}
}
resolvedModel, err := provider.FindModel(provName, model)
if err != nil {
if err != nil && provName != "ollama" {
return Resolved{}, err
}
if err != nil && provName == "ollama" {
resolvedModel = provider.Model{
Provider: "ollama",
ID: model,
DisplayName: model,
ContextWindow: 32768,
MaxOutput: 8192,
BaseURL: args.BaseURL,
Source: "ollama",
}
}
// If the model defines a base URL (e.g. local ollama) and the
// user didn't pass --base-url, use the model's URL.
@ -303,16 +332,20 @@ func (r Resolved) NewClient() provider.Client {
if !r.HasCredential() {
panic("NewClient called without credential; check HasCredential first")
}
if r.Provider == "openai" {
switch r.Provider {
case "ollama":
return provider.NewOpenAI(r.Credential, r.BaseURL)
case "openai":
if r.AuthMethod == "oauth" {
return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
}
return provider.NewOpenAI(r.Credential, r.BaseURL)
default:
if r.AuthMethod == "oauth" {
return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
}
return provider.NewAnthropic(r.Credential, r.BaseURL)
}
if r.AuthMethod == "oauth" {
return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
}
return provider.NewAnthropic(r.Credential, r.BaseURL)
}
// UseSandbox replaces the sandbox pointer that every tool in r's
@ -395,6 +428,8 @@ func envVarName(provider string) string {
switch provider {
case "openai":
return "OPENAI"
case "ollama":
return "OLLAMA"
default:
return "ANTHROPIC"
}

View file

@ -111,9 +111,18 @@ type oaiRequest struct {
// ---- request building ----
func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
m, err := FindModel("openai", req.Model)
// Look up the model by id across all providers (not just openai)
// because the OpenAI client is also used for ollama and other
// OpenAI-compatible backends.
m, err := FindModel("", req.Model)
if err != nil {
return nil, err
// Unknown model: use sensible defaults so local/custom
// models still work without a catalog entry.
m = Model{
ID: req.Model,
ContextWindow: 32768,
MaxOutput: 8192,
}
}
out := &oaiRequest{
Model: req.Model,
@ -311,7 +320,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
defer close(out)
defer resp.Body.Close()
model, _ := FindModel("openai", req.Model)
model, _ := FindModel("", req.Model)
out <- EventStart{Model: req.Model, Provider: "openai"}
raw := make(chan sseEvent, 16)