feat: provider presets (openai, openrouter, ollama, dahl, gonkarouter, custom), /api/info, provider display in Android settings
This commit is contained in:
parent
d2ec0848c8
commit
7b92e259ad
14 changed files with 233 additions and 18 deletions
26
README.md
26
README.md
|
|
@ -39,13 +39,26 @@ export BYTECLAW_LLM_API_KEY=sk-... # OpenAI / OpenRouter; für Ollama
|
||||||
make run # = go build + ./bin/byteclaw -config config.yaml
|
make run # = go build + ./bin/byteclaw -config config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Provider-Beispiele (`llm.base_url` / `llm.model`):
|
Provider-Presets (`llm.provider` bzw. `BYTECLAW_LLM_PROVIDER`). Ein Preset füllt `base_url` und `model`, wenn sie leer sind; explizite Werte haben Vorrang. Der API-Key fällt auf die genannte Umgebungsvariable zurück. Alle nutzen `Authorization: Bearer <key>` und `/chat/completions`.
|
||||||
|
|
||||||
| Provider | base_url | model (Beispiel) |
|
| provider | base_url | Default-Modell | Key-Env | Quelle |
|
||||||
|------------|---------------------------------|-------------------------|
|
|---------------|-----------------------------------|--------------------------|-----------------------|--------|
|
||||||
| OpenAI | https://api.openai.com/v1 | gpt-4o-mini |
|
| `openai` | https://api.openai.com/v1 | gpt-4o-mini | OPENAI_API_KEY | platform.openai.com |
|
||||||
| OpenRouter | https://openrouter.ai/api/v1 | openai/gpt-4o-mini |
|
| `openrouter` | https://openrouter.ai/api/v1 | openai/gpt-4o-mini | OPENROUTER_API_KEY | openrouter.ai/docs |
|
||||||
| Ollama | http://localhost:11434/v1 | llama3.1 (Tool-fähig) |
|
| `ollama` | http://localhost:11434/v1 | llama3.1 | – | ollama docs |
|
||||||
|
| `dahl` | https://inference.dahl.global/v1 | MiniMaxAI/MiniMax-M2.7 | DAHL_API_KEY | https://docs.dahl.global/ |
|
||||||
|
| `gonkarouter` | https://api.gonkarouter.io/v1 | MiniMaxAI/MiniMax-M2.7 | GONKAROUTER_API_KEY | https://gonkarouter.io/docs |
|
||||||
|
| `custom` | (Pflicht) | (Pflicht) | – | – |
|
||||||
|
|
||||||
|
**Dahl** (dahl.global) und **GonkaRouter** sind OpenAI-kompatible Inferenz-Dienste für offene Modelle auf dem dezentralen Gonka-GPU-Netz. Weitere Modelle (Stand 25.10.2026): `zai-org/GLM-5.3-Flash`, `deepseek-ai/DeepSeek-V4-Flash-0731`. Aktuelle IDs immer über `GET <base_url>/models` prüfen. Bei Dahl braucht der Key ein zugewiesenes Token-Guthaben (sonst HTTP 402).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
llm:
|
||||||
|
provider: dahl # oder gonkarouter
|
||||||
|
model: zai-org/GLM-5.3-Flash # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/info` liefert Provider, Modell und base_url des Servers (ohne Key); die Android-App zeigt das bei „Verbindung testen" an.
|
||||||
|
|
||||||
Test:
|
Test:
|
||||||
|
|
||||||
|
|
@ -59,6 +72,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" -d '{"text":"Wie spät ist
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---------|------------------------------------|------------------------------------------|
|
|---------|------------------------------------|------------------------------------------|
|
||||||
| GET | /healthz | Health (ohne Auth) |
|
| GET | /healthz | Health (ohne Auth) |
|
||||||
|
| GET | /api/info | Version, Provider, Modell (ohne Secrets) |
|
||||||
| GET | /api/sessions | Sessions (neueste zuerst) |
|
| GET | /api/sessions | Sessions (neueste zuerst) |
|
||||||
| POST | /api/sessions | Session anlegen `{"title":""}` |
|
| POST | /api/sessions | Session anlegen `{"title":""}` |
|
||||||
| GET/PATCH/DELETE | /api/sessions/{id} | Lesen / umbenennen / löschen |
|
| GET/PATCH/DELETE | /api/sessions/{id} | Lesen / umbenennen / löschen |
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful }
|
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build()))
|
||||||
|
|
||||||
suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
|
suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
|
||||||
|
|
||||||
suspend fun messages(sessionId: String): List<Message> =
|
suspend fun messages(sessionId: String): List<Message> =
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.byteclaw.client.data.ByteClawApi
|
import dev.byteclaw.client.data.ByteClawApi
|
||||||
|
import dev.byteclaw.client.data.ProviderPresets
|
||||||
import dev.byteclaw.client.data.ServerSettings
|
import dev.byteclaw.client.data.ServerSettings
|
||||||
import dev.byteclaw.client.data.Session
|
import dev.byteclaw.client.data.Session
|
||||||
import dev.byteclaw.client.data.SettingsStore
|
import dev.byteclaw.client.data.SettingsStore
|
||||||
|
|
@ -39,7 +40,8 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
|
fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
|
||||||
val msg = runCatching {
|
val msg = runCatching {
|
||||||
ByteClawApi(s).sessions(); "Verbindung OK"
|
val i = ByteClawApi(s).info()
|
||||||
|
"Verbindung OK · Provider: ${ProviderPresets.label(i.provider)} · Modell: ${i.model}"
|
||||||
}.getOrElse { "Fehler: ${it.message}" }
|
}.getOrElse { "Fehler: ${it.message}" }
|
||||||
onResult(msg)
|
onResult(msg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,9 @@ fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) {
|
||||||
}) { Text("Verbindung testen") }
|
}) { Text("Verbindung testen") }
|
||||||
}
|
}
|
||||||
status?.let { Text(it) }
|
status?.let { Text(it) }
|
||||||
|
Text("Der LLM-Provider (z. B. Dahl, GonkaRouter) wird am Server über llm.provider gewählt. " +
|
||||||
|
"Verbindung testen zeigt den aktiven Provider und das Modell.",
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package dev.byteclaw.client
|
||||||
|
|
||||||
import dev.byteclaw.client.data.AgentEvent
|
import dev.byteclaw.client.data.AgentEvent
|
||||||
import dev.byteclaw.client.data.Message
|
import dev.byteclaw.client.data.Message
|
||||||
|
import dev.byteclaw.client.data.ProviderPresets
|
||||||
|
import dev.byteclaw.client.data.ServerInfo
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -19,4 +21,11 @@ class ModelsTest {
|
||||||
"""{"id":2,"role":"assistant","content":"","tool_calls":[{"id":"c1","name":"time_now","arguments":"{}"}],"created_at":"x"}""")
|
"""{"id":2,"role":"assistant","content":"","tool_calls":[{"id":"c1","name":"time_now","arguments":"{}"}],"created_at":"x"}""")
|
||||||
assertEquals("time_now", m.toolCalls.single().name)
|
assertEquals("time_now", m.toolCalls.single().name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun decodesServerInfoAndLabels() {
|
||||||
|
val i = json.decodeFromString(ServerInfo.serializer(), """{"provider":"gonkarouter","model":"MiniMaxAI/MiniMax-M2.7","base_url":"https://api.gonkarouter.io/v1"}""")
|
||||||
|
assertEquals("GonkaRouter", ProviderPresets.label(i.provider))
|
||||||
|
assertEquals("Dahl (dahl.global)", ProviderPresets.label("dahl"))
|
||||||
|
assertEquals("x", ProviderPresets.label("x"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ func run(cfgPath string, log *slog.Logger) error {
|
||||||
ag := &agent.Agent{LLM: prov, Tools: reg, Store: st, SystemPrompt: cfg.Agent.SystemPrompt,
|
ag := &agent.Agent{LLM: prov, Tools: reg, Store: st, SystemPrompt: cfg.Agent.SystemPrompt,
|
||||||
MaxIterations: cfg.Agent.MaxIterations, HistoryLimit: cfg.Agent.HistoryLimit}
|
MaxIterations: cfg.Agent.MaxIterations, HistoryLimit: cfg.Agent.HistoryLimit}
|
||||||
srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log,
|
srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log,
|
||||||
|
Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL},
|
||||||
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}
|
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}
|
||||||
|
|
||||||
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
|
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second}
|
||||||
|
|
@ -74,7 +75,7 @@ func run(cfgPath string, log *slog.Logger) error {
|
||||||
defer stop()
|
defer stop()
|
||||||
errc := make(chan error, 1)
|
errc := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Info("byteclaw listening", "addr", cfg.Listen, "model", cfg.LLM.Model, "tools", len(reg.Specs()), "version", version)
|
log.Info("byteclaw listening", "addr", cfg.Listen, "provider", cfg.LLM.Provider, "model", cfg.LLM.Model, "tools", len(reg.Specs()), "version", version)
|
||||||
errc <- hs.ListenAndServe()
|
errc <- hs.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@ data_dir: ./data
|
||||||
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
base_url: https://api.openai.com/v1 # OpenRouter: https://openrouter.ai/api/v1 · Ollama: http://localhost:11434/v1
|
# Preset: openai | openrouter | ollama | dahl | gonkarouter | custom (env: BYTECLAW_LLM_PROVIDER)
|
||||||
|
# Presets fill base_url/model if empty; api_key falls back to OPENAI_API_KEY, OPENROUTER_API_KEY,
|
||||||
|
# DAHL_API_KEY or GONKAROUTER_API_KEY. "custom" requires base_url and model.
|
||||||
|
provider: openai
|
||||||
|
base_url: "" # override, e.g. https://inference.dahl.global/v1
|
||||||
api_key: ""
|
api_key: ""
|
||||||
model: gpt-4o-mini
|
model: "" # e.g. MiniMaxAI/MiniMax-M2.7, zai-org/GLM-5.3-Flash
|
||||||
timeout_sec: 120
|
timeout_sec: 120
|
||||||
temperature: 0.7
|
temperature: 0.7
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type LLM struct {
|
type LLM struct {
|
||||||
|
// Provider selects a preset: openai, openrouter, ollama, dahl, gonkarouter or custom.
|
||||||
|
Provider string `yaml:"provider" json:"provider"`
|
||||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
APIKey string `yaml:"api_key" json:"api_key"`
|
APIKey string `yaml:"api_key" json:"api_key"`
|
||||||
Model string `yaml:"model" json:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
|
|
@ -61,8 +63,7 @@ func Default() Config {
|
||||||
Listen: "127.0.0.1:8787",
|
Listen: "127.0.0.1:8787",
|
||||||
DataDir: "./data",
|
DataDir: "./data",
|
||||||
LLM: LLM{
|
LLM: LLM{
|
||||||
BaseURL: "https://api.openai.com/v1",
|
Provider: "openai",
|
||||||
Model: "gpt-4o-mini",
|
|
||||||
TimeoutSec: 120,
|
TimeoutSec: 120,
|
||||||
Temperature: 0.7,
|
Temperature: 0.7,
|
||||||
},
|
},
|
||||||
|
|
@ -98,6 +99,9 @@ func Load(path string) (Config, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyEnv(&cfg, os.Getenv)
|
applyEnv(&cfg, os.Getenv)
|
||||||
|
if err := applyPreset(&cfg.LLM, os.Getenv); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
if cfg.Workspace == "" {
|
if cfg.Workspace == "" {
|
||||||
cfg.Workspace = filepath.Join(cfg.DataDir, "workspace")
|
cfg.Workspace = filepath.Join(cfg.DataDir, "workspace")
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +118,7 @@ func applyEnv(c *Config, get func(string) string) {
|
||||||
set("BYTECLAW_AUTH_TOKEN", &c.AuthToken)
|
set("BYTECLAW_AUTH_TOKEN", &c.AuthToken)
|
||||||
set("BYTECLAW_DATA_DIR", &c.DataDir)
|
set("BYTECLAW_DATA_DIR", &c.DataDir)
|
||||||
set("BYTECLAW_WORKSPACE", &c.Workspace)
|
set("BYTECLAW_WORKSPACE", &c.Workspace)
|
||||||
|
set("BYTECLAW_LLM_PROVIDER", &c.LLM.Provider)
|
||||||
set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL)
|
set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL)
|
||||||
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
|
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
|
||||||
set("BYTECLAW_LLM_MODEL", &c.LLM.Model)
|
set("BYTECLAW_LLM_MODEL", &c.LLM.Model)
|
||||||
|
|
|
||||||
66
internal/config/presets.go
Normal file
66
internal/config/presets.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preset describes a known OpenAI-compatible provider. All presets use
|
||||||
|
// "Authorization: Bearer <api_key>" against <base_url>/chat/completions.
|
||||||
|
type Preset struct {
|
||||||
|
Name string
|
||||||
|
BaseURL string
|
||||||
|
DefaultModel string
|
||||||
|
// KeyEnv is an additional env var read for the API key if none is configured.
|
||||||
|
KeyEnv string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets: values verified against provider docs (see Source) on 2026-10-25.
|
||||||
|
var Presets = map[string]Preset{
|
||||||
|
"openai": {Name: "openai", BaseURL: "https://api.openai.com/v1", DefaultModel: "gpt-4o-mini", KeyEnv: "OPENAI_API_KEY", Source: "https://platform.openai.com/docs/api-reference"},
|
||||||
|
"openrouter": {Name: "openrouter", BaseURL: "https://openrouter.ai/api/v1", DefaultModel: "openai/gpt-4o-mini", KeyEnv: "OPENROUTER_API_KEY", Source: "https://openrouter.ai/docs"},
|
||||||
|
"ollama": {Name: "ollama", BaseURL: "http://localhost:11434/v1", DefaultModel: "llama3.1", Source: "https://github.com/ollama/ollama/blob/main/docs/openai.md"},
|
||||||
|
// Dahl: OpenAI-compatible inference on the Gonka network.
|
||||||
|
"dahl": {Name: "dahl", BaseURL: "https://inference.dahl.global/v1", DefaultModel: "MiniMaxAI/MiniMax-M2.7", KeyEnv: "DAHL_API_KEY", Source: "https://docs.dahl.global/"},
|
||||||
|
// GonkaRouter: OpenAI-/Anthropic-compatible router on the Gonka network.
|
||||||
|
"gonkarouter": {Name: "gonkarouter", BaseURL: "https://api.gonkarouter.io/v1", DefaultModel: "MiniMaxAI/MiniMax-M2.7", KeyEnv: "GONKAROUTER_API_KEY", Source: "https://gonkarouter.io/docs"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresetNames returns known provider names plus "custom".
|
||||||
|
func PresetNames() []string {
|
||||||
|
out := []string{"custom"}
|
||||||
|
for k := range Presets {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPreset fills empty base_url/model/api_key from the selected provider preset.
|
||||||
|
// Explicit config values always win. "custom" requires base_url and model.
|
||||||
|
func applyPreset(l *LLM, get func(string) string) error {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(l.Provider))
|
||||||
|
if name == "" {
|
||||||
|
name = "openai"
|
||||||
|
}
|
||||||
|
l.Provider = name
|
||||||
|
if name == "custom" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, ok := Presets[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown llm.provider %q (known: %s)", name, strings.Join(PresetNames(), ", "))
|
||||||
|
}
|
||||||
|
if l.BaseURL == "" {
|
||||||
|
l.BaseURL = p.BaseURL
|
||||||
|
}
|
||||||
|
if l.Model == "" {
|
||||||
|
l.Model = p.DefaultModel
|
||||||
|
}
|
||||||
|
if l.APIKey == "" && p.KeyEnv != "" {
|
||||||
|
l.APIKey = get(p.KeyEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
76
internal/config/presets_test.go
Normal file
76
internal/config/presets_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func load(t *testing.T, yaml string) (Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "c.yaml")
|
||||||
|
os.WriteFile(p, []byte("auth_token: aaaaaaaaaaaaaaaaaaaa\n"+yaml), 0o600)
|
||||||
|
return Load(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetDahl(t *testing.T) {
|
||||||
|
t.Setenv("DAHL_API_KEY", "dahl_test")
|
||||||
|
c, err := load(t, "llm:\n provider: dahl\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.LLM.BaseURL != "https://inference.dahl.global/v1" || c.LLM.Model != "MiniMaxAI/MiniMax-M2.7" || c.LLM.APIKey != "dahl_test" {
|
||||||
|
t.Fatalf("%+v", c.LLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetGonkaRouterViaEnv(t *testing.T) {
|
||||||
|
t.Setenv("BYTECLAW_LLM_PROVIDER", "GonkaRouter")
|
||||||
|
t.Setenv("GONKAROUTER_API_KEY", "sk-g")
|
||||||
|
c, err := load(t, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.LLM.Provider != "gonkarouter" || c.LLM.BaseURL != "https://api.gonkarouter.io/v1" || c.LLM.Model != "MiniMaxAI/MiniMax-M2.7" || c.LLM.APIKey != "sk-g" {
|
||||||
|
t.Fatalf("%+v", c.LLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetExplicitValuesWin(t *testing.T) {
|
||||||
|
t.Setenv("GONKAROUTER_API_KEY", "from-env")
|
||||||
|
c, err := load(t, "llm:\n provider: gonkarouter\n model: zai-org/GLM-5.3-Flash\n api_key: explicit\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.LLM.Model != "zai-org/GLM-5.3-Flash" || c.LLM.APIKey != "explicit" || c.LLM.BaseURL != "https://api.gonkarouter.io/v1" {
|
||||||
|
t.Fatalf("%+v", c.LLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetDefaultIsOpenAI(t *testing.T) {
|
||||||
|
c, err := load(t, "")
|
||||||
|
if err != nil || c.LLM.Provider != "openai" || c.LLM.BaseURL != "https://api.openai.com/v1" {
|
||||||
|
t.Fatalf("%+v %v", c.LLM, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetUnknownAndCustom(t *testing.T) {
|
||||||
|
if _, err := load(t, "llm:\n provider: nope\n"); err == nil {
|
||||||
|
t.Fatal("unknown provider must fail")
|
||||||
|
}
|
||||||
|
if _, err := load(t, "llm:\n provider: custom\n"); err == nil {
|
||||||
|
t.Fatal("custom without base_url/model must fail validation")
|
||||||
|
}
|
||||||
|
c, err := load(t, "llm:\n provider: custom\n base_url: http://x/v1\n model: m\n")
|
||||||
|
if err != nil || c.LLM.BaseURL != "http://x/v1" {
|
||||||
|
t.Fatalf("%+v %v", c.LLM, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllPresetsComplete(t *testing.T) {
|
||||||
|
for n, p := range Presets {
|
||||||
|
if p.Name != n || p.BaseURL == "" || p.DefaultModel == "" || p.Source == "" {
|
||||||
|
t.Errorf("incomplete preset %q: %+v", n, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,12 +29,22 @@ type Server struct {
|
||||||
AllowedOrigins []string
|
AllowedOrigins []string
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
RunTimeout time.Duration
|
RunTimeout time.Duration
|
||||||
|
// Info is returned by GET /api/info (never contains secrets).
|
||||||
|
Info Info
|
||||||
|
}
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
|
||||||
api := http.NewServeMux()
|
api := http.NewServeMux()
|
||||||
|
api.HandleFunc("GET /api/info", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.Info) })
|
||||||
api.HandleFunc("GET /api/sessions", s.listSessions)
|
api.HandleFunc("GET /api/sessions", s.listSessions)
|
||||||
api.HandleFunc("POST /api/sessions", s.createSession)
|
api.HandleFunc("POST /api/sessions", s.createSession)
|
||||||
api.HandleFunc("GET /api/sessions/{id}", s.getSession)
|
api.HandleFunc("GET /api/sessions/{id}", s.getSession)
|
||||||
|
|
@ -115,7 +125,9 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) }
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBody)).Decode(v); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBody)).Decode(v); err != nil {
|
||||||
|
|
@ -144,7 +156,9 @@ func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct{ Title string `json:"title"` }
|
var b struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
if r.ContentLength != 0 && !decode(w, r, &b) {
|
if r.ContentLength != 0 && !decode(w, r, &b) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +180,9 @@ func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
|
||||||
var b struct{ Title string `json:"title"` }
|
var b struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
if !decode(w, r, &b) {
|
if !decode(w, r, &b) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ func setup(t *testing.T, origins ...string) *httptest.Server {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
s := &Server{Store: st, Token: tok, AllowedOrigins: origins, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second,
|
s := &Server{Store: st, Token: tok, AllowedOrigins: origins, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Info: Info{Provider: "dahl", Model: "MiniMaxAI/MiniMax-M2.7"},
|
||||||
Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}}
|
Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}}
|
||||||
ts := httptest.NewServer(s.Handler())
|
ts := httptest.NewServer(s.Handler())
|
||||||
t.Cleanup(ts.Close)
|
t.Cleanup(ts.Close)
|
||||||
|
|
@ -154,3 +154,16 @@ func TestWebSocketStreaming(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInfo(t *testing.T) {
|
||||||
|
ts := setup(t)
|
||||||
|
if r := do(t, "GET", ts.URL+"/api/info", "", nil); r.StatusCode != 401 {
|
||||||
|
t.Fatal(r.StatusCode)
|
||||||
|
}
|
||||||
|
r := do(t, "GET", ts.URL+"/api/info", "", authH)
|
||||||
|
var i Info
|
||||||
|
json.NewDecoder(r.Body).Decode(&i)
|
||||||
|
if i.Provider != "dahl" || i.Model != "MiniMaxAI/MiniMax-M2.7" {
|
||||||
|
t.Fatalf("%+v", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ func (TimeNow) Spec() llm.ToolSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t TimeNow) Call(_ context.Context, raw json.RawMessage) (string, error) {
|
func (t TimeNow) Call(_ context.Context, raw json.RawMessage) (string, error) {
|
||||||
var a struct{ Timezone string `json:"timezone"` }
|
var a struct {
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
}
|
||||||
if err := json.Unmarshal(raw, &a); err != nil {
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,9 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (w *WebFetch) Call(ctx context.Context, raw json.RawMessage) (string, error) {
|
func (w *WebFetch) Call(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||||
var a struct{ URL string `json:"url"` }
|
var a struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
if err := json.Unmarshal(raw, &a); err != nil {
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue