diff --git a/README.md b/README.md index 81e935f..a140101 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 a8df7d5..e79d60c 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,18 @@ func FromEnv() (Config, bool) { if c.Model == "" { c.Model = "gpt-5" } + case "ollama": + c.Format = FormatOllama + // 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 + } + if c.Model == "" { + c.Model = "llama3.2" + } default: c.Format = llm.FormatAnthropic c.APIKey = anthKey @@ -203,7 +241,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 +275,22 @@ 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 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" + } default: c.Format = llm.FormatAnthropic // provider appends "/v1/messages". @@ -257,13 +312,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 +334,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 cf7e705..a20977e 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 8efe57c..5bb34e9 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 d53107e..c88f846 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 0000000..8a65966 --- /dev/null +++ b/server/llm_ollama_test.go @@ -0,0 +1,248 @@ +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 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 +// 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) + } + // 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) + } + 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) + } +} + +// 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/orchestration.go b/server/orchestration.go index 694b6af..49941bd 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 9c85a9d..1b898ab 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 2d2e602..65be6bc 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 = agent.DefaultOllamaBaseURL + } + 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 2430975..f59450e 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 52af08f..316985d 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 a9f9817..6797f9d 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;