feat: provider presets (openai, openrouter, ollama, dahl, gonkarouter, custom), /api/info, provider display in Android settings

This commit is contained in:
OpenClaw Dev 2026-10-25 04:30:26 +01:00
parent d2ec0848c8
commit 7b92e259ad
14 changed files with 233 additions and 18 deletions

View file

@ -13,6 +13,8 @@ import (
)
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"`
APIKey string `yaml:"api_key" json:"api_key"`
Model string `yaml:"model" json:"model"`
@ -61,8 +63,7 @@ func Default() Config {
Listen: "127.0.0.1:8787",
DataDir: "./data",
LLM: LLM{
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
Provider: "openai",
TimeoutSec: 120,
Temperature: 0.7,
},
@ -98,6 +99,9 @@ func Load(path string) (Config, error) {
}
}
applyEnv(&cfg, os.Getenv)
if err := applyPreset(&cfg.LLM, os.Getenv); err != nil {
return cfg, err
}
if cfg.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_DATA_DIR", &c.DataDir)
set("BYTECLAW_WORKSPACE", &c.Workspace)
set("BYTECLAW_LLM_PROVIDER", &c.LLM.Provider)
set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL)
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
set("BYTECLAW_LLM_MODEL", &c.LLM.Model)

View 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
}

View 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)
}
}
}

View file

@ -29,12 +29,22 @@ type Server struct {
AllowedOrigins []string
Log *slog.Logger
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 {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
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("POST /api/sessions", s.createSession)
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)
}
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 {
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) {
var b struct{ Title string `json:"title"` }
var b struct {
Title string `json:"title"`
}
if r.ContentLength != 0 && !decode(w, r, &b) {
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) {
var b struct{ Title string `json:"title"` }
var b struct {
Title string `json:"title"`
}
if !decode(w, r, &b) {
return
}

View file

@ -39,7 +39,7 @@ func setup(t *testing.T, origins ...string) *httptest.Server {
t.Fatal(err)
}
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}}
ts := httptest.NewServer(s.Handler())
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)
}
}

View file

@ -21,7 +21,9 @@ func (TimeNow) Spec() llm.ToolSpec {
}
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 {
return "", err
}

View file

@ -79,7 +79,9 @@ var (
)
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 {
return "", err
}