Skip to content

feat(providers): unlimited, fully manageable custom providers - #34

Merged
enowdev merged 11 commits into
enowdev:mainfrom
elliottophellia:unlimited-custom-provider
Aug 17, 2026
Merged

feat(providers): unlimited, fully manageable custom providers#34
enowdev merged 11 commits into
enowdev:mainfrom
elliottophellia:unlimited-custom-provider

Conversation

@elliottophellia

Copy link
Copy Markdown
Collaborator

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:

  • Only one custom provider could exist, and its key could not be
    managed from the UI — a second endpoint meant hand-editing YAML.
  • config.Default() seeded a custom entry ("Custom endpoint") into
    every fresh config: an unmanageable card that showed "needs a key"
    forever, and a blank reconnect silently wiped any saved key.
  • URL validation rejected localhost and LAN addresses for custom
    endpoints — excluding the most common case: pointing Antares at your
    own inference server.
  • A nameless or literally-"Custom" setup could land on a hidden slot,
    producing a configured-but-invisible provider that looked like setup
    had done nothing.

Changes

  • POST /api/providers creates a named custom provider; the name is
    minted 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-ins
    and the active provider are refused).
  • The Providers page replaces the built-in custom card with an empty
    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.
  • Custom providers accept loopback and private/LAN endpoints via a new
    allowPrivate validation tier. Link-local stays blocked for everyone
    (cloud metadata SSRF). Existing allowLocal semantics are unchanged.
  • First-run setup (web and terminal) asks for a provider name; a
    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.
  • The defaults no longer seed the custom entry, and that slot never
    renders 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.
  • Web typecheck and production build pass; embedded dist rebuilt.

…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).
@enowdev
enowdev merged commit a1ab109 into enowdev:main Aug 17, 2026
2 checks passed
@elliottophellia
elliottophellia deleted the unlimited-custom-provider branch August 17, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants