Skip to content

✨ Settings → API Keys: Advanced Base URL field per local LLM provider (closes #8254)#8256

Merged
clubanderson merged 2 commits intomainfrom
feat/local-llm-base-url-ui
Apr 16, 2026
Merged

✨ Settings → API Keys: Advanced Base URL field per local LLM provider (closes #8254)#8256
clubanderson merged 2 commits intomainfrom
feat/local-llm-base-url-ui

Conversation

@clubanderson
Copy link
Copy Markdown
Collaborator

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

  • KeyStatus gains baseURL, baseURLEnvVar, baseURLSource so the frontend can render the current resolved value and indicate whether it came from the env var or the config file.
  • AgentKeyConfig gains a new optional base_url YAML field alongside api_key and model.
  • ConfigManager.GetBaseURL / SetBaseURL / RemoveBaseURL mirror the existing GetAPIKey / SetAPIKey / RemoveAPIKey shape. Precedence chain: env var → config file → compiled-in default.
  • getBaseURLEnvKeyForProvider maps 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).
  • All nine HTTP providers refactored to resolve their base URL dynamically at request time via groqResolveBaseURL(), openRouterResolveBaseURL(), openWebUIResolveBaseURL(), localOpenAICompatBaseURL() — so changes take effect without restarting the process.
  • handleSetKey accepts any combination of apiKey, 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.
  • handleGetKeysStatus now 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.
  • Key validation is skipped for local LLM runners (validationRequired: false) — there is no meaningful way to validate the sentinel placeholder key against a real endpoint, so Configured tracks URL presence instead.

Frontend

  • KeyStatus TS interface gains baseURL, baseURLEnvVar, baseURLSource.
  • APIKeySettings.tsx adds an Advanced expandable section to each row. It only renders when the backend reports baseURLEnvVar (provider 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.
  • No changes to the existing API key edit form — Base URL is a distinct flow that can save independently.

Test plan

  • go build ./... passes
  • go test ./pkg/agent/... passes (one flaky auto-update test unrelated to this change)
  • npm run build passes all post-build safety checks
  • npx tsc --noEmit clean on modified files
  • Manual: open Settings → API Keys, expand Advanced on Ollama, enter http://127.0.0.1:11434, Save, verify dropdown flips from "no URL" to Available after restart
  • Manual: set OLLAMA_URL in shell, reload Settings, verify the Ollama row shows "(env)" next to the Advanced summary and the input is disabled

🤖 Generated with Claude Code

… 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]>
Copilot AI review requested due to automatic review settings April 16, 2026 00:40
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Apr 16, 2026
@kubestellar-prow
Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign clubanderson for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 16, 2026

Deploy Preview for kubestellarconsole ready!

Name Link
🔨 Latest commit ea91e5d
🔍 Latest deploy log https://app.netlify.com/projects/kubestellarconsole/deploys/69e02fe939367f00083b069a
😎 Deploy Preview https://deploy-preview-8256.console-deploy-preview.kubestellar.io
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Copy Markdown
Contributor

👋 Hey @clubanderson — thanks for opening this PR!

🤖 This project is developed exclusively using AI coding assistants.

Please do not attempt to code anything for this project manually.
All contributions should be authored using an AI coding tool such as:

This ensures consistency in code style, architecture patterns, test coverage,
and commit quality across the entire codebase.


This is an automated message.

@kubestellar-prow kubestellar-prow Bot added the size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. label Apr 16, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/keys API 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.

Comment on lines +324 to +332
// 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"
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +384 to 405
// 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
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread pkg/agent/server_ai.go
Comment on lines +1611 to +1622
// 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.
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +193 to +222
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])
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines 318 to 322
@@ -296,21 +321,35 @@ func (s *Server) handleGetKeysStatus(w http.ResponseWriter, r *http.Request) {
Configured: cm.HasAPIKey(p.name),
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@clubanderson clubanderson merged commit 3e38dc1 into main Apr 16, 2026
39 of 40 checks passed
@clubanderson clubanderson deleted the feat/local-llm-base-url-ui branch April 16, 2026 00:48
@github-actions
Copy link
Copy Markdown
Contributor

Thank you for your contribution! Your PR has been merged.

Check out what's new:

Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey

@github-actions
Copy link
Copy Markdown
Contributor

Post-merge build verification passed

Both Go and frontend builds compiled successfully against merge commit 3e38dc1ca39a841493694191868e40ac2aa738b0.

@github-actions
Copy link
Copy Markdown
Contributor

✅ Post-Merge Verification: passed

Commit: 3e38dc1ca39a841493694191868e40ac2aa738b0
Specs run: smoke.spec.ts
Report: https://github.com/kubestellar/console/actions/runs/24485973437

clubanderson added a commit that referenced this pull request Apr 16, 2026
…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]>
clubanderson added a commit that referenced this pull request Apr 16, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. tier/2-standard

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a per-provider Base URL field to APIKeySettings (Advanced)

2 participants