diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go
index 233a4c4..1a038e8 100644
--- a/backend/cmd/cyrene/main.go
+++ b/backend/cmd/cyrene/main.go
@@ -237,6 +237,7 @@ func main() {
AuthMgr: authMgr,
Orchestrator: orch,
LLMLogger: &llmLoggerAdapter{Logger: llmLogger},
+ ModelConfig: api.NewModelConfigStore(),
PluginList: pluginNamesToMap(reg.ListNames()),
}
if memSvc != nil {
diff --git a/backend/services/gateway/api/models.go b/backend/services/gateway/api/models.go
new file mode 100644
index 0000000..be7bc25
--- /dev/null
+++ b/backend/services/gateway/api/models.go
@@ -0,0 +1,135 @@
+// Package api — ModelConfigStore manages LLM provider/model/routing configuration.
+package api
+
+import (
+ "encoding/json"
+ "sync"
+)
+
+// ModelProvider represents an LLM API provider.
+type ModelProvider struct {
+ Name string `json:"name"`
+ BaseURL string `json:"base_url"`
+ APIKey string `json:"api_key,omitempty"`
+ Timeout int `json:"timeout_sec"`
+ Retries int `json:"max_retries"`
+}
+
+// ModelEntry represents a configured LLM model.
+type ModelEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ Description string `json:"description"`
+ Tags []string `json:"tags"`
+ Params map[string]any `json:"params"`
+ Enabled bool `json:"enabled"`
+}
+
+// RoutingRule defines fallback chain for a purpose.
+type RoutingRule struct {
+ Purpose string `json:"purpose"`
+ FallbackChain []string `json:"fallback_chain"`
+ Required bool `json:"required"`
+}
+
+// ModelConfigStore is an in-memory store for model configuration.
+type ModelConfigStore struct {
+ mu sync.RWMutex
+ providers map[string]*ModelProvider
+ models map[string]*ModelEntry
+ routing map[string]*RoutingRule
+}
+
+// NewModelConfigStore creates a store with sensible defaults.
+func NewModelConfigStore() *ModelConfigStore {
+ s := &ModelConfigStore{
+ providers: make(map[string]*ModelProvider),
+ models: make(map[string]*ModelEntry),
+ routing: make(map[string]*RoutingRule),
+ }
+
+ // Default provider
+ s.providers["openai"] = &ModelProvider{
+ Name: "openai", BaseURL: "https://api.openai.com/v1",
+ Timeout: 120, Retries: 3,
+ }
+ // Default models
+ s.models["primary"] = &ModelEntry{
+ ID: "primary", Name: "gpt-4o", Provider: "openai",
+ Description: "主对话模型", Tags: []string{"chat"},
+ Params: map[string]any{"temperature": 0.8}, Enabled: true,
+ }
+ s.models["fallback"] = &ModelEntry{
+ ID: "fallback", Name: "gpt-4o-mini", Provider: "openai",
+ Description: "备用模型", Tags: []string{"chat"},
+ Params: map[string]any{"temperature": 0.8}, Enabled: true,
+ }
+ // Default routing
+ s.routing["chat"] = &RoutingRule{
+ Purpose: "chat", FallbackChain: []string{"primary", "fallback"}, Required: true,
+ }
+ return s
+}
+
+func (s *ModelConfigStore) GetProviders() []*ModelProvider {
+ s.mu.RLock(); defer s.mu.RUnlock()
+ result := make([]*ModelProvider, 0, len(s.providers))
+ for _, p := range s.providers { result = append(result, p) }
+ return result
+}
+
+func (s *ModelConfigStore) GetModels() []*ModelEntry {
+ s.mu.RLock(); defer s.mu.RUnlock()
+ result := make([]*ModelEntry, 0, len(s.models))
+ for _, m := range s.models { result = append(result, m) }
+ return result
+}
+
+func (s *ModelConfigStore) GetRouting() []*RoutingRule {
+ s.mu.RLock(); defer s.mu.RUnlock()
+ result := make([]*RoutingRule, 0, len(s.routing))
+ for _, r := range s.routing { result = append(result, r) }
+ return result
+}
+
+func (s *ModelConfigStore) SaveProvider(p *ModelProvider) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ s.providers[p.Name] = p
+}
+
+func (s *ModelConfigStore) SaveModel(m *ModelEntry) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ s.models[m.ID] = m
+}
+
+func (s *ModelConfigStore) SaveRouting(r *RoutingRule) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ s.routing[r.Purpose] = r
+}
+
+func (s *ModelConfigStore) DeleteProvider(name string) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ delete(s.providers, name)
+}
+
+func (s *ModelConfigStore) DeleteModel(id string) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ delete(s.models, id)
+}
+
+func (s *ModelConfigStore) DeleteRouting(purpose string) {
+ s.mu.Lock(); defer s.mu.Unlock()
+ delete(s.routing, purpose)
+}
+
+// Import/Export for models.json compatibility
+func (s *ModelConfigStore) ExportJSON() ([]byte, error) {
+ s.mu.RLock(); defer s.mu.RUnlock()
+ export := map[string]any{
+ "providers": s.providers,
+ "models": s.models,
+ "routing": s.routing,
+ }
+ return json.MarshalIndent(export, "", " ")
+}
diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go
index 7a64dfd..9d7f3a1 100644
--- a/backend/services/gateway/api/router.go
+++ b/backend/services/gateway/api/router.go
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
+ "fmt"
"net/http"
"strings"
"time"
@@ -73,6 +74,7 @@ type Dependencies struct {
Orchestrator OrchestratorPipeline
WSHub WebSocketHub
LLMLogger LLMCallLogger
+ ModelConfig *ModelConfigStore
PluginList []map[string]string
}
@@ -114,6 +116,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/v1/admin/system", r.authMW(r.adminMW(r.systemInfoHandler)))
r.mux.HandleFunc("/api/v1/admin/plugins", r.authMW(r.adminMW(r.pluginListHandler)))
r.mux.HandleFunc("/api/v1/admin/sessions", r.authMW(r.adminMW(r.adminSessionsHandler)))
+ r.mux.HandleFunc("/api/v1/admin/models/config", r.authMW(r.adminMW(r.modelConfigHandler)))
+ r.mux.HandleFunc("/api/v1/admin/models/config/", r.authMW(r.adminMW(r.modelConfigItemHandler)))
}
// ServeHTTP implements http.Handler.
@@ -392,7 +396,94 @@ func (r *Router) pluginListHandler(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) adminSessionsHandler(w http.ResponseWriter, _ *http.Request) {
- writeJSON(w, 200, M{"sessions": []any{}, "total": r.deps.WSHub.Count()})
+ writeJSON(w, 200, M{"sessions": []any{}, "total": 0})
+}
+
+// --- Model Config ---
+
+func (r *Router) modelConfigHandler(w http.ResponseWriter, req *http.Request) {
+ if r.deps.ModelConfig == nil {
+ writeJSON(w, 503, M{"error": "model config unavailable"})
+ return
+ }
+ switch req.Method {
+ case "GET":
+ writeJSON(w, 200, M{
+ "providers": r.deps.ModelConfig.GetProviders(),
+ "models": r.deps.ModelConfig.GetModels(),
+ "routing": r.deps.ModelConfig.GetRouting(),
+ })
+ default:
+ writeJSON(w, 405, M{"error": "method not allowed"})
+ }
+}
+
+func (r *Router) modelConfigItemHandler(w http.ResponseWriter, req *http.Request) {
+ if r.deps.ModelConfig == nil {
+ writeJSON(w, 503, M{"error": "model config unavailable"})
+ return
+ }
+ // Path: /api/v1/admin/models/config/{type}/{name}
+ path := strings.TrimPrefix(req.URL.Path, "/api/v1/admin/models/config/")
+ parts := strings.SplitN(path, "/", 2)
+ if len(parts) < 2 {
+ writeJSON(w, 400, M{"error": "invalid path"})
+ return
+ }
+ kind, name := parts[0], parts[1]
+
+ switch req.Method {
+ case "POST", "PUT":
+ var body map[string]any
+ json.NewDecoder(req.Body).Decode(&body)
+ switch kind {
+ case "providers":
+ r.deps.ModelConfig.SaveProvider(&ModelProvider{
+ Name: name, BaseURL: str(body, "base_url"),
+ APIKey: str(body, "api_key"), Timeout: num(body, "timeout_sec"),
+ Retries: num(body, "max_retries"),
+ })
+ case "models":
+ var tags []string
+ if t, ok := body["tags"].([]any); ok {
+ for _, v := range t { tags = append(tags, fmt.Sprint(v)) }
+ }
+ r.deps.ModelConfig.SaveModel(&ModelEntry{
+ ID: name, Name: str(body, "name"), Provider: str(body, "provider"),
+ Description: str(body, "description"), Tags: tags,
+ Params: body["params"].(map[string]any), Enabled: true,
+ })
+ case "routing":
+ var chain []string
+ if c, ok := body["fallback_chain"].([]any); ok {
+ for _, v := range c { chain = append(chain, fmt.Sprint(v)) }
+ }
+ r.deps.ModelConfig.SaveRouting(&RoutingRule{
+ Purpose: name, FallbackChain: chain, Required: body["required"].(bool),
+ })
+ }
+ writeJSON(w, 200, M{"ok": true})
+
+ case "DELETE":
+ switch kind {
+ case "providers": r.deps.ModelConfig.DeleteProvider(name)
+ case "models": r.deps.ModelConfig.DeleteModel(name)
+ case "routing": r.deps.ModelConfig.DeleteRouting(name)
+ }
+ writeJSON(w, 204, nil)
+
+ default:
+ writeJSON(w, 405, M{"error": "method not allowed"})
+ }
+}
+
+func str(m map[string]any, key string) string {
+ if v, ok := m[key].(string); ok { return v }
+ return ""
+}
+func num(m map[string]any, key string) int {
+ if v, ok := m[key].(float64); ok { return int(v) }
+ return 0
}
// --- Helpers ---
diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx
index abff5d5..768da84 100644
--- a/frontend/web/src/App.tsx
+++ b/frontend/web/src/App.tsx
@@ -10,6 +10,8 @@ import PluginsPage from './pages/admin/PluginsPage'
import MemoryPage from './pages/admin/MemoryPage'
import LLMCallsPage from './pages/admin/LLMCallsPage'
import SystemPage from './pages/admin/SystemPage'
+import ModelsPage from './pages/admin/ModelsPage'
+import BridgePage from './pages/admin/BridgePage'
import LoginPage from './pages/LoginPage'
export default function App() {
@@ -38,6 +40,8 @@ export default function App() {
多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。
+ +{a.desc}
+{p.name}
+{p.base_url} · timeout {p.timeout_sec}s · retries {p.max_retries}
+{m.name} {m.enabled ? '启用' : '禁用'}
+{m.id} · {m.provider} · {m.description}
+{r.purpose}
+fallback: {r.fallback_chain?.join(' → ') || 'none'} {r.required ? '(required)' : ''}
+