✨ Settings → API Keys: Advanced Base URL field per local LLM provider (closes #8254)#8256
✨ Settings → API Keys: Advanced Base URL field per local LLM provider (closes #8254)#8256clubanderson merged 2 commits intomainfrom
Conversation
… base URL resolution Part 1 of #8254. Adds per-provider base URL storage in ~/.kc/config.yaml and updates all nine chat-only HTTP providers to resolve their base URL dynamically at request time, so changes take effect without restarting kc-agent. Backend changes --------------- - AgentKeyConfig gains an optional `base_url` YAML field alongside api_key and model. Empty string means "use the compiled-in default". - ConfigManager.GetBaseURL(provider) / SetBaseURL / RemoveBaseURL mirror the existing GetAPIKey / SetAPIKey / RemoveAPIKey shape. The precedence chain is env var → config file → compiled default, same as GetAPIKey and GetModel. - getBaseURLEnvKeyForProvider maps each registered provider key to its URL env var (OLLAMA_URL, LLAMACPP_URL, LOCALAI_URL, VLLM_URL, LM_STUDIO_URL, RHAIIS_URL, GROQ_BASE_URL, OPENROUTER_BASE_URL, OPEN_WEBUI_URL). Provider refactor ----------------- - LocalOpenAICompatProvider.localOpenAICompatBaseURL() now walks env var → ConfigManager.GetBaseURL → struct default. All six local LLM runners use this path. - GroqProvider drops its cached baseURL field and resolves via a new groqResolveBaseURL() helper (env → config → default). The existing groqValidationURL() already consults the same helper, so key validation continues to honor the override. - OpenRouterProvider and OpenWebUIProvider get the same treatment — dynamic resolution via openRouterResolveBaseURL() and openWebUIResolveBaseURL() respectively. The frontend UI (APIKeySettings Advanced section with a Base URL input field per provider) is Part 2 of this PR, coming next. - go build ./... passes. - go test ./pkg/agent/... passes. Signed-off-by: Andrew Anderson <[email protected]>
…RL 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]>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
✅ Deploy Preview for kubestellarconsole ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
👋 Hey @clubanderson — thanks for opening this PR!
This is an automated message. |
There was a problem hiding this comment.
Pull request overview
Adds per-provider Base URL overrides for chat-only HTTP LLM providers, wiring the Settings → API Keys UI through to ~/.kc/config.yaml and updating providers to resolve base URLs dynamically (env → config → default).
Changes:
- Frontend: adds an “Advanced” expandable per provider row to edit/save Base URL overrides.
- Backend: extends config schema (
base_url) and/settings/keysAPI payloads with Base URL metadata + persistence helpers. - Providers: refactors Groq/OpenRouter/Open WebUI + local OpenAI-compat runners to resolve base URLs at request time.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/components/agent/APIKeySettings.tsx | Adds Advanced UI for Base URL overrides and POST flow to save them. |
| pkg/agent/server_operations.go | Populates Base URL metadata in key status; extends POST handler to accept baseURL. |
| pkg/agent/server_ai.go | Extends API structs for keys status and set-key request with Base URL fields. |
| pkg/agent/config.go | Adds base_url to YAML config and ConfigManager get/set/remove helpers + env-var mapping. |
| pkg/agent/provider_groq.go | Resolves Groq base URL dynamically (env/config/default) per request. |
| pkg/agent/provider_openrouter.go | Resolves OpenRouter base URL dynamically (env/config/default) per request. |
| pkg/agent/provider_openwebui.go | Resolves Open WebUI base URL dynamically (env/config/empty) per request. |
| pkg/agent/provider_local_openai_compat.go | Adds config-file fallback in local runner base URL resolution. |
| // Base URL metadata — surfaces the current resolved value and | ||
| // the env var name so the UI can render an Advanced expandable. | ||
| status.BaseURL = cm.GetBaseURL(p.name) | ||
| status.BaseURLEnvVar = getBaseURLEnvKeyForProvider(p.name) | ||
| if status.BaseURLEnvVar != "" && os.Getenv(status.BaseURLEnvVar) != "" { | ||
| status.BaseURLSource = "env" | ||
| } else if status.BaseURL != "" { | ||
| status.BaseURLSource = "config" | ||
| } |
There was a problem hiding this comment.
status.BaseURL is populated via cm.GetBaseURL(p.name), but GetBaseURL currently returns only the env/config override (empty when using the compiled-in default). This conflicts with the struct/API doc that says BaseURL is the “currently-resolved” value and also prevents the UI from showing a meaningful resolved URL summary when no override is set. Either compute the resolved base URL here using the same provider-specific resolver logic (env → config → default) or adjust the API/field comment so it’s clear this is only the override value.
| // At least one of APIKey, BaseURL, or Model must be present — a request | ||
| // with none is a programming bug we should reject rather than silently | ||
| // store nothing. | ||
| if req.APIKey == "" && req.BaseURL == "" && req.Model == "" { | ||
| w.WriteHeader(http.StatusBadRequest) | ||
| json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "missing_key", Message: "API key required"}) | ||
| json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "missing_field", Message: "At least one of apiKey, baseURL, or model is required"}) | ||
| return | ||
| } | ||
|
|
||
| // Validate the key before saving | ||
| valid, validationErr := s.validateAPIKeyValue(req.Provider, req.APIKey) | ||
| if !valid { | ||
| w.WriteHeader(http.StatusBadRequest) | ||
| if validationErr != nil { | ||
| slog.Error("API key validation error", "error", validationErr) | ||
| cm := GetConfigManager() | ||
|
|
||
| // Base URL can be saved independently and does not need validation — | ||
| // operators point at local runners that the reachability/validation | ||
| // check cannot test meaningfully (the sentinel "local-llm-no-auth" key | ||
| // is not a real credential). Save first so that subsequent API-key | ||
| // validation below uses the updated endpoint. | ||
| if req.BaseURL != "" { | ||
| if err := validateBaseURL(req.BaseURL); err != nil { | ||
| w.WriteHeader(http.StatusBadRequest) | ||
| json.NewEncoder(w).Encode(protocol.ErrorPayload{Code: "invalid_base_url", Message: err.Error()}) | ||
| return | ||
| } |
There was a problem hiding this comment.
The frontend hint says “Leave blank to use the compiled-in default”, but handleSetKey rejects requests where BaseURL is empty (and also treats an all-empty request as missing_field). This makes it impossible to clear a previously-saved BaseURL override back to default from the UI. Add an explicit clear/remove path (e.g., baseURL: null with BaseURL *string in SetKeyRequest, or a dedicated DELETE endpoint that calls cm.RemoveBaseURL), and have the UI call it when the input is blank.
| // BaseURL is the currently-resolved base URL for this provider (env var, | ||
| // then ~/.kc/config.yaml, then compiled default). Empty when the provider | ||
| // does not support a base URL override (vendor HTTP APIs). | ||
| BaseURL string `json:"baseURL,omitempty"` | ||
| // BaseURLEnvVar is the environment variable this provider honors for | ||
| // base URL overrides (e.g. "OLLAMA_URL", "GROQ_BASE_URL"). Empty when | ||
| // the provider has no base URL override. Surfaced to the UI so the | ||
| // Advanced section can show the env var name as an operator hint. | ||
| BaseURLEnvVar string `json:"baseURLEnvVar,omitempty"` | ||
| // BaseURLSource is "env" when the current BaseURL value came from the | ||
| // env var, "config" when it came from ~/.kc/config.yaml, or empty when | ||
| // the resolved value is the compiled-in default. |
There was a problem hiding this comment.
KeyStatus.BaseURL is documented here as the fully resolved base URL (env → config → compiled default), but the current implementation in handleGetKeysStatus sets it from ConfigManager.GetBaseURL, which returns only the env/config override (empty when falling back to compiled default). Update the implementation to match this contract, or update the comment/field semantics so API consumers don’t assume they’re getting the resolved effective URL.
| // BaseURL is the currently-resolved base URL for this provider (env var, | |
| // then ~/.kc/config.yaml, then compiled default). Empty when the provider | |
| // does not support a base URL override (vendor HTTP APIs). | |
| BaseURL string `json:"baseURL,omitempty"` | |
| // BaseURLEnvVar is the environment variable this provider honors for | |
| // base URL overrides (e.g. "OLLAMA_URL", "GROQ_BASE_URL"). Empty when | |
| // the provider has no base URL override. Surfaced to the UI so the | |
| // Advanced section can show the env var name as an operator hint. | |
| BaseURLEnvVar string `json:"baseURLEnvVar,omitempty"` | |
| // BaseURLSource is "env" when the current BaseURL value came from the | |
| // env var, "config" when it came from ~/.kc/config.yaml, or empty when | |
| // the resolved value is the compiled-in default. | |
| // BaseURL is the configured base URL override for this provider, if any. | |
| // It reflects only values set via the provider's env var or | |
| // ~/.kc/config.yaml, and is empty when no override is configured or when | |
| // the provider does not support a base URL override (vendor HTTP APIs). | |
| BaseURL string `json:"baseURL,omitempty"` | |
| // BaseURLEnvVar is the environment variable this provider honors for | |
| // base URL overrides (e.g. "OLLAMA_URL", "GROQ_BASE_URL"). Empty when | |
| // the provider has no base URL override. Surfaced to the UI so the | |
| // Advanced section can show the env var name as an operator hint. | |
| BaseURLEnvVar string `json:"baseURLEnvVar,omitempty"` | |
| // BaseURLSource is "env" when the current BaseURL override came from the | |
| // env var, "config" when it came from ~/.kc/config.yaml, or empty when | |
| // no base URL override is configured. |
| const handleSaveBaseURL = useCallback(async (provider: string) => { | ||
| const draft = (baseURLDraft[provider] ?? '').trim() | ||
| setBaseURLError(e => ({ ...e, [provider]: '' })) | ||
| try { | ||
| setSaving(true) | ||
| const response = await fetch(`${KC_AGENT_URL}/settings/keys`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ provider, baseURL: draft }), | ||
| signal: AbortSignal.timeout(FETCH_DEFAULT_TIMEOUT_MS), | ||
| }) | ||
| if (!response.ok) { | ||
| let message = t('agent.failedToSaveKey') | ||
| try { | ||
| const data = await response.json() | ||
| message = data.message || message | ||
| } catch { | ||
| // Response body was not JSON — use default message | ||
| } | ||
| throw new Error(message) | ||
| } | ||
| setBaseURLSaved(prev => new Set(prev).add(provider)) | ||
| // Refresh status so the row reflects the new resolved value. | ||
| await fetchKeysStatus() | ||
| } catch (err) { | ||
| setBaseURLError(e => ({ ...e, [provider]: err instanceof Error ? err.message : t('agent.failedToSaveKey') })) | ||
| } finally { | ||
| setSaving(false) | ||
| } | ||
| }, [baseURLDraft, t]) |
There was a problem hiding this comment.
handleSaveBaseURL calls fetchKeysStatus() but it’s not included in the useCallback dependency list. This can capture a stale version of fetchKeysStatus (it changes when t changes) and will also trip react-hooks/exhaustive-deps linting. Add fetchKeysStatus to the dependency array (or restructure so the callback doesn’t close over it).
| @@ -296,21 +321,35 @@ func (s *Server) handleGetKeysStatus(w http.ResponseWriter, r *http.Request) { | |||
| Configured: cm.HasAPIKey(p.name), | |||
| } | |||
There was a problem hiding this comment.
Configured is currently derived only from cm.HasAPIKey(p.name), but for local LLM runners this endpoint is intended to treat “configured” as “URL present” (per the comment above and the PR description). As written, saving only a BaseURL (the common local-runner path) will still show configured: false in the UI. Consider setting Configured based on (HasAPIKey || BaseURLOverridePresent || BaseURLEnvVarSet) for the URL-driven providers, or add a separate baseURLConfigured/reachable flag so the frontend can render availability correctly.
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
|
Post-merge build verification passed ✅ Both Go and frontend builds compiled successfully against merge commit |
✅ Post-Merge Verification: passedCommit: |
…8314) The nightly Release workflow has been red since 2026-04-13 because four Go tests fell behind handler changes shipped over the past two weeks. Each test now aligns with the current handler contract: - `pkg/agent/server_test.go:TestServer_SettingsHandlers` — expected the keys endpoint to return 0 providers, but #8248/#8254/#8256 now register nine chat-only HTTP providers (3 OpenAI-compatible gateways + 6 local LLM runners) so the Settings modal can show per-provider base URL overrides. Assert non-empty list and Provider presence. - `pkg/api/handlers/cards_test.go:TestRecordFocus_BadBody_Returns400` — `RecordFocus` now runs `requireEditorOrAdmin` before BodyParser (#7011), which calls `store.GetUser`. The test's custom `recordFocusStore` never registered `GetUser`, so the mock panicked. Add the admin-user expectation. - `pkg/api/handlers/dashboard_test.go:setupDashboardTest` — `CreateDashboard` now calls `store.CountUserDashboards` to enforce the per-user dashboard limit (#7010). Register a default `Return(0, nil).Maybe()` so the three CreateDashboard tests stay under the limit. Tests exercising the limit can override. - `pkg/api/handlers/setup_test.go` — `TestClusterGroupsCRUD` exercises handlers that persist definitions via `SaveClusterGroup` / `DeleteClusterGroup` / `ListClusterGroups` (#7013). Register permissive `.Maybe()` mocks on the shared `setupTestEnv` MockStore. Verification: `go test ./...` — all packages pass locally. Fixes #8310, Fixes #8313, Fixes #8314 Signed-off-by: Andy Anderson <[email protected]>
…8314) (#8317) The nightly Release workflow has been red since 2026-04-13 because four Go tests fell behind handler changes shipped over the past two weeks. Each test now aligns with the current handler contract: - `pkg/agent/server_test.go:TestServer_SettingsHandlers` — expected the keys endpoint to return 0 providers, but #8248/#8254/#8256 now register nine chat-only HTTP providers (3 OpenAI-compatible gateways + 6 local LLM runners) so the Settings modal can show per-provider base URL overrides. Assert non-empty list and Provider presence. - `pkg/api/handlers/cards_test.go:TestRecordFocus_BadBody_Returns400` — `RecordFocus` now runs `requireEditorOrAdmin` before BodyParser (#7011), which calls `store.GetUser`. The test's custom `recordFocusStore` never registered `GetUser`, so the mock panicked. Add the admin-user expectation. - `pkg/api/handlers/dashboard_test.go:setupDashboardTest` — `CreateDashboard` now calls `store.CountUserDashboards` to enforce the per-user dashboard limit (#7010). Register a default `Return(0, nil).Maybe()` so the three CreateDashboard tests stay under the limit. Tests exercising the limit can override. - `pkg/api/handlers/setup_test.go` — `TestClusterGroupsCRUD` exercises handlers that persist definitions via `SaveClusterGroup` / `DeleteClusterGroup` / `ListClusterGroups` (#7013). Register permissive `.Maybe()` mocks on the shared `setupTestEnv` MockStore. Verification: `go test ./...` — all packages pass locally. Fixes #8310, Fixes #8313, Fixes #8314 Signed-off-by: Andy Anderson <[email protected]>
Summary
Closes #8254. The Settings → API Keys modal now lists the nine chat-only providers registered in #8248 (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 written to
~/.kc/config.yaml.No more editing shell profiles to point the Console at a LAN Ollama or a corporate Groq gateway — operators can do it from the UI.
Backend
KeyStatusgainsbaseURL,baseURLEnvVar,baseURLSourceso the frontend can render the current resolved value and indicate whether it came from the env var or the config file.AgentKeyConfiggains a new optionalbase_urlYAML field alongsideapi_keyandmodel.ConfigManager.GetBaseURL / SetBaseURL / RemoveBaseURLmirror the existingGetAPIKey / SetAPIKey / RemoveAPIKeyshape. Precedence chain: env var → config file → compiled-in default.getBaseURLEnvKeyForProvidermaps each registered provider to its env var name (OLLAMA_URL,LLAMACPP_URL,LOCALAI_URL,VLLM_URL,LM_STUDIO_URL,RHAIIS_URL,GROQ_BASE_URL,OPENROUTER_BASE_URL,OPEN_WEBUI_URL).groqResolveBaseURL(),openRouterResolveBaseURL(),openWebUIResolveBaseURL(),localOpenAICompatBaseURL()— so changes take effect without restarting the process.handleSetKeyaccepts any combination ofapiKey,baseURL,model. At least one is required. Base URL is syntactically validated (http(s)://, no whitespace) before being saved. On save, cached key validity is invalidated so the next validation hits the new endpoint.handleGetKeysStatusnow returns the nine chat-only providers (was previously empty — predated ✨ Register local LLM providers (Ollama, llama.cpp, LocalAI, vLLM, LM Studio, RHAIIS) in agent selector #8248). CLI-based tool-capable agents remain omitted.validationRequired: false) — there is no meaningful way to validate the sentinel placeholder key against a real endpoint, soConfiguredtracks URL presence instead.Frontend
KeyStatusTS interface gainsbaseURL,baseURLEnvVar,baseURLSource.APIKeySettings.tsxadds an Advanced expandable section to each row. It only renders when the backend reportsbaseURLEnvVar(provider supports an override).baseURLSourceis "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.Test plan
go build ./...passesgo test ./pkg/agent/...passes (one flaky auto-update test unrelated to this change)npm run buildpasses all post-build safety checksnpx tsc --noEmitclean on modified fileshttp://127.0.0.1:11434, Save, verify dropdown flips from "no URL" to Available after restartOLLAMA_URLin shell, reload Settings, verify the Ollama row shows "(env)" next to the Advanced summary and the input is disabled🤖 Generated with Claude Code