ScuttleBot

scuttlebot / internal / api / llm_handlers.go
Blame History Raw 397 lines
1
package api
2
3
import (
4
"encoding/json"
5
"net/http"
6
"sort"
7
8
"github.com/conflicthq/scuttlebot/internal/config"
9
"github.com/conflicthq/scuttlebot/internal/llm"
10
)
11
12
// backendView is the read-safe representation of a backend returned by the API.
13
// API keys are replaced with "***" if set, so they are never exposed.
14
type backendView struct {
15
Name string `json:"name"`
16
Backend string `json:"backend"`
17
APIKey string `json:"api_key,omitempty"` // "***" if set, "" if not
18
BaseURL string `json:"base_url,omitempty"`
19
Model string `json:"model,omitempty"`
20
Region string `json:"region,omitempty"`
21
AWSKeyID string `json:"aws_key_id,omitempty"` // "***" if set
22
Allow []string `json:"allow,omitempty"`
23
Block []string `json:"block,omitempty"`
24
Default bool `json:"default,omitempty"`
25
Source string `json:"source"` // "config" (yaml, read-only) or "policy" (ui-managed)
26
}
27
28
func mask(s string) string {
29
if s == "" {
30
return ""
31
}
32
return "***"
33
}
34
35
// handleLLMKnown returns the list of all known backend names.
36
func (s *Server) handleLLMKnown(w http.ResponseWriter, _ *http.Request) {
37
type knownBackend struct {
38
Name string `json:"name"`
39
BaseURL string `json:"base_url,omitempty"`
40
Native bool `json:"native,omitempty"`
41
}
42
43
var backends []knownBackend
44
for name, url := range llm.KnownBackends {
45
backends = append(backends, knownBackend{Name: name, BaseURL: url})
46
}
47
backends = append(backends,
48
knownBackend{Name: "anthropic", Native: true},
49
knownBackend{Name: "gemini", Native: true},
50
knownBackend{Name: "bedrock", Native: true},
51
knownBackend{Name: "ollama", BaseURL: "http://localhost:11434", Native: true},
52
)
53
sort.Slice(backends, func(i, j int) bool {
54
return backends[i].Name < backends[j].Name
55
})
56
writeJSON(w, http.StatusOK, backends)
57
}
58
59
// handleLLMBackends lists all configured backends (YAML config + policy store).
60
// API keys are masked.
61
func (s *Server) handleLLMBackends(w http.ResponseWriter, _ *http.Request) {
62
var out []backendView
63
64
// YAML-configured backends (read-only).
65
if s.llmCfg != nil {
66
for _, b := range s.llmCfg.Backends {
67
out = append(out, backendView{
68
Name: b.Name,
69
Backend: b.Backend,
70
APIKey: mask(b.APIKey),
71
BaseURL: b.BaseURL,
72
Model: b.Model,
73
Region: b.Region,
74
AWSKeyID: mask(b.AWSKeyID),
75
Allow: b.Allow,
76
Block: b.Block,
77
Default: b.Default,
78
Source: "config",
79
})
80
}
81
}
82
83
// Policy-store backends (UI-managed, editable).
84
if s.policies != nil {
85
for _, b := range s.policies.Get().LLMBackends {
86
out = append(out, backendView{
87
Name: b.Name,
88
Backend: b.Backend,
89
APIKey: mask(b.APIKey),
90
BaseURL: b.BaseURL,
91
Model: b.Model,
92
Region: b.Region,
93
AWSKeyID: mask(b.AWSKeyID),
94
Allow: b.Allow,
95
Block: b.Block,
96
Default: b.Default,
97
Source: "policy",
98
})
99
}
100
}
101
102
if out == nil {
103
out = []backendView{}
104
}
105
writeJSON(w, http.StatusOK, out)
106
}
107
108
// handleLLMBackendCreate adds a new backend to the policy store.
109
func (s *Server) handleLLMBackendCreate(w http.ResponseWriter, r *http.Request) {
110
if s.policies == nil {
111
http.Error(w, "policy store not available", http.StatusServiceUnavailable)
112
return
113
}
114
var b PolicyLLMBackend
115
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
116
http.Error(w, "invalid request body", http.StatusBadRequest)
117
return
118
}
119
if b.Name == "" || b.Backend == "" {
120
http.Error(w, "name and backend are required", http.StatusBadRequest)
121
return
122
}
123
124
p := s.policies.Get()
125
for _, existing := range p.LLMBackends {
126
if existing.Name == b.Name {
127
http.Error(w, "backend name already exists", http.StatusConflict)
128
return
129
}
130
}
131
// Also check YAML backends.
132
if s.llmCfg != nil {
133
for _, existing := range s.llmCfg.Backends {
134
if existing.Name == b.Name {
135
http.Error(w, "backend name already exists in config", http.StatusConflict)
136
return
137
}
138
}
139
}
140
141
p.LLMBackends = append(p.LLMBackends, b)
142
if err := s.policies.Set(p); err != nil {
143
http.Error(w, "failed to save", http.StatusInternalServerError)
144
return
145
}
146
writeJSON(w, http.StatusCreated, backendView{
147
Name: b.Name,
148
Backend: b.Backend,
149
APIKey: mask(b.APIKey),
150
BaseURL: b.BaseURL,
151
Model: b.Model,
152
Region: b.Region,
153
Allow: b.Allow,
154
Block: b.Block,
155
Default: b.Default,
156
Source: "policy",
157
})
158
}
159
160
// handleLLMBackendUpdate updates a policy-store backend by name.
161
// Fields present in the request body override the stored value.
162
// Send api_key / aws_secret_key as "" to leave the stored value unchanged
163
// (the UI masks these and should omit them if unchanged).
164
func (s *Server) handleLLMBackendUpdate(w http.ResponseWriter, r *http.Request) {
165
if s.policies == nil {
166
http.Error(w, "policy store not available", http.StatusServiceUnavailable)
167
return
168
}
169
name := r.PathValue("name")
170
var req PolicyLLMBackend
171
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
172
http.Error(w, "invalid request body", http.StatusBadRequest)
173
return
174
}
175
176
p := s.policies.Get()
177
idx := -1
178
for i, b := range p.LLMBackends {
179
if b.Name == name {
180
idx = i
181
break
182
}
183
}
184
if idx == -1 {
185
http.Error(w, "backend not found (only policy backends are editable)", http.StatusNotFound)
186
return
187
}
188
189
existing := p.LLMBackends[idx]
190
// Preserve stored secrets when the UI sends "***" or empty.
191
if req.APIKey == "" || req.APIKey == "***" {
192
req.APIKey = existing.APIKey
193
}
194
if req.AWSSecretKey == "" || req.AWSSecretKey == "***" {
195
req.AWSSecretKey = existing.AWSSecretKey
196
}
197
if req.AWSKeyID == "" || req.AWSKeyID == "***" {
198
req.AWSKeyID = existing.AWSKeyID
199
}
200
req.Name = name // name is immutable
201
p.LLMBackends[idx] = req
202
203
if err := s.policies.Set(p); err != nil {
204
http.Error(w, "failed to save", http.StatusInternalServerError)
205
return
206
}
207
writeJSON(w, http.StatusOK, backendView{
208
Name: req.Name,
209
Backend: req.Backend,
210
APIKey: mask(req.APIKey),
211
BaseURL: req.BaseURL,
212
Model: req.Model,
213
Region: req.Region,
214
Allow: req.Allow,
215
Block: req.Block,
216
Default: req.Default,
217
Source: "policy",
218
})
219
}
220
221
// handleLLMBackendDelete removes a policy-store backend by name.
222
func (s *Server) handleLLMBackendDelete(w http.ResponseWriter, r *http.Request) {
223
if s.policies == nil {
224
http.Error(w, "policy store not available", http.StatusServiceUnavailable)
225
return
226
}
227
name := r.PathValue("name")
228
p := s.policies.Get()
229
idx := -1
230
for i, b := range p.LLMBackends {
231
if b.Name == name {
232
idx = i
233
break
234
}
235
}
236
if idx == -1 {
237
http.Error(w, "backend not found (only policy backends are deletable)", http.StatusNotFound)
238
return
239
}
240
p.LLMBackends = append(p.LLMBackends[:idx], p.LLMBackends[idx+1:]...)
241
if err := s.policies.Set(p); err != nil {
242
http.Error(w, "failed to save", http.StatusInternalServerError)
243
return
244
}
245
w.WriteHeader(http.StatusNoContent)
246
}
247
248
// handleLLMModels runs model discovery for the named backend.
249
// Looks in YAML config first, then policy store.
250
func (s *Server) handleLLMModels(w http.ResponseWriter, r *http.Request) {
251
name := r.PathValue("name")
252
cfg, ok := s.findBackendConfig(name)
253
if !ok {
254
http.Error(w, "backend not found", http.StatusNotFound)
255
return
256
}
257
models, err := llm.Discover(r.Context(), cfg)
258
if err != nil {
259
s.log.Error("llm model discovery", "backend", name, "err", err)
260
http.Error(w, "model discovery failed: "+err.Error(), http.StatusBadGateway)
261
return
262
}
263
writeJSON(w, http.StatusOK, models)
264
}
265
266
// handleLLMDiscover runs ad-hoc model discovery from form credentials.
267
// Used by the UI "load live models" button before a backend is saved.
268
func (s *Server) handleLLMDiscover(w http.ResponseWriter, r *http.Request) {
269
var req struct {
270
Backend string `json:"backend"`
271
APIKey string `json:"api_key"`
272
BaseURL string `json:"base_url"`
273
Model string `json:"model"`
274
Region string `json:"region"`
275
AWSKeyID string `json:"aws_key_id"`
276
AWSSecretKey string `json:"aws_secret_key"`
277
Allow []string `json:"allow"`
278
Block []string `json:"block"`
279
}
280
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
281
http.Error(w, "invalid request body", http.StatusBadRequest)
282
return
283
}
284
if req.Backend == "" {
285
http.Error(w, "backend is required", http.StatusBadRequest)
286
return
287
}
288
cfg := llm.BackendConfig{
289
Backend: req.Backend,
290
APIKey: req.APIKey,
291
BaseURL: req.BaseURL,
292
Model: req.Model,
293
Region: req.Region,
294
AWSKeyID: req.AWSKeyID,
295
AWSSecretKey: req.AWSSecretKey,
296
Allow: req.Allow,
297
Block: req.Block,
298
}
299
models, err := llm.Discover(r.Context(), cfg)
300
if err != nil {
301
s.log.Error("llm ad-hoc discovery", "backend", req.Backend, "err", err)
302
http.Error(w, "model discovery failed: "+err.Error(), http.StatusBadGateway)
303
return
304
}
305
writeJSON(w, http.StatusOK, models)
306
}
307
308
// findBackendConfig looks up a backend by name in YAML config then policy store.
309
func (s *Server) findBackendConfig(name string) (llm.BackendConfig, bool) {
310
if s.llmCfg != nil {
311
for _, b := range s.llmCfg.Backends {
312
if b.Name == name {
313
return yamlBackendToLLM(b), true
314
}
315
}
316
}
317
if s.policies != nil {
318
for _, b := range s.policies.Get().LLMBackends {
319
if b.Name == name {
320
return policyBackendToLLM(b), true
321
}
322
}
323
}
324
return llm.BackendConfig{}, false
325
}
326
327
func yamlBackendToLLM(b config.LLMBackendConfig) llm.BackendConfig {
328
return llm.BackendConfig{
329
Backend: b.Backend,
330
APIKey: b.APIKey,
331
BaseURL: b.BaseURL,
332
Model: b.Model,
333
Region: b.Region,
334
AWSKeyID: b.AWSKeyID,
335
AWSSecretKey: b.AWSSecretKey,
336
Allow: b.Allow,
337
Block: b.Block,
338
}
339
}
340
341
// handleLLMComplete proxies a prompt to a named backend and returns the text.
342
// The API key stays server-side — callers only need a Bearer token.
343
//
344
// POST /v1/llm/complete
345
//
346
// {"backend": "anthro", "prompt": "hello"}
347
// → {"text": "..."}
348
func (s *Server) handleLLMComplete(w http.ResponseWriter, r *http.Request) {
349
var req struct {
350
Backend string `json:"backend"`
351
Prompt string `json:"prompt"`
352
}
353
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
354
http.Error(w, "invalid request body", http.StatusBadRequest)
355
return
356
}
357
if req.Backend == "" || req.Prompt == "" {
358
http.Error(w, "backend and prompt are required", http.StatusBadRequest)
359
return
360
}
361
362
cfg, ok := s.findBackendConfig(req.Backend)
363
if !ok {
364
http.Error(w, "backend not found", http.StatusNotFound)
365
return
366
}
367
368
provider, err := llm.New(cfg)
369
if err != nil {
370
http.Error(w, "backend init failed: "+err.Error(), http.StatusInternalServerError)
371
return
372
}
373
374
text, err := provider.Summarize(r.Context(), req.Prompt)
375
if err != nil {
376
s.log.Error("llm complete", "backend", req.Backend, "err", err)
377
http.Error(w, "llm error: "+err.Error(), http.StatusBadGateway)
378
return
379
}
380
381
writeJSON(w, http.StatusOK, map[string]string{"text": text})
382
}
383
384
func policyBackendToLLM(b PolicyLLMBackend) llm.BackendConfig {
385
return llm.BackendConfig{
386
Backend: b.Backend,
387
APIKey: b.APIKey,
388
BaseURL: b.BaseURL,
389
Model: b.Model,
390
Region: b.Region,
391
AWSKeyID: b.AWSKeyID,
392
AWSSecretKey: b.AWSSecretKey,
393
Allow: b.Allow,
394
Block: b.Block,
395
}
396
}
397

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button