From 80a1ee96f678550ff56f94a8d9c669545c4bd573 Mon Sep 17 00:00:00 2001 From: Glauber Portella Date: Fri, 23 Oct 2026 18:12:38 -0300 Subject: [PATCH 1/2] ollama llm --- README.md | 4 +- agent/provider.go | 88 ++++++++-- db/config.go | 9 +- db/schema.sql | 9 +- server/conversations.go | 4 +- server/llm_ollama_test.go | 223 +++++++++++++++++++++++++ server/orchestration.go | 2 +- server/server.go | 55 +++--- server/server_mgmt.go | 26 ++- server/task_resolution.go | 3 +- web/src/app/(main)/system/llm/page.tsx | 43 +++-- web/src/lib/types.ts | 2 +- 12 files changed, 403 insertions(+), 65 deletions(-) create mode 100644 server/llm_ollama_test.go diff --git a/README.md b/README.md index 81e935f2..a1401015 100644 --- a/README.md +++ b/README.md @@ -233,8 +233,8 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex } ``` -**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page. -Optional: `ARTEX_LLM_PROVIDER` / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`. +**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page — which can add a local Ollama server as a keyless provider (format "Ollama", base URL default `http://127.0.0.1:11434/v1`, models loaded from the server). +Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama) / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`. **Concurrency**: the number of work agents per task is configured in "System Settings" (default 3). diff --git a/agent/provider.go b/agent/provider.go index a8df7d5e..3bfa67a6 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -3,8 +3,10 @@ // §4.3 (planner) and §4.4 (work agent). // // Provider configuration is read from the environment so the system runs with -// any Anthropic- or OpenAI-format endpoint. If no key is configured, FromEnv -// returns ok=false and the exploration engine stays idle (an LLM is required). +// any Anthropic-, OpenAI- or Ollama-format endpoint (the last being keyless). +// If no usable configuration is found — a key for the cloud formats, a base +// URL for keyless Ollama — FromEnv returns ok=false and the exploration engine +// stays idle (an LLM is required). package agent import ( @@ -125,6 +127,30 @@ const ( maxWindowK = 1000 // cap at 1M tokens (user request) ) +// FormatOllama marks a LOCAL Ollama server profile. Ollama exposes an +// OpenAI-compatible Chat Completions endpoint (/v1), so on the wire it speaks +// llm.FormatOpenAI; the distinct sentinel matters everywhere a profile must be +// treated as usable WITHOUT an API key — Ollama runs keyless on localhost/LAN. +// See NeedsAPIKey and NewProvider (which maps it onto FormatOpenAI while +// forcing our base-URL/key defaults so an empty key never falls back to +// OpenAI's public endpoint). +const FormatOllama llm.Format = "ollama" + +// DefaultOllamaBaseURL is the base of Ollama's OpenAI-compatible endpoint the +// provider appends /chat/completions to. Must never end up empty: with an +// empty BaseURL, the SDK would fall back to OpenAI's public endpoint. +const DefaultOllamaBaseURL = "http://127.0.0.1:11434/v1" + +// ollamaPlaceholderKey is sent as the Bearer token when no key is configured. +// Ollama ignores authorization, but a concrete value keeps the SDK from +// falling back to OPENAI_API_KEY (which would silently ship a real cloud key +// to the local server) and keeps the header stable. +const ollamaPlaceholderKey = "ollama" + +// NeedsAPIKey reports whether this provider format requires an API key to be +// usable. Ollama (local server) is keyless; every cloud format needs one. +func (c Config) NeedsAPIKey() bool { return c.Format != FormatOllama } + // CompactionWindow returns the model context window in TOKENS for compaction // thresholds, resolved from the user-configured size (ContextWindowK). 0/unset → // a 200K default; otherwise clamped to [32K, 1M] so compaction stays effective. @@ -154,11 +180,11 @@ func compactionConfig(windowTokens int) *compaction.Config { // FromEnv resolves the LLM provider config: // -// ARTEX_LLM_PROVIDER = anthropic|openai (default: inferred from keys) +// ARTEX_LLM_PROVIDER = anthropic|openai|openai-responses|ollama (default: inferred from keys) // ARTEX_LLM_MODEL = model id (default: per provider) -// ARTEX_LLM_BASE_URL = endpoint (optional) +// ARTEX_LLM_BASE_URL = endpoint (optional; ollama defaults to localhost:11434/v1) // ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5) -// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials +// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials (unused for ollama) func FromEnv() (Config, bool) { prov := os.Getenv("ARTEX_LLM_PROVIDER") anthKey := os.Getenv("ANTHROPIC_API_KEY") @@ -196,6 +222,15 @@ func FromEnv() (Config, bool) { if c.Model == "" { c.Model = "gpt-5" } + case "ollama": + c.Format = FormatOllama + c.APIKey = oaiKey // optional; a local Ollama server is usable keyless + if c.BaseURL == "" { + c.BaseURL = DefaultOllamaBaseURL + } + if c.Model == "" { + c.Model = "llama3.2" + } default: c.Format = llm.FormatAnthropic c.APIKey = anthKey @@ -203,7 +238,8 @@ func FromEnv() (Config, bool) { c.Model = "claude-opus-4-8" } } - if c.APIKey == "" { + // Ollama runs without a key: only the cloud formats gate on one. + if c.APIKey == "" && c.NeedsAPIKey() { return Config{}, false } return c, true @@ -236,6 +272,19 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config { if c.Model == "" { c.Model = "gpt-5" } + case "ollama": + c.Format = FormatOllama + // Ollama's OpenAI-compatible endpoint lives at /v1 and the provider + // appends "/chat/completions"; tolerate a full endpoint URL. Never leave + // it empty: an empty BaseURL would let the SDK fall back to OpenAI's + // public endpoint with this profile's request. + c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/chat/completions"), "/") + if c.BaseURL == "" { + c.BaseURL = DefaultOllamaBaseURL + } + if c.Model == "" { + c.Model = "llama3.2" + } default: c.Format = llm.FormatAnthropic // provider appends "/v1/messages". @@ -257,13 +306,16 @@ func isFalsy(s string) bool { return false } -// Provider returns the short provider name ("anthropic"/"openai"). +// Provider returns the short provider name +// ("anthropic"/"openai"/"openai-responses"/"ollama"). func (c Config) Provider() string { switch c.Format { case llm.FormatOpenAI: return "openai" case llm.FormatOpenAIResponses: return "openai-responses" + case FormatOllama: + return "ollama" } return "anthropic" } @@ -276,10 +328,26 @@ func (c Config) NewProvider() (llm.Provider, error) { if err != nil { return nil, err } + wireFormat := c.Format + baseURL := c.BaseURL + apiKey := c.APIKey + // Ollama speaks OpenAI's Chat Completions on the wire. Bundle the keyless + // defaults here so an Ollama config can never leak toward the SDK's env- + // driven fallbacks: BaseURL to OpenAI's public endpoint, APIKey to + // OPENAI_API_KEY. + if wireFormat == FormatOllama { + wireFormat = llm.FormatOpenAI + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultOllamaBaseURL + } + if strings.TrimSpace(apiKey) == "" { + apiKey = ollamaPlaceholderKey + } + } lc := llm.Config{ - Format: c.Format, - BaseURL: c.BaseURL, - APIKey: c.APIKey, + Format: wireFormat, + BaseURL: baseURL, + APIKey: apiKey, Model: c.Model, HTTPClient: client, } diff --git a/db/config.go b/db/config.go index cf7e7054..a20977ea 100644 --- a/db/config.go +++ b/db/config.go @@ -143,13 +143,14 @@ func (d *DB) ProfileByID(id int64) (*LLMProfile, error) { } // PoolProfiles returns the failover chain in run order, api keys included: the -// active profile first, then every other keyed profile that isn't excluded, by +// active profile first, then every other usable profile that isn't excluded, by // priority DESC (id ASC to stay stable). Profiles without an api key can't serve -// a request, so they never enter the chain. The ordering IS the policy — callers -// walk the slice front to back. +// a request — except Ollama ('ollama'), which is a keyless local server — so +// they never enter the chain. The ordering IS the policy — callers walk the +// slice front to back. func (d *DB) PoolProfiles() ([]*LLMProfile, error) { rows, err := d.Query(`SELECT ` + profileColsKey + ` FROM llm_profiles -WHERE COALESCE(api_key,'') <> '' AND (is_default OR NOT pool_exclude) +WHERE (COALESCE(api_key,'') <> '' OR format='ollama') AND (is_default OR NOT pool_exclude) ORDER BY is_default DESC, priority DESC, id ASC`) if err != nil { return nil, err diff --git a/db/schema.sql b/db/schema.sql index 8efe57c9..5bb34e90 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -363,7 +363,7 @@ CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS llm_profiles ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, - format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')), + format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses','ollama')), base_url TEXT, proxy TEXT, model TEXT NOT NULL, @@ -416,11 +416,12 @@ ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false; -- Streaming toggle; backfills old databases. Default true = keep existing streaming behavior; old configs upgrade transparently. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true; --- Relax the format constraint to allow openai-responses (OpenAI Responses API); backfills old databases. --- Runs at every startup, idempotent: drop the old CHECK first, then create the new three-value CHECK. +-- Relax the format constraint to allow openai-responses (OpenAI Responses API) and +-- ollama (local Ollama server, keyless OpenAI-compatible endpoint); backfills old databases. +-- Runs at every startup, idempotent: drop the old CHECK first, then create the new four-value CHECK. ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check; ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check - CHECK (format IN ('openai','anthropic','openai-responses')); + CHECK (format IN ('openai','anthropic','openai-responses','ollama')); -- Output cap and its field name; backfills old databases. Defaults 0 / '' = send no cap and keep the max_tokens field name; -- old configs behave exactly as before. diff --git a/server/conversations.go b/server/conversations.go index d53107ea..c88f8467 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -103,7 +103,7 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) { } if req.LLMProfileID != nil { if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok { - writeErr(w, 400, "The specified LLM profile does not exist or has no API Key set") + writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)") return } } @@ -136,7 +136,7 @@ func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) { } if req.LLMProfileID != nil { if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok { - writeErr(w, 400, "The specified LLM profile does not exist or has no API Key set") + writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)") return } } diff --git a/server/llm_ollama_test.go b/server/llm_ollama_test.go new file mode 100644 index 00000000..dbbf6bab --- /dev/null +++ b/server/llm_ollama_test.go @@ -0,0 +1,223 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/norma/llm" +) + +// ConfigFrom("ollama", ...) must normalize to the keyless sentinel with our own +// defaults, never the SDK's OpenAI-public-endpoint fallback. +func TestOllamaConfigFromKeylessDefaults(t *testing.T) { + c := agent.ConfigFrom("ollama", "", "", "", "") + if c.Format != agent.FormatOllama { + t.Fatalf("format=%v, want FormatOllama", c.Format) + } + if c.Provider() != "ollama" { + t.Fatalf("Provider()=%q", c.Provider()) + } + if c.BaseURL != agent.DefaultOllamaBaseURL { + t.Fatalf("BaseURL=%q, want default %q", c.BaseURL, agent.DefaultOllamaBaseURL) + } + if c.Model == "" { + t.Fatal("model must default to a non-empty value") + } + if c.NeedsAPIKey() { + t.Fatal("ollama must not require an API key") + } + // A full endpoint URL is tolerated (the provider appends /chat/completions). + e := agent.ConfigFrom("ollama", "llama3.2", "http://box:11434/v1/chat/completions", "", "") + if e.BaseURL != "http://box:11434/v1" { + t.Fatalf("full endpoint URL not normalized: %q", e.BaseURL) + } + // A custom key is preserved (e.g. an authenticated Ollama gateway). + k := agent.ConfigFrom("ollama", "m", "http://box:11434", "ollama-secret", "") + if k.APIKey != "ollama-secret" { + t.Fatalf("custom key lost: %q", k.APIKey) + } +} + +// The provider built from an Ollama config speaks OpenAI Chat Completions on +// the wire (the SDK appends /chat/completions) with the placeholder Bearer (no +// real key was configured, no OPENAI_API_KEY env leak). +func TestOllamaProviderWiresOpenAIProtocol(t *testing.T) { + var gotPath, gotAuth, gotModel atomic.Value + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath.Store(r.URL.Path) + gotAuth.Store(r.Header.Get("Authorization")) + var body struct { + Model string `json:"model"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotModel.Store(body.Model) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","model":"llama3.2","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)) + })) + t.Cleanup(fake.Close) + + cfg := agent.ConfigFrom("ollama", "llama3.2", fake.URL, "", "") + cfg.Stream = false // non-streaming: the fake answers one plain JSON body + prov, err := cfg.NewProvider() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req := llm.CompletionRequest{ + System: []string{"You are a connection test. Output OK."}, + Messages: []llm.Message{llm.UserText("ping")}, + } + if _, _, _, err = prov.Complete(ctx, req); err != nil { + t.Fatalf("Complete: %v", err) + } + if p, _ := gotPath.Load().(string); p != "/chat/completions" { + t.Fatalf("path=%q, want /chat/completions (base URL must NOT be the OpenAI fallback)", p) + } + if a, _ := gotAuth.Load().(string); a != "Bearer ollama" { + t.Fatalf("Authorization=%q, want placeholder Bearer ollama", a) + } + if m, _ := gotModel.Load().(string); m != "llama3.2" { + t.Fatalf("model on wire=%q", m) + } +} + +// A keyless Ollama profile must be usable end to end: stored round-trips, +// loadProfileConfig accepts it, the task chain validates it, the failover pool +// includes it, and the connection test completes against a fake Ollama. +func TestOllamaKeylessProfileLifecycle(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.Close() }) + if m.pg == nil { + t.Skip("no postgres for this run") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","model":"llama3.2","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)) + })) + t.Cleanup(fake.Close) + + pid, err := m.pg.SaveProfile(&db.LLMProfile{ + Name: "ollama-local", Format: "ollama", Model: "llama3.2", + BaseURL: fake.URL, APIKey: "", IsDefault: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) }) + + stored, err := m.pg.ProfileByID(pid) + if err != nil || stored == nil { + t.Fatalf("ProfileByID: %v %v", stored, err) + } + + if _, ok := s.loadProfileConfig(pid); !ok { + t.Fatal("keyless ollama profile must load") + } + if err := s.validateTaskProfileIDs([]int64{pid}); err != nil { + t.Fatalf("task chain must accept keyless ollama: %v", err) + } + pool, err := m.pg.PoolProfiles() + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range pool { + if p.ID == pid { + found = true + } + } + if !found { + t.Fatal("keyless ollama profile must join the failover pool") + } + + // Connection test: no api_key in the form either. + body, _ := json.Marshal(map[string]any{ + "profile_id": pid, "provider": "ollama", "model": "llama3.2", + "base_url": fake.URL, "api_key": "", "streaming": false, + }) + req := httptest.NewRequest(http.MethodPost, "/api/llm/test", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.testLLM(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("handler status %d", rec.Code) + } + var resp struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if !resp.OK { + t.Fatalf("connection test on keyless ollama failed: %s", resp.Error) + } +} + +// Model listing for an Ollama server works without an API key and tolerates a +// base URL with or without the /v1 suffix. +func TestOllamaListModels(t *testing.T) { + var calls atomic.Value // []string paths + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + paths, _ := calls.Load().([]string) + calls.Store(append(paths, r.URL.Path)) + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"llama3.2"},{"id":"qwen2.5:14b"}]}`)) + })) + t.Cleanup(fake.Close) + + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.Close() }) + if m.pg == nil { + t.Skip("no postgres for this run") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + + call := func(baseURL string) (bool, []string) { + body, _ := json.Marshal(map[string]any{"provider": "ollama", "base_url": baseURL, "api_key": ""}) + req := httptest.NewRequest(http.MethodPost, "/api/llm/models", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.pgListModels(rec, req) + var resp struct { + OK bool `json:"ok"` + Models []string `json:"models"` + Error string `json:"error"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + return resp.OK, resp.Models + } + // Base WITH /v1. + ok, models := call(fake.URL + "/v1") + if !ok || len(models) != 2 { + t.Fatalf("listing with /v1 base failed: ok=%v models=%v", ok, models) + } + // Base WITHOUT /v1 must still be normalized onto the OpenAI-compatible path + // (/v1/models); both calls hit the same endpoint. + ok2, models2 := call(fake.URL) + if !ok2 || len(models2) != 2 { + t.Fatalf("listing without /v1 base failed: ok=%v models=%v", ok2, models2) + } + if paths, _ := calls.Load().([]string); len(paths) != 2 || paths[0] != "/v1/models" || paths[1] != "/v1/models" { + t.Fatalf("both calls must hit /v1/models, got %v", paths) + } +} diff --git a/server/orchestration.go b/server/orchestration.go index 694b6af2..49941bdc 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -293,7 +293,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool { var pin *int64 if id := parseProfileID(a.LLMProfileID); id > 0 { if _, ok := s.loadProfileConfig(id); !ok { - return actool.Errorf(fmt.Sprintf("LLM profile #%d not found or has no API key set", id)), nil + return actool.Errorf(fmt.Sprintf("LLM profile #%d not found or not usable (no API key for the key-requiring formats)", id)), nil } pin = &id } else if a.ParentRef != "" { diff --git a/server/server.go b/server/server.go index 9c85a9d1..1b898abb 100644 --- a/server/server.go +++ b/server/server.go @@ -335,7 +335,8 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) { cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField cfg.SessionHeaderKey = p.SessionHeaderKey s.applyProfileRetry(&cfg, p) - if cfg.APIKey == "" { + // Ollama profiles run keyless (local server); the other formats need a key. + if cfg.APIKey == "" && cfg.NeedsAPIKey() { return cfg, false } s.cfgMu.Lock() @@ -491,7 +492,8 @@ func (s *Server) applyLLM(cfg agent.Config) error { } // loadProfileConfig builds an agent.Config from a specific profile id (with its key). -// ok=false when the profile is missing or has no api key. +// ok=false when the profile is missing or unusable (no api key for the key-requiring +// formats). func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) { p, err := s.m.pg.ProfileByID(id) if err != nil || p == nil { @@ -506,7 +508,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) { cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField cfg.SessionHeaderKey = p.SessionHeaderKey s.applyProfileRetry(&cfg, p) - if cfg.APIKey == "" { + if cfg.APIKey == "" && cfg.NeedsAPIKey() { return cfg, false } return cfg, true @@ -1343,12 +1345,13 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) { } cfg.ContextWindowK = k } - if cfg.APIKey == "" { + // Keyless formats (Ollama) need no key; the others keep/require one. + if cfg.APIKey == "" && cfg.NeedsAPIKey() { s.cfgMu.Lock() cfg.APIKey = s.llmCfg.APIKey // keep existing key if not re-entered s.cfgMu.Unlock() } - if cfg.APIKey == "" { + if cfg.APIKey == "" && cfg.NeedsAPIKey() { writeErr(w, 400, "api_key required") return } @@ -1441,28 +1444,30 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { // see TestConnection). Endpoints like opencode zen that mandate x-opencode-session return // 400 without it, so the test path must send it too — otherwise "chat works, test 400s". cfg.SessionHeaderKey = req.SessionHeaderKey - // API key resolution precedence: form input > the named profile's stored key > the global config's key. - // A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB. - // Same for the session header name: when the form omits it, the stored profile's value is the fallback. - if stored != nil && (cfg.APIKey == "" || cfg.SessionHeaderKey == "") { - // Trim like ConfigFrom does for form values: a key stored with - // surrounding whitespace runs fine in conversations (the runtime - // path re-trims) but would be sent raw here and fail auth. - if cfg.APIKey == "" { + // The custom session header name follows that profile regardless of format: + // when the form omits it, the stored profile's value is the fallback. + if stored != nil && cfg.SessionHeaderKey == "" { + cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey) + } + // Keyless formats (Ollama) skip the key resolution entirely. + if cfg.NeedsAPIKey() { + // API key resolution precedence: form input > the named profile's stored key > the global config's key. + // A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB. + if stored != nil && cfg.APIKey == "" { + // Trim like ConfigFrom does for form values: a key stored with + // surrounding whitespace runs fine in conversations (the runtime + // path re-trims) but would be sent raw here and fail auth. cfg.APIKey = strings.TrimSpace(stored.APIKey) } - if cfg.SessionHeaderKey == "" { - cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey) + if cfg.APIKey == "" { + s.cfgMu.Lock() + cfg.APIKey = s.llmCfg.APIKey + s.cfgMu.Unlock() + } + if cfg.APIKey == "" { + writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"}) + return } - } - if cfg.APIKey == "" { - s.cfgMu.Lock() - cfg.APIKey = s.llmCfg.APIKey - s.cfgMu.Unlock() - } - if cfg.APIKey == "" { - writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"}) - return } // Retry params do [not] go into the connection test: the test has a 30s hard timeout, and stacking the configured retry counts/long intervals on top // would only turn a perfectly usable endpoint into a "timeout failure". The test asks "does this endpoint work at all"; retry pacing @@ -1621,7 +1626,7 @@ func (s *Server) validateTaskProfileIDs(ids []int64) error { } seen[id] = true if _, ok := s.loadProfileConfig(id); !ok { - return fmt.Errorf("LLM profile #%d not found or has no API key set", id) + return fmt.Errorf("LLM profile #%d not found or not usable (no API key for the key-requiring formats)", id) } } return nil diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 2d2e6029..3791b22c 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -2037,12 +2037,13 @@ func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) { } // pgListModels fetches available models from the provider's API endpoint. -// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for +// Supports OpenAI-format (GET /models), Anthropic-format (GET /v1/models) and +// Ollama (keyless OpenAI-compatible GET /v1/models on the local server); for // Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on // the OpenAI path, it falls back to the OpenAI endpoint at the stripped root. func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { var req struct { - Provider string `json:"provider"` // "openai" | "anthropic" + Provider string `json:"provider"` // "openai" | "anthropic" | "ollama" BaseURL string `json:"base_url"` APIKey string `json:"api_key"` Proxy string `json:"proxy"` @@ -2059,13 +2060,14 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { apiKey = p.APIKey } } - if apiKey == "" { + provider := strings.TrimSpace(req.Provider) + // Ollama serves its model list keyless on the local server: no key gate. + if apiKey == "" && provider != "ollama" { writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"}) return } baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") - provider := strings.TrimSpace(req.Provider) // Candidate endpoints to try in order. Some Anthropic-compatible providers // (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose @@ -2097,6 +2099,22 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { } b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/") candidates = append(candidates, candidate{b + "/models", bearerHdr()}) + case "ollama": + // OpenAI-compatible list at {base}/v1/models. Tolerate base URLs with or + // without the /v1 suffix (and a full endpoint URL). No auth needed; when a + // key was provided anyway, send it as Bearer (Ollama ignores it). + if baseURL == "" { + baseURL = "http://127.0.0.1:11434/v1" + } + b := strings.TrimSuffix(baseURL, "/chat/completions") + if !strings.HasSuffix(b, "/v1") { + b += "/v1" + } + hdr := http.Header{} + if apiKey != "" { + hdr.Set("Authorization", "Bearer "+apiKey) + } + candidates = append(candidates, candidate{b + "/models", hdr}) default: // anthropic if baseURL == "" { baseURL = "https://api.anthropic.com" diff --git a/server/task_resolution.go b/server/task_resolution.go index 24309759..f59450e7 100644 --- a/server/task_resolution.go +++ b/server/task_resolution.go @@ -29,7 +29,8 @@ func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMR Model: p.Model, Source: source, } - if p.APIKey == "" { + // Ollama profiles are keyless by design; the other formats need a key. + if p.APIKey == "" && p.Format != "ollama" { result.Reason = "LLM profile has no API Key set" return result } diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 52af08f5..316985d4 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -49,13 +49,18 @@ const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [ { value: NONE, label: "max_tokens (default)" }, { value: "max_completion_tokens", label: "max_completion_tokens" }, ]; -// The other two formats hard-code their field names, so the option is meaningless for them; the hint texts spell this out. +// The other formats hard-code their field names, so the option is meaningless for them; the hint texts spell this out. const MAX_TOKENS_FIELD_HINTS: Record = { openai: "Which key carries the cap. max_tokens is the default and the vast majority of compatible gateways only accept it; OpenAI's official reasoning models (the o series / GPT-5) conversely only accept max_completion_tokens and fail outright with unsupported_parameter when they receive max_tokens.", anthropic: "Only selectable for the openai format. Anthropic's field name is fixed as max_tokens.", "openai-responses": "Only selectable for the openai format. The Responses API's field name is fixed as max_output_tokens.", + ollama: "Only selectable for the openai format. Ollama's field name is fixed as max_tokens.", }; +// LLM profile formats. The default in the create form is "anthropic"; "ollama" is the +// keyless local-server format (base URL defaults to http://127.0.0.1:11434/v1). +type ProfileFormat = "anthropic" | "openai" | "openai-responses" | "ollama"; +const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"; const EFFORT_LEVELS: { value: string; label: string }[] = [ { value: NONE, label: "Not sent (default)" }, { value: "low", label: "low" }, @@ -72,10 +77,11 @@ function cooldownText(secs: number) { } // The "healthy or not" badge a profile shows on its card. A profile without a Key cannot send any request at all — say that before the breaker; -// the other states come from the pool's breaker records (with polling off no new records appear, so "healthy" = no known failure). +// the exception is Ollama, a keyless local server that IS callable without a key. The other states come from the pool's breaker +// records (with polling off no new records appear, so "healthy" = no known failure). type Health = { label: string; cls: string; hint?: string }; function healthOf(p: LLMProfile, m?: LLMPoolMember): Health { - if (!p.api_key_hint) { + if (!p.api_key_hint && p.format !== "ollama") { return { label: "No Key configured", cls: "border-muted-foreground/40 text-muted-foreground", @@ -215,7 +221,8 @@ function PoolSheet({ {inChain.length < 2 && (

- Only {inChain.length} usable profile(s) right now, so the pool has no effect — at least 2 profiles with an API Key set and pool participation are required. + Only {inChain.length} usable profile(s) right now, so the pool has no effect — at least 2 usable profiles (API Key set, or Ollama) with pool participation are + required.

)} {chain.map((m) => { @@ -311,7 +318,7 @@ function ProfileSheet({ }) { const isNew = !profile; const [name, setName] = React.useState(""); - const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic"); + const [format, setFormat] = React.useState("anthropic"); const [model, setModel] = React.useState(""); const [baseUrl, setBaseUrl] = React.useState(""); const [proxy, setProxy] = React.useState(""); @@ -340,7 +347,11 @@ function ProfileSheet({ React.useEffect(() => { if (!open) return; setName(profile?.name ?? ""); - setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic"); + setFormat( + profile?.format === "openai" || profile?.format === "openai-responses" || profile?.format === "ollama" + ? profile.format + : "anthropic", + ); setModel(profile?.model ?? ""); setBaseUrl(profile?.base_url ?? ""); setProxy(profile?.proxy ?? ""); @@ -457,6 +468,13 @@ function ProfileSheet({ } } + let keyPlaceholder = "sk-…"; + if (format === "ollama") { + keyPlaceholder = "optional — a local Ollama server runs keyless"; + } else if (keyHint) { + keyPlaceholder = `Set (${keyHint}); leave empty to keep it`; + } + return (
- setFormat(v as ProfileFormat)}> @@ -500,6 +518,7 @@ function ProfileSheet({ Anthropic OpenAI (Chat Completions) OpenAI (Responses API) + Ollama (local, no key needed)
@@ -511,7 +530,7 @@ function ProfileSheet({ setModel(e.target.value)} /> @@ -557,7 +576,7 @@ function ProfileSheet({ setBaseUrl(e.target.value)} /> @@ -595,11 +614,13 @@ function ProfileSheet({
- + setApiKey(e.target.value)} /> diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index a9f98176..6797f9d0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1028,7 +1028,7 @@ export interface NotificationDelivery { export interface LLMProfile { id: string; name: string; - format: "openai" | "anthropic" | "openai-responses"; + format: "openai" | "anthropic" | "openai-responses" | "ollama"; base_url?: string; proxy?: string; model: string; From 978f308a9e643c471cad4b565f91e937e105e05a Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:18:25 +0100 Subject: [PATCH 2/2] fix(ollama): keyless env config and /v1 base URL normalization Two review fixes on top of the Ollama provider: FromEnv no longer inherits OPENAI_API_KEY for the ollama format. Ollama ignores the bearer token, so inheriting the cloud key could only ship it to the local or LAN endpoint, contradicting the comment in NewProvider and the documented behavior. ConfigFrom now normalizes the base URL to the /v1 root, so the natural Ollama URL (host:11434 without /v1) works for chat as well. Previously the provider appended /chat/completions to it and the request landed on a path Ollama does not serve, while pgListModels already appended /v1. Tests: bare-host normalization assertion, the /v1/chat/completions path assertion, and a new test that the env config carries no inherited key. --- agent/provider.go | 16 +++++++++++----- server/llm_ollama_test.go | 29 +++++++++++++++++++++++++++-- server/server_mgmt.go | 2 +- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/agent/provider.go b/agent/provider.go index 3bfa67a6..e79d60ca 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -224,7 +224,10 @@ func FromEnv() (Config, bool) { } case "ollama": c.Format = FormatOllama - c.APIKey = oaiKey // optional; a local Ollama server is usable keyless + // Deliberately NOT inheriting OPENAI_API_KEY: Ollama ignores the bearer + // token, so the only thing the inheritance could do is ship a real cloud + // key to the local (or LAN) endpoint. An authenticated Ollama gateway can + // still be configured as a saved profile with an explicit key. if c.BaseURL == "" { c.BaseURL = DefaultOllamaBaseURL } @@ -275,12 +278,15 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config { case "ollama": c.Format = FormatOllama // Ollama's OpenAI-compatible endpoint lives at /v1 and the provider - // appends "/chat/completions"; tolerate a full endpoint URL. Never leave - // it empty: an empty BaseURL would let the SDK fall back to OpenAI's - // public endpoint with this profile's request. - c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/chat/completions"), "/") + // appends "/chat/completions"; tolerate a full endpoint URL and the + // natural Ollama URL without the /v1 suffix. Never leave it empty: an + // empty BaseURL would let the SDK fall back to OpenAI's public endpoint + // with this profile's request. + c.BaseURL = strings.TrimRight(strings.TrimSuffix(strings.TrimSpace(c.BaseURL), "/chat/completions"), "/") if c.BaseURL == "" { c.BaseURL = DefaultOllamaBaseURL + } else if !strings.HasSuffix(c.BaseURL, "/v1") { + c.BaseURL += "/v1" } if c.Model == "" { c.Model = "llama3.2" diff --git a/server/llm_ollama_test.go b/server/llm_ollama_test.go index dbbf6bab..8a65966d 100644 --- a/server/llm_ollama_test.go +++ b/server/llm_ollama_test.go @@ -44,6 +44,12 @@ func TestOllamaConfigFromKeylessDefaults(t *testing.T) { if k.APIKey != "ollama-secret" { t.Fatalf("custom key lost: %q", k.APIKey) } + // The natural Ollama URL has no /v1, but the provider appends + // /chat/completions, so the base must be normalized to the /v1 root. + n := agent.ConfigFrom("ollama", "m", "http://box:11434", "", "") + if n.BaseURL != "http://box:11434/v1" { + t.Fatalf("bare host URL not normalized: %q", n.BaseURL) + } } // The provider built from an Ollama config speaks OpenAI Chat Completions on @@ -79,8 +85,11 @@ func TestOllamaProviderWiresOpenAIProtocol(t *testing.T) { if _, _, _, err = prov.Complete(ctx, req); err != nil { t.Fatalf("Complete: %v", err) } - if p, _ := gotPath.Load().(string); p != "/chat/completions" { - t.Fatalf("path=%q, want /chat/completions (base URL must NOT be the OpenAI fallback)", p) + // ConfigFrom normalizes the fake server URL (no /v1) onto the OpenAI root, + // so the request must land on /v1/chat/completions. A path of /chat/completions + // would mean the base URL leaked through unnormalized (or fell back to OpenAI). + if p, _ := gotPath.Load().(string); p != "/v1/chat/completions" { + t.Fatalf("path=%q, want /v1/chat/completions", p) } if a, _ := gotAuth.Load().(string); a != "Bearer ollama" { t.Fatalf("Authorization=%q, want placeholder Bearer ollama", a) @@ -221,3 +230,19 @@ func TestOllamaListModels(t *testing.T) { t.Fatalf("both calls must hit /v1/models, got %v", paths) } } + +// An Ollama config resolved from the environment must not inherit OPENAI_API_KEY: +// Ollama ignores the bearer token, so inheritance would only ship the cloud key +// to the local or LAN endpoint. +func TestOllamaFromEnvDoesNotInheritOpenAIKey(t *testing.T) { + t.Setenv("ARTEX_LLM_PROVIDER", "ollama") + t.Setenv("OPENAI_API_KEY", "sk-cloud-secret") + t.Setenv("ARTEX_LLM_BASE_URL", "") + c, ok := agent.FromEnv() + if !ok { + t.Fatal("ollama env config must be usable without a key") + } + if c.APIKey != "" { + t.Fatalf("ollama inherited a cloud key: %q", c.APIKey) + } +} diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 3791b22c..65be6bc2 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -2104,7 +2104,7 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { // without the /v1 suffix (and a full endpoint URL). No auth needed; when a // key was provided anyway, send it as Bearer (Ollama ignores it). if baseURL == "" { - baseURL = "http://127.0.0.1:11434/v1" + baseURL = agent.DefaultOllamaBaseURL } b := strings.TrimSuffix(baseURL, "/chat/completions") if !strings.HasSuffix(b, "/v1") {