feat(providers): unlimited, fully manageable custom providers - #34
Merged
enowdev merged 11 commits intoAug 17, 2026
Conversation
…iders validateProviderBaseURL only exempted loopback, and only for the built-in local catalogue entries, so a custom provider could never point at 127.0.0.1 or a LAN address — exactly the services people name themselves (llama.cpp on localhost, a box on the home network). Split the core into validateProviderBaseURLWithOptions with a separate allowPrivate tier next to allowLocal, and add validateCustomProviderBaseURL for user-defined endpoints: loopback and private ranges pass, while link-local stays blocked for everyone because it carries the cloud metadata endpoints. The existing allowLocal-only semantics (loopback and nothing else) are preserved for the catalogue entries, so the SSRF posture elsewhere is unchanged.
The single catalogue slot was the only "Something else" a user could
reach: /providers/{id}/key rejected any id outside the catalogue, so a
second custom endpoint meant hand-editing YAML, and its base URL was
validated with allowLocal=false even when it pointed at localhost.
Rework the custom entry into a proper user-defined provider:
- The catalogue entry becomes "Custom provider" and is marked Custom; a
name is minted into a unique config id (my-lm-server, my-lm-server-2,
...) so any number can coexist. An unnamed one keeps the legacy
"custom" slot, and a previously configured "custom" entry continues to
work unchanged — it just renders as a custom provider now.
- setup/complete accepts a name for it; setup/test, /providers/{id}/key
and /providers/{id}/settings validate its base URL with the custom
rules, and the key endpoint no longer requires a key for custom
providers (a keyless LAN service is legitimate) while still managing
config-only ids instead of rejecting them.
- New POST /api/providers creates a named custom provider (verifying the
endpoint/key pair first) and DELETE /api/providers/{id} removes one;
built-ins and the active provider are refused.
- /model/options omits the unconfigured custom slot — the UI shows an
empty add-provider card in its place — and marks every custom provider
with custom:true, never local, so it groups under "API key" even when
its endpoint is 127.0.0.1.
The terminal picker still offered an anonymous "Something else" that always wrote the single "custom" slot. Rename it to "Custom provider", ask for a name, and store the entry under an id minted from that name — so the terminal wizard and the web wizard produce the same named providers. No name keeps the legacy "custom" slot; pickModel now resolves against cfg.Model.Provider, which the name step may have changed after the picker ran.
Replace the always-present "Something else" card with an empty dashed
add-provider card (plus icon) at the end of the API key group. It opens
a small dialog that takes a name, a base URL, and an optional key and
creates the provider via POST /api/providers — local addresses
included, and there can be any number of them.
Custom providers always group under "API key", never "Local", even when
their endpoint is localhost: the card shows the configured service, not
a built-in runtime. Their manage modal gains a name field under Advanced
(rename through /providers/{id}/settings), a delete action, and the key
is no longer forced — the backend verifies the pair either way.
The first-run setup wizard asks for the provider name too when Custom
provider is selected, and sends it with setup/complete. Locale strings
land in en; the other dictionaries fall back to it.
The custom entry was skipped only when providers.custom was missing from the config entirely. A config that had touched the old "Something else" picker without finishing it carries an empty custom entry (no key, no base URL), which still rendered as a "Custom provider — needs a key" card next to the new add-provider card. Skip the slot whenever it holds nothing worth showing — no credential and no endpoint — so the empty plus card replaces it there too. A custom entry with a key or a base URL is a live provider and keeps appearing.
Two ways the old "Something else" slot still felt welded in: The key field disappeared for custom providers — keyRequired drove both whether a key was mandatory and whether the input rendered, so making it optional for customs hid it entirely and an existing key could not be changed. Split the two: the input now shows for every keyed provider and only the requirement is dropped for customs (bedrock and local runtimes stay keyless by design). The delete endpoint refused the "custom" id like any built-in. The legacy slot is user property, not a built-in: allow deleting it (and only it) alongside named custom providers, with a clearer message when the provider is still the active one.
config.Default() seeded providers.custom (label "Custom endpoint", kind "custom") into every fresh config — that is where the unmanageable card came from, not the setup catalogue. The old UI could only ever write a base URL into it, which is why it looked connected-forever-but-keyless: "needs a key" with no way to set one. Stop seeding the entry, and never render the catalogue's custom slot on the providers page regardless of what an existing config holds — the slot is reserved for the first-run wizard, where it names a real provider. The providers page now lists built-ins plus only the user-created custom providers (named, renamable, key-settable, deletable). An old config's custom entry keeps functioning if a model still points at it; it just no longer appears as a card.
…d key Making the key optional for custom providers let a blank Connect submit reach handleSetProviderKey, which wrote entry.APIKey verbatim — so reopening the manage modal (key field starts empty) and reconnecting to update the endpoint silently destroyed the saved credential, and the connection probe ran keyless on top. Treat a blank or redacted key as "keep what is stored", the convention the setup wizard already uses, and probe with the kept key. The modal now says so: a provider with a saved key shows the leave-blank-to-keep hint instead of the optional-key one. Covered by TestSetProviderKeyBlankKeepsStoredKey against a fake OpenAI-compatible endpoint, which also asserts the probe carried the kept key and that a real new key still replaces the old one.
…slot customProviderID mapped an empty name (and a name that slugs to "custom") onto the legacy "custom" id. After that slot was removed from the providers page, a first-run setup that skipped the name still saved the provider — base URL, key, active model and all — but saved it onto an id that never renders. The result looked exactly like "setup did nothing": no custom provider on the page, and chat silently pointing at an entry the UI cannot even show. Default a missing or "Custom" name to "custom-provider" (deduped like any other id) in both wizards, so a nameless setup produces a visible, manageable provider labeled "Custom provider". The terminal wizard also defaults its skipped prompt instead of only naming on explicit input.
…viders The custom-provider work left the same decisions duplicated per handler: a catalogue scan to find the entry, an if/else picking the custom vs built-in URL rule, and — worst — a second id minter in the terminal wizard whose builtin list covered only that wizard's short provider menu. Naming a custom provider "Vertex" there minted the id "vertex", which the dashboard then renders as the built-in Vertex, silently merging the user's endpoint into a catalogue entry. Consolidate: lookupSetupProvider for the catalogue scan, validateChosenBaseURL for the URL rule, and CustomProviderID as the single exported minter (now used by both wizards and the create endpoint). Two setup holes close along the way: setup/test and setup/complete now refuse a custom provider with no base URL instead of saving one that cannot answer, and setup/test no longer demands a key for customs — a keyless LAN service is legitimate there, same as everywhere else.
The provider step's Next button stayed enabled with Custom provider selected and an empty base URL, deferring the failure to the backend's 400 at completion. Disable Next instead, mirroring the model step. The add-provider dialog now submits from Enter on the base URL field, not just the key field, and the Indonesian dictionary gets the nine custom-provider strings (the other non-English dicts carry no providers.* keys at all and fall back to English, matching their existing coverage).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replaces the old single "Something else" slot with real user-defined
providers: you name them, they're unlimited, and each one is fully
manageable (rename, change key, change endpoint, delete) from the
Providers page.
Why
The previous flow had one semi-baked custom slot and it failed in
several ways:
managed from the UI — a second endpoint meant hand-editing YAML.
config.Default()seeded acustomentry ("Custom endpoint") intoevery fresh config: an unmanageable card that showed "needs a key"
forever, and a blank reconnect silently wiped any saved key.
endpoints — excluding the most common case: pointing Antares at your
own inference server.
producing a configured-but-invisible provider that looked like setup
had done nothing.
Changes
POST /api/providerscreates a named custom provider; the name isminted into a unique config id (
my-lm-server,my-lm-server-2, …)that avoids every built-in id. The endpoint/key pair is verified
before saving.
DELETE /api/providers/{id}removes one (built-insand the active provider are refused).
dashed "+ Add a custom provider" card. The manage modal gains a name
field, a delete action, and a key field that is optional for customs;
a blank key means "keep the stored one", never an overwrite.
allowPrivatevalidation tier. Link-local stays blocked for everyone(cloud metadata SSRF). Existing
allowLocalsemantics are unchanged.missing name defaults to a visible "Custom provider" entry instead of
the hidden legacy slot, and both setup endpoints now refuse a custom
provider with no base URL. Both wizards share one id minter, so a
name like "Vertex" can no longer collide with a built-in id.
customentry, and that slot neverrenders on the Providers page — only built-ins and user-created
providers appear there, always under "API key" (never "Local", even
for a localhost endpoint).
Testing
go test ./...passes, including new tests: custom URL validation(loopback/LAN pass, metadata/link-local blocked), provider id minting
(dedupe, builtin clashes, nameless fallback), and a regression test
that a blank key neither wipes the stored credential nor probes
keyless — run against a fake OpenAI-compatible endpoint.