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