mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
ollama llm
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user