Implementation Plan: Support baseUrl as a Model Setting Param¶
Issue Summary¶
Users should be able to provide an optional custom baseUrl during the /model
setup flow. If no value is supplied, the SDK's default URL is used. A basic URL
format validation must be applied to the input.
Current Architecture (relevant touch-points)¶
| Layer | File | What it does |
|---|---|---|
| Config types | internal/config/config.go |
ProviderConfig, GlobalConfig, ResolvedConfig, Resolve() |
| Config persistence | internal/config/loader.go |
JSON read/write to ~/.keen/configs.json |
| LLM factory | internal/llm/models.go |
ClientConfig, NewClient() |
| Anthropic client | internal/llm/anthropic.go |
reads ANTHROPIC_BASE_URL from .env today |
| OpenAI Responses | internal/llm/openai_responses.go |
NewOpenAIResponsesClient — no baseURL today |
| OpenAI-compat | internal/llm/openai.go |
openAICompatibleBaseURL() hard-codes provider URLs |
| Genkit (Google) | internal/llm/genkit.go |
NewGenkitClient — no baseURL today |
| Model selection UI | internal/cli/repl/widgets/model_selection.go |
multi-step wizard: Provider → Model → Thinking → APIKey |
| REPL wiring | internal/cli/repl/repl.go |
calls replwidgets.New(...) and onComplete callback |
Implementation Plan¶
1. Config layer — store baseUrl per provider¶
File: internal/config/config.go
- [ ] Add
BaseURL stringfield toProviderConfig(JSON tag"base_url,omitempty"). - [ ] Add
BaseURL stringfield toResolvedConfig. - [ ] In
Resolve(), propagateProviderConfig.BaseURLintoResolvedConfig.BaseURL(no session-level override needed for now — it is always persisted globally).
2. LLM layer — thread BaseURL through ClientConfig and all clients¶
File: internal/llm/models.go
- [ ] Add
BaseURL stringtoClientConfig. - [ ] Pass
cfg.BaseURLwhen constructing eachClientConfiginsideNewClient().
File: internal/llm/anthropic.go
- [ ] Remove the
.env-basedANTHROPIC_BASE_URLlookup. - [ ] Read
cfg.BaseURLfromClientConfig; applyoption.WithBaseURLonly when non-empty.
File: internal/llm/openai_responses.go
- [ ] Accept
cfg.BaseURLinNewOpenAIResponsesClient; applyoption.WithBaseURLonly when non-empty.
File: internal/llm/openai.go
- [ ] Accept
cfg.BaseURLinNewOpenAICompatibleClient. - [ ] When
cfg.BaseURLis non-empty, use it instead of the value returned byopenAICompatibleBaseURL(). - [ ] Keep
openAICompatibleBaseURL()as the fallback for providers that have a well-known default URL.
File: internal/llm/genkit.go
- [ ] Accept
cfg.BaseURLinNewGenkitClient. - [ ] Pass it through to the
compat_oai.OpenAICompatible/googlegenai.GoogleAIplugin config when non-empty (check each plugin's struct for aBaseURLfield).
3. UI layer — add StepBaseURL to the model selection wizard¶
File: internal/cli/repl/widgets/model_selection.go
- [ ] Add
StepBaseURL Stepconstant (insert afterStepProvider, beforeStepModel— or afterStepAPIKey, before completion — whichever feels most natural in UX; proposed order: Provider → Model → Thinking → BaseURL → APIKey). - [ ] Add fields to
Model: BaseURLInput stringBaseURLError string- [ ] Update
handleKeyMsgforStepBaseURL: backspace/ printable text → editBaseURLInput.enter→ validate; if valid (or empty) advance toStepAPIKey; else setBaseURLError.esc→ emitmodelSelectionCancelMsg.- [ ] Add
handlePasteMsgsupport forStepBaseURL(same pattern asStepAPIKey). - [ ] Add URL validation helper
isValidBaseURL(s string) bool: - Accept empty string (optional field).
- Use
net/url.Parse+ checkschemeishttporhttpsandHostis non-empty. - [ ] Add
renderBaseURLInput()view method (mirroringrenderAPIKeyInputstyle, but no masking — base URLs are not secrets). - Show existing saved value as a hint:
(press Enter to keep: <url>). - Show
BaseURLErrorin red when non-empty. - [ ] Update
ViewString()to handleStepBaseURL. - [ ] Update
complete()to: - Read
BaseURLInput; fall back to existingProviderConfig.BaseURLwhen empty. - Store in
ProviderConfig.BaseURLandm.resolvedCfg.BaseURL. - [ ] Update
New(...)/onCompletesignature: the callback currently receives(provider, model, apiKey string)— extend to also passbaseURL string, or simply rely onresolvedCfgwhich is already mutated in place beforeonCompleteis called. The latter requires no signature change.
File: internal/cli/repl/repl.go
- [ ] No signature change needed if
resolvedCfgmutation approach is used. - [ ] Verify
updateLLMClient()re-readsm.ctx.cfg(which isresolvedCfg) — it already does viallm.NewClient(m.ctx.cfg). - [ ] Optionally show
baseUrlinbuildInitialScreeninfo block when non-empty.
4. Validation helper (shared or inline)¶
- [ ] Implement
isValidBaseURLinmodel_selection.go(keeping it local; no need for a separate package given it is UI-only validation). - [ ] Rules:
- Empty → valid (field is optional).
- Must parse without error via
url.Parse. - Scheme must be
"http"or"https". - Host must be non-empty.
5. Tests¶
File: internal/config/config_test.go
- [ ] Add test:
ResolvepropagatesProviderConfig.BaseURLintoResolvedConfig. - [ ] Add test:
ResolveleavesResolvedConfig.BaseURLempty whenProviderConfighas noBaseURL.
File: internal/llm/anthropic_test.go
- [ ] Add test:
NewAnthropicClientuses customBaseURLwhen provided. - [ ] Add test:
NewAnthropicClientdoes not set base URL whencfg.BaseURLis empty.
File: internal/llm/openai_responses_test.go / internal/llm/openai_test.go
- [ ] Same coverage pattern as Anthropic tests above.
File: (new or existing widget test)
- [ ] Add test for
isValidBaseURL: ""→ valid."https://api.example.com"→ valid."http://localhost:8080"→ valid."ftp://bad.com"→ invalid."not-a-url"→ invalid."https://"(missing host) → invalid.- [ ] Add test:
StepBaseURLadvances toStepAPIKeyon Enter with valid URL. - [ ] Add test:
StepBaseURLstays on step and sets error on Enter with invalid URL. - [ ] Add test:
complete()persistsBaseURLInputintoProviderConfig.BaseURL.
6. Housekeeping¶
- [ ] Run
go test ./...— all tests pass. - [ ] Run
go mod tidy. - [ ] Update
CHANGELOG.md[Unreleased]section with the new feature entry.
File Change Summary¶
| File | Change type |
|---|---|
internal/config/config.go |
Edit — add BaseURL to ProviderConfig and ResolvedConfig; update Resolve() |
internal/config/config_test.go |
Edit — new tests |
internal/llm/models.go |
Edit — add BaseURL to ClientConfig; pass through in NewClient() |
internal/llm/anthropic.go |
Edit — remove .env lookup; use cfg.BaseURL |
internal/llm/anthropic_test.go |
Edit — new tests |
internal/llm/openai_responses.go |
Edit — use cfg.BaseURL |
internal/llm/openai_responses_test.go |
Edit — new tests |
internal/llm/openai.go |
Edit — use cfg.BaseURL with fallback |
internal/llm/openai_test.go |
Edit — new tests |
internal/llm/genkit.go |
Edit — use cfg.BaseURL where plugin supports it |
internal/cli/repl/widgets/model_selection.go |
Edit — new StepBaseURL, validation, render |
internal/cli/repl/repl.go |
Edit (minor) — optionally display baseURL in initial screen |
CHANGELOG.md |
Edit — add unreleased entry |