From 7b92e259ada713107c807bb3f9569d1150537262 Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 25 Oct 2026 04:30:26 +0100 Subject: [PATCH] feat: provider presets (openai, openrouter, ollama, dahl, gonkarouter, custom), /api/info, provider display in Android settings --- README.md | 26 +++++-- .../dev/byteclaw/client/data/ByteClawApi.kt | 2 + .../dev/byteclaw/client/ui/ChatViewModel.kt | 4 +- .../java/dev/byteclaw/client/ui/Screens.kt | 3 + .../java/dev/byteclaw/client/ModelsTest.kt | 9 +++ cmd/byteclaw/main.go | 3 +- config.example.yaml | 8 +- internal/config/config.go | 9 ++- internal/config/presets.go | 66 ++++++++++++++++ internal/config/presets_test.go | 76 +++++++++++++++++++ internal/server/server.go | 22 +++++- internal/server/server_test.go | 15 +++- internal/tools/timetool.go | 4 +- internal/tools/webfetch.go | 4 +- 14 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 internal/config/presets.go create mode 100644 internal/config/presets_test.go diff --git a/README.md b/README.md index 6f127e0..0ce61af 100644 --- a/README.md +++ b/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 ``` -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 ` und `/chat/completions`. -| Provider | base_url | model (Beispiel) | -|------------|---------------------------------|-------------------------| -| OpenAI | https://api.openai.com/v1 | gpt-4o-mini | -| OpenRouter | https://openrouter.ai/api/v1 | openai/gpt-4o-mini | -| Ollama | http://localhost:11434/v1 | llama3.1 (Tool-fähig) | +| provider | base_url | Default-Modell | Key-Env | Quelle | +|---------------|-----------------------------------|--------------------------|-----------------------|--------| +| `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_API_KEY | openrouter.ai/docs | +| `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 /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: @@ -59,6 +72,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" -d '{"text":"Wie spät ist | Methode | Pfad | Zweck | |---------|------------------------------------|------------------------------------------| | GET | /healthz | Health (ohne Auth) | +| GET | /api/info | Version, Provider, Modell (ohne Secrets) | | GET | /api/sessions | Sessions (neueste zuerst) | | POST | /api/sessions | Session anlegen `{"title":""}` | | GET/PATCH/DELETE | /api/sessions/{id} | Lesen / umbenennen / löschen | diff --git a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt index a2ba2d2..0bd892d 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt @@ -40,6 +40,8 @@ class ByteClawApi(private val settings: ServerSettings) { 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 = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build())) suspend fun messages(sessionId: String): List = diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index bddbe7f..cee1601 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dev.byteclaw.client.data.ByteClawApi +import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ServerSettings import dev.byteclaw.client.data.Session 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 { 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}" } onResult(msg) } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index 7498bba..56f8a27 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -158,6 +158,9 @@ fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) { }) { Text("Verbindung testen") } } 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) } } } diff --git a/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt b/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt index 5104994..482a2cb 100644 --- a/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt +++ b/android/app/src/test/java/dev/byteclaw/client/ModelsTest.kt @@ -2,6 +2,8 @@ package dev.byteclaw.client import dev.byteclaw.client.data.AgentEvent import dev.byteclaw.client.data.Message +import dev.byteclaw.client.data.ProviderPresets +import dev.byteclaw.client.data.ServerInfo import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals 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"}""") 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")) + } } diff --git a/cmd/byteclaw/main.go b/cmd/byteclaw/main.go index a0f33aa..a6be006 100644 --- a/cmd/byteclaw/main.go +++ b/cmd/byteclaw/main.go @@ -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, MaxIterations: cfg.Agent.MaxIterations, HistoryLimit: cfg.Agent.HistoryLimit} 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} 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() errc := make(chan error, 1) 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() }() select { diff --git a/config.example.yaml b/config.example.yaml index a719d95..76b17b9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,9 +8,13 @@ data_dir: ./data workspace: "" # default: /workspace; file tools cannot leave it 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: "" - model: gpt-4o-mini + model: "" # e.g. MiniMaxAI/MiniMax-M2.7, zai-org/GLM-5.3-Flash timeout_sec: 120 temperature: 0.7 diff --git a/internal/config/config.go b/internal/config/config.go index e5b1c9c..54a6a97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/presets.go b/internal/config/presets.go new file mode 100644 index 0000000..15239cd --- /dev/null +++ b/internal/config/presets.go @@ -0,0 +1,66 @@ +package config + +import ( + "fmt" + "sort" + "strings" +) + +// Preset describes a known OpenAI-compatible provider. All presets use +// "Authorization: Bearer " against /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 +} diff --git a/internal/config/presets_test.go b/internal/config/presets_test.go new file mode 100644 index 0000000..d2e957c --- /dev/null +++ b/internal/config/presets_test.go @@ -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) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index cdf1b45..7d57478 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e65b5a2..cdb4518 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) + } +} diff --git a/internal/tools/timetool.go b/internal/tools/timetool.go index 0c0c600..1ab41c4 100644 --- a/internal/tools/timetool.go +++ b/internal/tools/timetool.go @@ -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 } diff --git a/internal/tools/webfetch.go b/internal/tools/webfetch.go index 5c934bb..7e4e31d 100644 --- a/internal/tools/webfetch.go +++ b/internal/tools/webfetch.go @@ -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 }