diff --git a/README.md b/README.md index 1b4df98..3185100 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---| diff --git a/internal/agent/args.go b/internal/agent/args.go index 8b9535c..ebb2a9a 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -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"}, diff --git a/internal/agent/build.go b/internal/agent/build.go index de9742f..26a51cf 100644 --- a/internal/agent/build.go +++ b/internal/agent/build.go @@ -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" } diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 7938839..03011e6 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -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)