Skip to content

Bind up to three upstream addresses per provider - #52

Merged
loadchange merged 3 commits into
mainfrom
claude/compassionate-cori-nv12t5
Sep 11, 2026
Merged

loadchange merged 3 commits into
mainfrom
claude/compassionate-cori-nv12t5

Conversation

@loadchange

Copy link
Copy Markdown
Collaborator

A provider was one protocol at one URL, so a vendor that publishes Anthropic Messages, Chat Completions and Responses of its own had two of those three callers translated on the way to an upstream that already spoke their language. Conversion was decided by the shape of the config, not by what the upstream could actually serve.

A provider is now one bare root address plus up to three addresses bound under it, one per protocol. Whatever is bound is passed through untouched; whatever is not is converted onto the primary.

Field Value
API 地址 https://api.deepseek.com
Anthropic Messages https://api.deepseek.com/anthropic
OpenAI Chat Completions https://api.deepseek.com/chat/completions
OpenAI Responses (not bound — converted)

What changed

  • Provider gains protocolUrls. A provider saved before this falls back to baseUrl under protocol, which is exactly the single upstream it has always had, so existing configurations route identically. Opening the editor writes that address out so the field shows what is in use instead of looking empty under a segment marked as bound.
  • GatewayUpstreamURL.upstream accepts either spelling a vendor documents. An endpoint spelling (…/chat/completions) is called exactly as typed, pinned through request_path_overrides, because a provider publishing that path need not also answer /v1/chat/completions. A base spelling (…/anthropic) keeps the original /v1 reconciliation.
  • routedProviders emits one Bifrost provider per bound address, since a Bifrost provider carries one base URL and one wire format. Names are digested from provider id and protocol so a provider's own entries cannot collide.
  • Failover stays between providers. A provider that binds three addresses contributes three routes; only its first remaining route stands in for it in the fallback chain, so the next provider in the queue is still reached.
  • Editor. The API address field is a bare root; the protocol segmented control picks which address the field under it edits (above the API key), with the conventional address offered from the root, a ● marker on each bound protocol, and a line stating what gets converted.
  • Probe and model discovery are per address, so three bound addresses can be tested independently, and a catalog is looked for at the API being bound before the provider root (https://api.kimi.com/coding/v1 lists models https://api.kimi.com does not).

Presets

Aggregator and third-party relay presets are removed — their endpoints move, their protocol support is whatever their own upstream exposes that week, and listing them implied vetting this app cannot do. What remains is Anthropic, OpenAI, Google and the model vendors with a first-party coding endpoint, each with a pure root plus the endpoints it publishes. Anything not listed is one 自定义 entry away.

Testing

Unit coverage added for the three-of-three / two-of-three routing cases on a single provider, provider-level fallbacks when one provider binds every protocol, endpoint-vs-base URL resolution, per-address probing, discovery roots, and normalization of blank addresses. Existing preset and upstream-URL invariants were rewritten against the per-protocol endpoints rather than the base URL.

The JavaScript suite passes locally. Swift compilation and the native unit tests only run on the macOS runner, so CI is the first build of this branch.

Note

Scoped to native/ (the shipped SwiftUI 2.x app). The legacy src/renderer/ provider modal is untouched — CI does not build it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ


Generated by Claude Code

A provider was one protocol at one URL, so a vendor that publishes
Anthropic Messages, Chat Completions and Responses of its own had two of
those three callers translated on the way to an upstream that already
spoke their language. Conversion was decided by the shape of the config,
not by what the upstream could actually serve.

A provider is now one bare root address plus up to three addresses bound
under it, one per protocol. Whatever is bound is passed through
untouched; whatever is not is converted onto the primary. Conversion
follows what the user configured.

- Provider gains `protocolUrls`. A provider saved before this falls back
  to `baseUrl` under `protocol`, which is exactly the single upstream it
  has always had, so nothing changes for existing configurations.
- `GatewayUpstreamURL.upstream` accepts either spelling a vendor
  documents. An endpoint spelling (`…/chat/completions`) is called
  exactly as typed, pinned through `request_path_overrides`, because a
  provider publishing that path need not answer `/v1/chat/completions`
  too. A base spelling keeps the original reconciliation.
- `routedProviders` emits one Bifrost provider per bound address, since a
  Bifrost provider carries one base URL and one wire format. Failover
  stays between providers: a provider's other addresses do not occupy the
  fallback chain ahead of the next provider in the queue.
- The editor's API address field is now a bare root; the protocol
  segmented control picks which address the field under it edits, with
  the conventional address offered from the root, a marker on each bound
  protocol and a line saying what gets converted.
- The probe and model discovery work per address rather than per
  provider, so three bound addresses can be tested independently and a
  catalog is looked for at the API being bound before the root.

Aggregator and third-party relay presets are removed. Their endpoints
move, their protocol support is whatever their own upstream exposes that
week, and listing them implied vetting this app cannot do. What remains
is Anthropic, OpenAI, Google and the model vendors with a first-party
coding endpoint, each with a pure root and the endpoints it publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ
Discovery reads the address bound to the protocol being edited, not the
provider root, so that address is what makes a previous "this host has no
listing endpoint" answer meaningless. Only the root and the selected
protocol were watched, which left an `.unsupported` verdict from one host
greying the refresh control out against the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ
Discovery took the bound address's resolved root and the provider's base
URL as two places to look, deduplicated as raw strings. For a provider
saved before per-protocol addresses those are the same API spelled two
ways — `https://host/v1` and the `https://host` it resolves to — so the
second root survived, and its candidate list put `…/v1/v1/models` back on
the probe list, which is exactly what the candidate ordering exists to
keep off it.

Reconcile the base URL the same way the bound address is before
deduplicating. A provider whose root and bound address name one API now
contributes one root; one whose root genuinely differs, like Kimi For
Coding's `https://api.kimi.com/coding/v1` under `https://api.kimi.com`,
still contributes both, most specific first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ
@loadchange
loadchange merged commit 2ae3044 into main Sep 11, 2026
2 checks passed
@loadchange
loadchange deleted the claude/compassionate-cori-nv12t5 branch September 11, 2026 21:32
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