ollama llm

This commit is contained in:
Glauber Portella
2026-10-23 18:12:38 -03:00
parent b3717f7a5f
commit 80a1ee96f6
12 changed files with 403 additions and 65 deletions
+2 -2
View File
@@ -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).
+78 -10
View File
@@ -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,
}
+5 -4
View File
@@ -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
+5 -4
View File
@@ -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.
+2 -2
View File
@@ -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
}
}
+223
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 != "" {
+30 -25
View File
@@ -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
+22 -4
View File
@@ -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"
+2 -1
View File
@@ -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
}
+32 -11
View File
@@ -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<string, string> = {
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({
</div>
{inChain.length < 2 && (
<p className="text-muted-foreground text-xs">
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.
</p>
)}
{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<ProfileFormat>("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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
@@ -492,7 +510,7 @@ function ProfileSheet({
</div>
<div className="grid gap-2">
<Label>Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
<Select value={format} onValueChange={(v) => setFormat(v as ProfileFormat)}>
<SelectTrigger>
<SelectValue placeholder="Select a format" />
</SelectTrigger>
@@ -500,6 +518,7 @@ function ProfileSheet({
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
<SelectItem value="ollama">Ollama (local, no key needed)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -511,7 +530,7 @@ function ProfileSheet({
<Input
id="p-model"
className="font-mono"
placeholder="claude-opus-4-8"
placeholder={format === "ollama" ? "llama3.2" : "claude-opus-4-8"}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
@@ -557,7 +576,7 @@ function ProfileSheet({
<Input
id="p-base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
placeholder={format === "ollama" ? OLLAMA_DEFAULT_BASE_URL : "https://api.openai.com/v1"}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
@@ -595,11 +614,13 @@ function ProfileSheet({
</div>
<div className="grid gap-2">
<Label htmlFor="p-api-key">API Key</Label>
<Label htmlFor="p-api-key">
{format === "ollama" ? "API Key (optional for Ollama)" : "API Key"}
</Label>
<Input
id="p-api-key"
type="password"
placeholder={keyHint ? `Set (${keyHint}); leave empty to keep it` : "sk-…"}
placeholder={keyPlaceholder}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
+1 -1
View File
@@ -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;