ScuttleBot

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

Keyboard Shortcuts

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