-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.go
More file actions
181 lines (163 loc) · 6.64 KB
/
Copy pathserver.go
File metadata and controls
181 lines (163 loc) · 6.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// Package adminapi serves the web admin interface — an htmx-driven page for
// browsing OpenRouter models, reassigning them to slots, and editing routing
// roles. Designed to run behind a reverse proxy (Traefik) with optional
// Authentik forward-auth; the package itself only TRUSTS those headers when
// explicitly configured.
package adminapi
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"telegram-agent/internal/agent"
"telegram-agent/internal/config"
"telegram-agent/internal/llm"
"telegram-agent/internal/store"
)
// MCPReloader is the minimal surface the admin UI needs from the bot's MCP
// client to hot-reload after a save/delete. Agent implements it; nil-safe.
type MCPReloader interface {
ReloadMCP(ctx context.Context, configs map[string]config.MCPServerConfig) (int, error)
}
type TGOperationalAgent interface {
ClearHistory(chatID int64)
Compact(ctx context.Context, chatID int64) error
GetStats(chatID int64) (store.ChatStats, bool)
ListTools() []agent.ToolInfo
}
// Server wraps an http.Server + the upstream dependencies it needs to render
// and mutate routing state.
type Server struct {
cfg config.AdminAPIConfig
router *llm.Router
capStore llm.CapabilityStore
settings llm.SettingsStore // for AA cache persistence; may be nil
usageStore llm.UsageStore // for Usage/Cost section; may be nil
cfgRef *config.Config // needed for enumerating OpenRouter slots
reloader MCPReloader // may be nil (local dev / tests)
agent ChatAgent // may be nil (admin UI only, no chat tab)
opsAgent TGOperationalAgent
modelProbe modelProbeFunc
logger *slog.Logger
httpSrv *http.Server
modelChecksCancel context.CancelFunc
modelChecksOnce sync.Once
modelEvalMu sync.Mutex
}
// SetMCPReloader wires the hot-reload hook so saving/deleting MCP servers in
// the UI applies immediately without a container restart.
func (s *Server) SetMCPReloader(r MCPReloader) { s.reloader = r }
// New constructs the admin API server but does not start it. Call Start to
// bind the listener.
func New(cfg config.AdminAPIConfig, router *llm.Router, capStore llm.CapabilityStore, settings llm.SettingsStore, usageStore llm.UsageStore, cfgRef *config.Config, logger *slog.Logger) *Server {
if cfg.ForwardAuthHeader == "" {
cfg.ForwardAuthHeader = "X-authentik-username"
}
return &Server{
cfg: cfg,
router: router,
capStore: capStore,
settings: settings,
usageStore: usageStore,
cfgRef: cfgRef,
logger: logger,
}
}
// Start binds the listener and serves in a goroutine. Non-blocking.
func (s *Server) Start() error {
if !s.cfg.Enabled {
return nil
}
if s.cfg.Listen == "" {
return errors.New("adminapi: listen address is required when enabled")
}
if s.cfg.Token == "" && !s.cfg.TrustForwardAuth {
return errors.New("adminapi: either token or trust_forward_auth must be set")
}
mux := http.NewServeMux()
s.registerRoutes(mux)
s.httpSrv = &http.Server{
Addr: s.cfg.Listen,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 0, // no write timeout — chat tab may block for minutes
IdleTimeout: 60 * time.Second,
}
go func() {
s.logger.Info("admin API listening",
"addr", s.cfg.Listen,
"trust_forward_auth", s.cfg.TrustForwardAuth)
if err := s.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Error("admin API server stopped", "err", err)
}
}()
s.startModelCheckScheduler()
return nil
}
// Shutdown gracefully stops the server.
func (s *Server) Shutdown(ctx context.Context) error {
if s.modelChecksCancel != nil {
s.modelChecksCancel()
}
if s.httpSrv == nil {
return nil
}
return s.httpSrv.Shutdown(ctx)
}
// registerRoutes wires the public surface. Kept separate so tests can assemble
// a mux without starting a real listener.
func (s *Server) registerRoutes(mux *http.ServeMux) {
// Static assets (design system + htmx). Served unauthenticated so the
// page can load its own CSS/JS after auth succeeds on /.
mux.Handle("/static/", http.StripPrefix("/static/", staticFileServer()))
// Unauthenticated probe.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
})
// Telegram Mini App shell + Telegram init-data protected API.
mux.Handle("/tg-admin", http.HandlerFunc(s.handleTGAdmin))
mux.Handle("/tg-admin/", http.HandlerFunc(s.handleTGAdminRouter))
// Authenticated routes.
authed := s.requireAuth
mux.Handle("/", authed(http.HandlerFunc(s.handleIndex)))
mux.Handle("/models/override", authed(http.HandlerFunc(s.handleModelOverride)))
mux.Handle("/models/check", authed(http.HandlerFunc(s.handleModelCheck)))
mux.Handle("/models/eval", authed(http.HandlerFunc(s.handleModelEval)))
mux.Handle("/models", authed(http.HandlerFunc(s.handleModels)))
mux.Handle("/routing", authed(http.HandlerFunc(s.handleRouting)))
mux.Handle("/slots/", authed(http.HandlerFunc(s.handleSlotAssign))) // POST /slots/{slot}/assign
mux.Handle("/routing/", authed(http.HandlerFunc(s.handleRoleSet))) // POST /routing/{role}/set
mux.Handle("/refresh", authed(http.HandlerFunc(s.handleRefresh)))
mux.Handle("/usage", authed(http.HandlerFunc(s.handleUsage)))
mux.Handle("/analytics", authed(http.HandlerFunc(s.handleAnalytics)))
mux.Handle("/prompts", authed(http.HandlerFunc(s.handlePrompts)))
mux.Handle("/prompts/", authed(http.HandlerFunc(s.handlePromptSet))) // POST /prompts/{key}/set
mux.Handle("/settings", authed(http.HandlerFunc(s.handleSettings)))
mux.Handle("/settings/", authed(http.HandlerFunc(s.handleSettingSet))) // POST /settings/{key}/set
mux.Handle("/mcp", authed(http.HandlerFunc(s.handleMCP)))
mux.Handle("/mcp/", authed(http.HandlerFunc(s.handleMCPRouter))) // dispatches {name}/set | {name}/delete
mux.Handle("/chat", authed(http.HandlerFunc(s.handleChat)))
mux.Handle("/chat/history", authed(http.HandlerFunc(s.handleChatHistory)))
mux.Handle("/chat/stream", authed(http.HandlerFunc(s.handleChatStream)))
mux.Handle("/chat/pop", authed(http.HandlerFunc(s.handleChatPop)))
mux.Handle("/chat/clear", authed(http.HandlerFunc(s.handleChatClear)))
}
// handleMCPRouter dispatches /mcp/{name}/set and /mcp/{name}/delete to the
// specific handlers. Done in one handler so registerRoutes keeps one entry
// per tab in the admin.
func (s *Server) handleMCPRouter(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/set"):
s.handleMCPSet(w, r)
case strings.HasSuffix(path, "/delete"):
s.handleMCPDelete(w, r)
default:
http.NotFound(w, r)
}
}