Skip to content

Commit ea91e5d

Browse files
committed
✨ feat(ui): Settings → API Keys lists local LLM providers with Base URL Advanced section
Part 2 of #8254. The Settings → API Keys modal now renders the nine chat-only providers (Groq, OpenRouter, Open WebUI + Ollama, llama.cpp, LocalAI, vLLM, LM Studio, RHAIIS) with an Advanced expandable section that accepts a per-provider Base URL override. Backend changes --------------- - KeyStatus gains BaseURL, BaseURLEnvVar, BaseURLSource fields so the frontend can render the current resolved value and indicate whether it came from env or config file. - handleGetKeysStatus now returns the nine chat-only providers (was previously an empty list per the "API-key-driven agents are hidden" comment, which predated PR #8248 flipping them on). CLI-based tool-capable agents (claude-code, bob, codex, ...) remain omitted — they manage their own credentials. - SetKeyRequest.APIKey is now optional: setting just BaseURL is the common path for unauthenticated local LLM runners. handleSetKey accepts any combination of apiKey, baseURL, model — at least one is required. Base URL is syntactically validated (http(s)://, no whitespace) via validateBaseURL before being saved. On successful save, cached key validity is invalidated so the next validation hits the new endpoint. - Key validation is skipped for local LLM runners (validationRequired is false on their providerDef entries). There is no meaningful way to validate the sentinel "local-llm-no-auth" placeholder against a real endpoint, so we report Configured=true based on URL presence. Frontend changes ---------------- - KeyStatus TS interface gains baseURL, baseURLEnvVar, baseURLSource. - APIKeySettings.tsx adds an Advanced expandable to each row. It only renders when the backend reports baseURLEnvVar (meaning the provider actually supports an override). The form includes a text input, an inline "env var wins" hint when baseURLSource is "env", syntactic validation feedback, and a post-save "restart kc-agent to apply" message. The summary row shows the current resolved value next to the Advanced toggle so operators can see it at a glance. Closes #8254. - go build ./... passes. - go test ./pkg/agent/... passes (flaky auto-update test unrelated). - npm run build passes all post-build safety checks. Signed-off-by: Andrew Anderson <[email protected]>
1 parent 63155c3 commit ea91e5d

3 files changed

Lines changed: 278 additions & 42 deletions

File tree

pkg/agent/server_ai.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,6 +1608,19 @@ type KeyStatus struct {
16081608
Source string `json:"source,omitempty"` // "env" or "config"
16091609
Valid *bool `json:"valid,omitempty"` // nil = not tested, true/false = test result
16101610
Error string `json:"error,omitempty"`
1611+
// BaseURL is the currently-resolved base URL for this provider (env var,
1612+
// then ~/.kc/config.yaml, then compiled default). Empty when the provider
1613+
// does not support a base URL override (vendor HTTP APIs).
1614+
BaseURL string `json:"baseURL,omitempty"`
1615+
// BaseURLEnvVar is the environment variable this provider honors for
1616+
// base URL overrides (e.g. "OLLAMA_URL", "GROQ_BASE_URL"). Empty when
1617+
// the provider has no base URL override. Surfaced to the UI so the
1618+
// Advanced section can show the env var name as an operator hint.
1619+
BaseURLEnvVar string `json:"baseURLEnvVar,omitempty"`
1620+
// BaseURLSource is "env" when the current BaseURL value came from the
1621+
// env var, "config" when it came from ~/.kc/config.yaml, or empty when
1622+
// the resolved value is the compiled-in default.
1623+
BaseURLSource string `json:"baseURLSource,omitempty"`
16111624
}
16121625

16131626
// KeysStatusResponse is the response for GET /settings/keys
@@ -1616,11 +1629,15 @@ type KeysStatusResponse struct {
16161629
ConfigPath string `json:"configPath"`
16171630
}
16181631

1619-
// SetKeyRequest is the request body for POST /settings/keys
1632+
// SetKeyRequest is the request body for POST /settings/keys.
1633+
// Setting APIKey requires a valid key; setting BaseURL is independent
1634+
// (operators can configure a base URL without an API key, which is the
1635+
// common path for unauthenticated local LLM runners).
16201636
type SetKeyRequest struct {
16211637
Provider string `json:"provider"`
1622-
APIKey string `json:"apiKey"`
1638+
APIKey string `json:"apiKey,omitempty"`
16231639
Model string `json:"model,omitempty"`
1640+
BaseURL string `json:"baseURL,omitempty"`
16241641
}
16251642

16261643
// handleSettingsKeys handles GET and POST for /settings/keys

pkg/agent/server_operations.go

Lines changed: 126 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -271,23 +271,48 @@ func (s *Server) handleSettingsImport(w http.ResponseWriter, r *http.Request) {
271271
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "message": "Settings imported"})
272272
}
273273

274-
// handleGetKeysStatus returns the status of all API keys (without exposing the actual keys)
274+
// handleGetKeysStatus returns the status of all API keys (without exposing the actual keys).
275+
//
276+
// The list covers the nine chat-only HTTP providers registered in
277+
// InitializeProviders (pkg/agent/registry.go): three OpenAI-compatible
278+
// gateway providers (Groq, OpenRouter, Open WebUI) and six local LLM
279+
// runners (Ollama, llama.cpp, LocalAI, vLLM, LM Studio, Red Hat AI
280+
// Inference Server). CLI-based tool-capable agents (claude-code, bob,
281+
// codex, gemini-cli, antigravity, goose, copilot-cli) are deliberately
282+
// omitted — they manage their own credentials and do not need an API
283+
// key in ~/.kc/config.yaml.
284+
//
285+
// Each entry includes the current BaseURL + BaseURLEnvVar so the
286+
// frontend Settings modal can offer a per-provider base URL override
287+
// field without needing to hardcode the mapping.
275288
func (s *Server) handleGetKeysStatus(w http.ResponseWriter, r *http.Request) {
276289
cm := GetConfigManager()
277290

278-
// Build provider list dynamically from registry
279-
// Include all providers that accept API keys (exclude pure CLI providers like bob, claude-code)
280291
type providerDef struct {
281292
name string
282293
displayName string
294+
// validationRequired is true for providers that have a working
295+
// validation endpoint (Groq, OpenRouter). Local LLM runners
296+
// typically have no authentication, so attempting to validate
297+
// their placeholder sentinel key against a real endpoint is
298+
// pointless — we report Configured=true whenever a URL is set.
299+
validationRequired bool
300+
}
301+
302+
providers := []providerDef{
303+
// OpenAI-compatible gateways with real API keys
304+
{name: "groq", displayName: "Groq", validationRequired: true},
305+
{name: "openrouter", displayName: "OpenRouter", validationRequired: true},
306+
{name: "open-webui", displayName: "Open WebUI", validationRequired: false},
307+
// Local LLM runners — URL-driven, no API key by default
308+
{name: "ollama", displayName: "Ollama (Local)"},
309+
{name: "llamacpp", displayName: "llama.cpp (Local)"},
310+
{name: "localai", displayName: "LocalAI (Local)"},
311+
{name: "vllm", displayName: "vLLM (Local)"},
312+
{name: "lm-studio", displayName: "LM Studio (Local)"},
313+
{name: "rhaiis", displayName: "Red Hat AI Inference Server"},
283314
}
284315

285-
// Only show CLI-based agents — API-key-driven agents are hidden because
286-
// they cannot execute commands to diagnose/repair clusters.
287-
// This list is intentionally empty; the keys endpoint remains functional
288-
// for any future API providers but currently returns no keys.
289-
providers := []providerDef{}
290-
291316
keys := make([]KeyStatus, 0, len(providers))
292317
for _, p := range providers {
293318
status := KeyStatus{
@@ -296,21 +321,35 @@ func (s *Server) handleGetKeysStatus(w http.ResponseWriter, r *http.Request) {
296321
Configured: cm.HasAPIKey(p.name),
297322
}
298323

324+
// Base URL metadata — surfaces the current resolved value and
325+
// the env var name so the UI can render an Advanced expandable.
326+
status.BaseURL = cm.GetBaseURL(p.name)
327+
status.BaseURLEnvVar = getBaseURLEnvKeyForProvider(p.name)
328+
if status.BaseURLEnvVar != "" && os.Getenv(status.BaseURLEnvVar) != "" {
329+
status.BaseURLSource = "env"
330+
} else if status.BaseURL != "" {
331+
status.BaseURLSource = "config"
332+
}
333+
299334
if status.Configured {
300335
if cm.IsFromEnv(p.name) {
301336
status.Source = "env"
302337
} else {
303338
status.Source = "config"
304339
}
305340

306-
// Test if the key is valid
307-
valid, err := s.validateAPIKey(p.name)
308-
status.Valid = &valid
309-
// Cache the validity for IsAvailable() checks
310-
cm.SetKeyValidity(p.name, valid)
311-
if err != nil {
312-
slog.Error("API key validation error", "provider", p.name, "error", err)
313-
status.Error = "validation failed"
341+
if p.validationRequired {
342+
// Test if the key is valid — validateAPIKey honors the
343+
// base URL override via the per-provider resolver, so
344+
// pointing a Groq config at a local Ollama validates
345+
// against the local endpoint.
346+
valid, err := s.validateAPIKey(p.name)
347+
status.Valid = &valid
348+
cm.SetKeyValidity(p.name, valid)
349+
if err != nil {
350+
slog.Error("API key validation error", "provider", p.name, "error", err)
351+
status.Error = "validation failed"
352+
}
314353
}
315354
}
316355

@@ -323,7 +362,11 @@ func (s *Server) handleGetKeysStatus(w http.ResponseWriter, r *http.Request) {
323362
})
324363
}
325364

326-
// handleSetKey saves a new API key
365+
// handleSetKey saves an API key, a model preference, a base URL override,
366+
// or any combination of the three for a provider. Setting BaseURL alone
367+
// (no APIKey) is the common path for unauthenticated local LLM runners —
368+
// operators point Ollama at a LAN server by saving `OLLAMA_URL` via this
369+
// endpoint rather than editing a shell profile.
327370
func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
328371
var req SetKeyRequest
329372
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodyBytes)).Decode(&req); err != nil {
@@ -338,35 +381,61 @@ func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
338381
return
339382
}
340383

341-
if req.APIKey == "" {
384+
// At least one of APIKey, BaseURL, or Model must be present — a request
385+
// with none is a programming bug we should reject rather than silently
386+
// store nothing.
387+
if req.APIKey == "" && req.BaseURL == "" && req.Model == "" {
342388
w.WriteHeader(http.StatusBadRequest)
343-
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "missing_key", Message: "API key required"})
389+
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "missing_field", Message: "At least one of apiKey, baseURL, or model is required"})
344390
return
345391
}
346392

347-
// Validate the key before saving
348-
valid, validationErr := s.validateAPIKeyValue(req.Provider, req.APIKey)
349-
if !valid {
350-
w.WriteHeader(http.StatusBadRequest)
351-
if validationErr != nil {
352-
slog.Error("API key validation error", "error", validationErr)
393+
cm := GetConfigManager()
394+
395+
// Base URL can be saved independently and does not need validation —
396+
// operators point at local runners that the reachability/validation
397+
// check cannot test meaningfully (the sentinel "local-llm-no-auth" key
398+
// is not a real credential). Save first so that subsequent API-key
399+
// validation below uses the updated endpoint.
400+
if req.BaseURL != "" {
401+
if err := validateBaseURL(req.BaseURL); err != nil {
402+
w.WriteHeader(http.StatusBadRequest)
403+
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "invalid_base_url", Message: err.Error()})
404+
return
353405
}
354-
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "invalid_key", Message: "Invalid API key"})
355-
return
406+
if err := cm.SetBaseURL(req.Provider, req.BaseURL); err != nil {
407+
slog.Error("save base URL error", "error", err)
408+
w.WriteHeader(http.StatusInternalServerError)
409+
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "save_failed", Message: "failed to save base URL"})
410+
return
411+
}
412+
// Invalidate cached validity for this provider — the endpoint
413+
// changed, so any previously-cached "key valid" result is stale.
414+
cm.InvalidateKeyValidity(req.Provider)
356415
}
357416

358-
cm := GetConfigManager()
417+
if req.APIKey != "" {
418+
// Validate the key before saving. Validation uses the provider's
419+
// now-current base URL, so pointing Groq at a local Ollama works.
420+
valid, validationErr := s.validateAPIKeyValue(req.Provider, req.APIKey)
421+
if !valid {
422+
w.WriteHeader(http.StatusBadRequest)
423+
if validationErr != nil {
424+
slog.Error("API key validation error", "error", validationErr)
425+
}
426+
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "invalid_key", Message: "Invalid API key"})
427+
return
428+
}
359429

360-
// Save the key
361-
if err := cm.SetAPIKey(req.Provider, req.APIKey); err != nil {
362-
slog.Error("save API key error", "error", err)
363-
w.WriteHeader(http.StatusInternalServerError)
364-
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "save_failed", Message: "failed to save API key"})
365-
return
366-
}
430+
if err := cm.SetAPIKey(req.Provider, req.APIKey); err != nil {
431+
slog.Error("save API key error", "error", err)
432+
w.WriteHeader(http.StatusInternalServerError)
433+
json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "save_failed", Message: "failed to save API key"})
434+
return
435+
}
367436

368-
// Cache validity (we validated before saving)
369-
cm.SetKeyValidity(req.Provider, true)
437+
cm.SetKeyValidity(req.Provider, true)
438+
}
370439

371440
// Save model if provided
372441
if req.Model != "" {
@@ -378,14 +447,31 @@ func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) {
378447
// Refresh provider availability
379448
s.refreshProviderAvailability()
380449

381-
slog.Info("API key configured", "provider", req.Provider)
450+
slog.Info("provider configured", "provider", req.Provider, "hasKey", req.APIKey != "", "hasBaseURL", req.BaseURL != "", "hasModel", req.Model != "")
382451
json.NewEncoder(w).Encode(map[string]any{
383452
"success": true,
384453
"provider": req.Provider,
385-
"valid": true,
386454
})
387455
}
388456

457+
// validateBaseURL performs a syntactic check on a base URL before it is
458+
// saved. This is not a reachability test — local runners may not be
459+
// running at the time the operator configures them. The goal is only to
460+
// reject obvious typos (missing scheme, whitespace, non-http(s) scheme).
461+
func validateBaseURL(s string) error {
462+
s = strings.TrimSpace(s)
463+
if s == "" {
464+
return fmt.Errorf("base URL is empty")
465+
}
466+
if strings.ContainsAny(s, " \t\n\r") {
467+
return fmt.Errorf("base URL must not contain whitespace")
468+
}
469+
if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
470+
return fmt.Errorf("base URL must start with http:// or https://")
471+
}
472+
return nil
473+
}
474+
389475
// validateAPIKey tests if the configured key for a provider works
390476
func (s *Server) validateAPIKey(provider string) (bool, error) {
391477
cm := GetConfigManager()

0 commit comments

Comments
 (0)