From 2a82936ca18ac741f8d8e4ae9b2f436f6a10ffe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 07:57:24 +0000 Subject: [PATCH 1/3] Bind up to three upstream addresses per provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ --- README.md | 14 +- README.zh-CN.md | 14 +- native/Resources/en.lproj/Localizable.strings | 11 + native/Resources/ja.lproj/Localizable.strings | 11 + native/Resources/ko.lproj/Localizable.strings | 11 + .../zh-Hans.lproj/Localizable.strings | 11 + .../zh-Hant.lproj/Localizable.strings | 11 + .../native-localization-overrides.json | 77 +++++ .../Gateway/BifrostConfiguration.swift | 75 +++-- .../Sources/Gateway/BifrostSupervisor.swift | 8 +- .../Gateway/GatewayProtocolRouting.swift | 26 +- .../Sources/Gateway/GatewayUpstreamURL.swift | 100 +++++- native/Sources/Models/AppConfig.swift | 16 + native/Sources/Models/Provider.swift | 72 +++- .../ProviderModelDiscoveryService.swift | 40 ++- .../Services/ProviderProbeService.swift | 50 +-- .../Sources/Views/Monitor/MonitorView.swift | 6 +- .../Providers/ProviderEditorSupport.swift | 6 +- .../Views/Providers/ProviderEditorView.swift | 198 ++++++++++- .../Providers/ProviderPresetCatalog.swift | 311 +++++++++++++----- .../Sources/Views/Providers/ProviderRow.swift | 23 +- .../Views/Settings/GatewaySettingsPane.swift | 2 +- native/Tests/Unit/AppConfigTests.swift | 46 +++ native/Tests/Unit/BifrostConfigTests.swift | 54 +++ .../Unit/GatewayProtocolRoutingTests.swift | 96 ++++++ .../Tests/Unit/GatewayUpstreamURLTests.swift | 158 +++++++-- .../Tests/Unit/ProviderHeroUsageTests.swift | 67 ++-- .../Unit/ProviderModelDiscoveryTests.swift | 38 +++ .../Unit/ProviderPresetCatalogTests.swift | 70 +++- .../Unit/ProviderProbeServiceTests.swift | 56 ++++ 30 files changed, 1430 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index 23b9b7d6..72c2de0b 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,18 @@ The redesign follows a macOS 27 design direction using available macOS 26 APIs b As a companion feature, the gateway accepts **Anthropic Messages**, **OpenAI Chat Completions**, and **OpenAI Responses** from clients, whichever of those three you configure as upstreams. -- **Configure all three** and every client is handed to the upstream that already speaks its protocol, untouched. -- **Configure one or two** and a client speaking a protocol you did have still passes through, while one speaking a protocol you do not is converted for you. +A provider is one root address — `https://api.deepseek.com` — and up to three addresses bound under it, one per protocol: -Either way, if an upstream fails the rest of your queue takes over in order. It configures **Claude Code and Codex** with one click; other compatible clients can use the local endpoint manually. Around seventy provider presets ship built in, alongside custom and plugin-backed providers, with switching and model mapping. A provider that publishes `/v1/models` or `/models` can fill in its own model bindings from the editor; one that does not leaves the control disabled rather than failing. +| Protocol | Address | +| --- | --- | +| Anthropic Messages | `https://api.deepseek.com/anthropic` | +| OpenAI Chat Completions | `https://api.deepseek.com/chat/completions` | +| OpenAI Responses | `https://api.deepseek.com/responses` | + +- **Bind all three** and every client is handed to the address that already speaks its protocol, untouched. A vendor publishing all three of its own endpoints is never translated for. +- **Bind one or two** and a client speaking a protocol you did bind still passes through, while one speaking a protocol you did not is converted for you. Conversion follows what you configured, rather than being decided in advance. + +Either way, if an upstream fails the rest of your queue takes over in order. It configures **Claude Code and Codex** with one click; other compatible clients can use the local endpoint manually. Presets ship for Anthropic, OpenAI, Google and the model vendors with a first-party coding endpoint, alongside custom and plugin-backed providers, with switching and model mapping. A provider that publishes `/v1/models` or `/models` can fill in its own model bindings from the editor; one that does not leaves the control disabled rather than failing. The gateway binds to `127.0.0.1`; inference requests still go to the provider you select. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2a4bc0d6..aea0508d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -48,10 +48,18 @@ CLI 会话记录 ──▶ CC Buddy ──▶ 浏览 · 搜索 · 追踪 · 导 作为附带能力,网关始终接受客户端发来的 **Anthropic Messages、OpenAI Chat Completions 和 OpenAI Responses**,而上游由你在这三种协议中自行选配。 -- **三种都配**:每个客户端都会被交给协议相同的那个上游,原样直通,不做任何转换。 -- **只配一种或两种**:协议已配置的客户端照样直通;协议未配置的客户端由网关完成协议转换后再发出。 +一个服务商只填一个根地址——`https://api.deepseek.com`——再在它下面按协议绑定最多三个地址: -两种情况下,若某个上游失败,队列中的其余上游都会按顺序接管。它能一键配置 **Claude Code 和 Codex**;其他兼容客户端可手动使用本地端点。内置约七十个服务商预设,也支持自定义与插件服务,均可切换并映射模型。若服务商提供 `/v1/models` 或 `/models`,可在编辑器中一键拉取并自动建立模型绑定;没有该接口时按钮会置灰而不是报错。 +| 协议 | 地址 | +| --- | --- | +| Anthropic Messages | `https://api.deepseek.com/anthropic` | +| OpenAI Chat Completions | `https://api.deepseek.com/chat/completions` | +| OpenAI Responses | `https://api.deepseek.com/responses` | + +- **三个都绑**:每个客户端都会被交给协议相同的那个地址,原样直通,不做任何转换。服务商自己就支持三种协议时,网关不会白做一次转换。 +- **只绑一个或两个**:协议已绑定的客户端照样直通;协议未绑定的客户端由网关完成协议转换后再发出。转换是按你实际配置了什么来决定的,不是提前写死的。 + +两种情况下,若某个上游失败,队列中的其余上游都会按顺序接管。它能一键配置 **Claude Code 和 Codex**;其他兼容客户端可手动使用本地端点。内置预设覆盖 Anthropic、OpenAI、Google 以及提供官方 Coding 端点的模型厂商,也支持自定义与插件服务,均可切换并映射模型。若服务商提供 `/v1/models` 或 `/models`,可在编辑器中一键拉取并自动建立模型绑定;没有该接口时按钮会置灰而不是报错。 网关只监听 `127.0.0.1`;推理请求仍会发送给所选服务商。 diff --git a/native/Resources/en.lproj/Localizable.strings b/native/Resources/en.lproj/Localizable.strings index 30519eb4..298a05b3 100644 --- a/native/Resources/en.lproj/Localizable.strings +++ b/native/Resources/en.lproj/Localizable.strings @@ -27,6 +27,7 @@ "{n} 个会话无法建立索引,已显示其余会话" = "{n} conversations could not be indexed; the rest are shown"; "{n} 个失败" = "{n} failed"; "{n} 个文件" = "{n} files"; +"{n} 个协议直通" = "{n} protocols pass through"; "{n} 个已存在" = "{n} already imported"; "{n} 会话" = "{n} sessions"; "{n} 天前" = "{n} d ago"; @@ -67,7 +68,9 @@ "安装到工具" = "Install to tools"; "安装受支持的 coding agent 后刷新本页。" = "Install a supported coding agent, then refresh this page."; "按 Agent" = "By agent"; +"按 API 地址填入该协议的常规地址" = "Fill in this protocol's conventional address from the API URL"; "按目录" = "By directory"; +"按需转换" = "Converts as needed"; "把客户端模型名精确映射到上游模型;未命中时才使用自动映射。" = "Map client model names exactly to upstream models; automatic mapping is used only when none match."; "把客户端请求的模型名精确映射到上游模型,回包自动改回原名。带 [1m](1M 上下文)等特殊模型务必在此显式映射到支持的上游,避免被自动映射降级。未命中别名时才走「自动映射」。" = "Map the client’s requested model name exactly to an upstream model; the response is renamed back automatically. Models with [1m] (1M context) etc. MUST be mapped here explicitly to a supported upstream, to avoid being auto-downgraded. Auto-mapping only kicks in when no alias matches."; "版本" = "Version"; @@ -480,6 +483,7 @@ "模型厂商" = "Model vendors"; "模型列表为空或无法解析" = "The model list was empty or could not be read"; "默认" = "Default"; +"默认地址" = "Default address"; "默认位置不可移除" = "The default location can't be removed"; "目标工具" = "Target tools"; "目标位置已存在同名 Skill,覆盖后原内容将被替换:\n{paths}" = "A Skill with the same name already exists at the target. Replacing it will overwrite its current contents:\n{paths}"; @@ -565,6 +569,7 @@ "全部范围" = "All scopes"; "全部更新" = "Update all"; "全部会话" = "All sessions"; +"全部直通" = "All pass through"; "全部状态" = "All statuses"; "全局" = "Global"; "全局安装" = "Global installs"; @@ -583,6 +588,8 @@ "软删除的会话会出现在这里" = "Soft-deleted sessions appear here"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除旧值以免覆盖。" = "If you previously exported ANTHROPIC_BASE_URL in a terminal, remove the old value so it does not override this one."; "若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。" = "If you ran export ANTHROPIC_BASE_URL in a terminal, remove it to avoid overriding."; +"三个协议都已绑定,全部直通" = "All three protocols are bound; everything passes through"; +"三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。" = "All three protocols are bound: every client uses its own address and nothing is converted."; "筛选标签 {name}" = "Filter by tag {name}"; "删除" = "Delete"; "删除 Skill {name}" = "Delete Skill {name}"; @@ -742,6 +749,8 @@ "未安装 ChatGPT 桌面端" = "The ChatGPT app is not installed"; "未安装 Claude 桌面端" = "Claude Desktop is not installed"; "未安装插件" = "No plugins installed"; +"未绑定的协议会转换后发往「{protocol}」。" = "Unbound protocols are converted and sent to “{protocol}”."; +"未绑定的协议由 Bifrost 自动转换" = "Unbound protocols are converted by Bifrost"; "未标注模型" = "Model not specified"; "未登录" = "Not signed in"; "未发现插件。把插件目录放到 ~/.ccbud/plugins// 后回到本页。" = "No plugins found. Put a plugin folder in ~/.ccbud/plugins//, then return to this page."; @@ -1054,8 +1063,10 @@ "直接搜索" = "Direct search"; "直连" = "Direct"; "直通" = "Pass-through"; +"只填服务商根地址,每个协议各自的地址在下面绑定。" = "Enter the provider's root address only; each protocol's own address is bound below."; "至少 {count} 处匹配" = "At least {count} matches"; "至少 1 处匹配" = "At least 1 match"; +"至少绑定一个协议的地址,网关才有上游可用。" = "Bind at least one protocol's address so the gateway has an upstream."; "智能排序" = "Smart ranking"; "置顶会话" = "Pin session"; "中心源副本会保留。" = "The central source copy will be kept."; diff --git a/native/Resources/ja.lproj/Localizable.strings b/native/Resources/ja.lproj/Localizable.strings index a80f5e13..e8c998f8 100644 --- a/native/Resources/ja.lproj/Localizable.strings +++ b/native/Resources/ja.lproj/Localizable.strings @@ -27,6 +27,7 @@ "{n} 个会话无法建立索引,已显示其余会话" = "{n} 件の会話をインデックス化できませんでした。残りの会話を表示しています"; "{n} 个失败" = "{n} 件失敗"; "{n} 个文件" = "{n} ファイル"; +"{n} 个协议直通" = "{n}個のプロトコルが直通"; "{n} 个已存在" = "{n} 件は既存"; "{n} 会话" = "{n} セッション"; "{n} 天前" = "{n} 日前"; @@ -67,7 +68,9 @@ "安装到工具" = "インストール先ツール"; "安装受支持的 coding agent 后刷新本页。" = "対応する coding agent をインストールしてから、このページを更新してください。"; "按 Agent" = "エージェント別"; +"按 API 地址填入该协议的常规地址" = "API アドレスからこのプロトコルの標準アドレスを入力します"; "按目录" = "ディレクトリ別"; +"按需转换" = "必要に応じて変換"; "把客户端模型名精确映射到上游模型;未命中时才使用自动映射。" = "クライアントのモデル名を上流モデルに正確にマッピングします。一致しない場合のみ自動マッピングを使います。"; "把客户端请求的模型名精确映射到上游模型,回包自动改回原名。带 [1m](1M 上下文)等特殊模型务必在此显式映射到支持的上游,避免被自动映射降级。未命中别名时才走「自动映射」。" = "クライアントが要求したモデル名を上流モデルに正確にマッピングし、レスポンスは自動で元の名前に戻します。[1m](1M コンテキスト)などの特殊モデルは自動ダウングレードを避けるため、ここで対応する上流に明示的にマッピングしてください。別名に一致しない場合のみ「自動マッピング」が動作します。"; "版本" = "バージョン"; @@ -480,6 +483,7 @@ "模型厂商" = "モデルベンダー"; "模型列表为空或无法解析" = "モデル一覧が空か、解析できませんでした"; "默认" = "標準"; +"默认地址" = "既定のアドレス"; "默认位置不可移除" = "既定の場所は削除できません"; "目标工具" = "対象ツール"; "目标位置已存在同名 Skill,覆盖后原内容将被替换:\n{paths}" = "対象には同名の Skill がすでにあります。上書きすると現在の内容が置き換えられます:\n{paths}"; @@ -565,6 +569,7 @@ "全部范围" = "すべてのスコープ"; "全部更新" = "すべて更新"; "全部会话" = "すべてのセッション"; +"全部直通" = "すべて直通"; "全部状态" = "すべてのステータス"; "全局" = "グローバル"; "全局安装" = "グローバルインストール"; @@ -583,6 +588,8 @@ "软删除的会话会出现在这里" = "論理削除したセッションがここに表示されます"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除旧值以免覆盖。" = "ターミナルで ANTHROPIC_BASE_URL を export したことがある場合は、上書きを避けるため古い値を削除してください。"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。" = "ターミナルで export ANTHROPIC_BASE_URL を実行した場合は、上書きを避けるため削除してください。"; +"三个协议都已绑定,全部直通" = "3つのプロトコルすべてを設定済みで、すべて直通します"; +"三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。" = "3つのプロトコルすべてを設定済みです。各クライアントは自分のアドレスを使い、変換は行われません。"; "筛选标签 {name}" = "タグ {name} で絞り込む"; "删除" = "削除"; "删除 Skill {name}" = "Skill {name} を削除"; @@ -742,6 +749,8 @@ "未安装 ChatGPT 桌面端" = "ChatGPTアプリがインストールされていません"; "未安装 Claude 桌面端" = "Claudeデスクトップがインストールされていません"; "未安装插件" = "プラグイン未インストール"; +"未绑定的协议会转换后发往「{protocol}」。" = "設定されていないプロトコルは変換されて「{protocol}」に送られます。"; +"未绑定的协议由 Bifrost 自动转换" = "設定されていないプロトコルは Bifrost が自動変換します"; "未标注模型" = "モデル未指定"; "未登录" = "未ログイン"; "未发现插件。把插件目录放到 ~/.ccbud/plugins// 后回到本页。" = "プラグインが見つかりません。プラグインフォルダを ~/.ccbud/plugins// に置いてから本ページに戻ってください。"; @@ -1054,8 +1063,10 @@ "直接搜索" = "直接検索"; "直连" = "直接"; "直通" = "パススルー"; +"只填服务商根地址,每个协议各自的地址在下面绑定。" = "サービス提供元のルートアドレスのみを入力します。各プロトコルのアドレスは下で設定します。"; "至少 {count} 处匹配" = "少なくとも {count} 件一致"; "至少 1 处匹配" = "少なくとも 1 件一致"; +"至少绑定一个协议的地址,网关才有上游可用。" = "ゲートウェイに上流が必要です。少なくとも1つのプロトコルのアドレスを設定してください。"; "智能排序" = "スマート並べ替え"; "置顶会话" = "セッションをピン留め"; "中心源副本会保留。" = "中央ソースのコピーは保持されます。"; diff --git a/native/Resources/ko.lproj/Localizable.strings b/native/Resources/ko.lproj/Localizable.strings index 0a744c34..a58d0155 100644 --- a/native/Resources/ko.lproj/Localizable.strings +++ b/native/Resources/ko.lproj/Localizable.strings @@ -27,6 +27,7 @@ "{n} 个会话无法建立索引,已显示其余会话" = "대화 {n}개의 색인을 생성하지 못했습니다. 나머지 대화는 표시됩니다"; "{n} 个失败" = "{n}개 실패"; "{n} 个文件" = "{n}개 파일"; +"{n} 个协议直通" = "{n}개 프로토콜 통과"; "{n} 个已存在" = "{n}개는 이미 있음"; "{n} 会话" = "세션 {n}개"; "{n} 天前" = "{n}일 전"; @@ -67,7 +68,9 @@ "安装到工具" = "설치할 도구"; "安装受支持的 coding agent 后刷新本页。" = "지원되는 coding agent를 설치한 후 이 페이지를 새로 고치세요."; "按 Agent" = "에이전트별"; +"按 API 地址填入该协议的常规地址" = "API 주소를 바탕으로 이 프로토콜의 일반 주소를 채웁니다"; "按目录" = "디렉터리별"; +"按需转换" = "필요할 때만 변환"; "把客户端模型名精确映射到上游模型;未命中时才使用自动映射。" = "클라이언트 모델명을 업스트림 모델에 정확히 매핑합니다. 일치하지 않을 때만 자동 매핑을 사용합니다."; "把客户端请求的模型名精确映射到上游模型,回包自动改回原名。带 [1m](1M 上下文)等特殊模型务必在此显式映射到支持的上游,避免被自动映射降级。未命中别名时才走「自动映射」。" = "클라이언트가 요청한 모델명을 업스트림 모델에 정확히 매핑하고, 응답은 자동으로 원래 이름으로 되돌립니다. [1m](1M 컨텍스트) 등 특수 모델은 자동 다운그레이드를 피하기 위해 여기서 지원되는 업스트림에 명시적으로 매핑하세요. 별칭에 맞지 않을 때만 “자동 매핑”이 동작합니다."; "版本" = "버전"; @@ -480,6 +483,7 @@ "模型厂商" = "모델 제공사"; "模型列表为空或无法解析" = "모델 목록이 비어 있거나 읽을 수 없습니다"; "默认" = "기본"; +"默认地址" = "기본 주소"; "默认位置不可移除" = "기본 위치는 제거할 수 없습니다"; "目标工具" = "대상 도구"; "目标位置已存在同名 Skill,覆盖后原内容将被替换:\n{paths}" = "대상에 같은 이름의 Skill이 이미 있습니다. 덮어쓰면 현재 내용이 교체됩니다:\n{paths}"; @@ -565,6 +569,7 @@ "全部范围" = "모든 범위"; "全部更新" = "모두 업데이트"; "全部会话" = "모든 세션"; +"全部直通" = "모두 통과"; "全部状态" = "모든 상태"; "全局" = "전역"; "全局安装" = "전역 설치"; @@ -583,6 +588,8 @@ "软删除的会话会出现在这里" = "삭제된 세션이 여기에 표시됩니다"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除旧值以免覆盖。" = "터미널에서 ANTHROPIC_BASE_URL을 export한 적이 있다면 이전 값이 덮어쓰지 않도록 삭제하세요."; "若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。" = "터미널에서 export ANTHROPIC_BASE_URL을 실행했다면 덮어쓰지 않도록 삭제하세요."; +"三个协议都已绑定,全部直通" = "세 프로토콜이 모두 지정되어 전부 통과합니다"; +"三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。" = "세 프로토콜이 모두 지정되어 각 클라이언트가 자기 주소를 사용하며 변환이 일어나지 않습니다."; "筛选标签 {name}" = "태그 {name}(으)로 필터링"; "删除" = "삭제"; "删除 Skill {name}" = "Skill {name} 삭제"; @@ -742,6 +749,8 @@ "未安装 ChatGPT 桌面端" = "ChatGPT 앱이 설치되어 있지 않습니다"; "未安装 Claude 桌面端" = "Claude 데스크톱이 설치되어 있지 않습니다"; "未安装插件" = "설치된 플러그인 없음"; +"未绑定的协议会转换后发往「{protocol}」。" = "지정하지 않은 프로토콜은 변환되어 “{protocol}”(으)로 전송됩니다."; +"未绑定的协议由 Bifrost 自动转换" = "지정하지 않은 프로토콜은 Bifrost가 자동 변환합니다"; "未标注模型" = "모델 미지정"; "未登录" = "로그인 안 됨"; "未发现插件。把插件目录放到 ~/.ccbud/plugins// 后回到本页。" = "플러그인을 찾을 수 없습니다. 플러그인 폴더를 ~/.ccbud/plugins//에 넣은 후 이 페이지로 돌아오세요."; @@ -1054,8 +1063,10 @@ "直接搜索" = "직접 검색"; "直连" = "직접"; "直通" = "패스스루"; +"只填服务商根地址,每个协议各自的地址在下面绑定。" = "공급자의 루트 주소만 입력하세요. 프로토콜별 주소는 아래에서 지정합니다."; "至少 {count} 处匹配" = "최소 {count}회 일치"; "至少 1 处匹配" = "최소 1회 일치"; +"至少绑定一个协议的地址,网关才有上游可用。" = "게이트웨이에 업스트림이 있으려면 프로토콜 주소를 하나 이상 지정하세요."; "智能排序" = "스마트 정렬"; "置顶会话" = "세션 고정"; "中心源副本会保留。" = "중앙 원본 사본은 유지됩니다."; diff --git a/native/Resources/zh-Hans.lproj/Localizable.strings b/native/Resources/zh-Hans.lproj/Localizable.strings index 3997936d..0839568f 100644 --- a/native/Resources/zh-Hans.lproj/Localizable.strings +++ b/native/Resources/zh-Hans.lproj/Localizable.strings @@ -27,6 +27,7 @@ "{n} 个会话无法建立索引,已显示其余会话" = "{n} 个会话无法建立索引,已显示其余会话"; "{n} 个失败" = "{n} 个失败"; "{n} 个文件" = "{n} 个文件"; +"{n} 个协议直通" = "{n} 个协议直通"; "{n} 个已存在" = "{n} 个已存在"; "{n} 会话" = "{n} 会话"; "{n} 天前" = "{n} 天前"; @@ -67,7 +68,9 @@ "安装到工具" = "安装到工具"; "安装受支持的 coding agent 后刷新本页。" = "安装受支持的 coding agent 后刷新本页。"; "按 Agent" = "按 Agent"; +"按 API 地址填入该协议的常规地址" = "按 API 地址填入该协议的常规地址"; "按目录" = "按目录"; +"按需转换" = "按需转换"; "把客户端模型名精确映射到上游模型;未命中时才使用自动映射。" = "把客户端模型名精确映射到上游模型;未命中时才使用自动映射。"; "把客户端请求的模型名精确映射到上游模型,回包自动改回原名。带 [1m](1M 上下文)等特殊模型务必在此显式映射到支持的上游,避免被自动映射降级。未命中别名时才走「自动映射」。" = "把客户端请求的模型名精确映射到上游模型,回包自动改回原名。带 [1m](1M 上下文)等特殊模型务必在此显式映射到支持的上游,避免被自动映射降级。未命中别名时才走「自动映射」。"; "版本" = "版本"; @@ -480,6 +483,7 @@ "模型厂商" = "模型厂商"; "模型列表为空或无法解析" = "模型列表为空或无法解析"; "默认" = "默认"; +"默认地址" = "默认地址"; "默认位置不可移除" = "默认位置不可移除"; "目标工具" = "目标工具"; "目标位置已存在同名 Skill,覆盖后原内容将被替换:\n{paths}" = "目标位置已存在同名 Skill,覆盖后原内容将被替换:\n{paths}"; @@ -565,6 +569,7 @@ "全部范围" = "全部范围"; "全部更新" = "全部更新"; "全部会话" = "全部会话"; +"全部直通" = "全部直通"; "全部状态" = "全部状态"; "全局" = "全局"; "全局安装" = "全局安装"; @@ -583,6 +588,8 @@ "软删除的会话会出现在这里" = "软删除的会话会出现在这里"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除旧值以免覆盖。" = "若曾在终端 export ANTHROPIC_BASE_URL,请删除旧值以免覆盖。"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。" = "若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。"; +"三个协议都已绑定,全部直通" = "三个协议都已绑定,全部直通"; +"三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。" = "三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。"; "筛选标签 {name}" = "筛选标签 {name}"; "删除" = "删除"; "删除 Skill {name}" = "删除 Skill {name}"; @@ -742,6 +749,8 @@ "未安装 ChatGPT 桌面端" = "未安装 ChatGPT 桌面端"; "未安装 Claude 桌面端" = "未安装 Claude 桌面端"; "未安装插件" = "未安装插件"; +"未绑定的协议会转换后发往「{protocol}」。" = "未绑定的协议会转换后发往「{protocol}」。"; +"未绑定的协议由 Bifrost 自动转换" = "未绑定的协议由 Bifrost 自动转换"; "未标注模型" = "未标注模型"; "未登录" = "未登录"; "未发现插件。把插件目录放到 ~/.ccbud/plugins// 后回到本页。" = "未发现插件。把插件目录放到 ~/.ccbud/plugins// 后回到本页。"; @@ -1054,8 +1063,10 @@ "直接搜索" = "直接搜索"; "直连" = "直连"; "直通" = "直通"; +"只填服务商根地址,每个协议各自的地址在下面绑定。" = "只填服务商根地址,每个协议各自的地址在下面绑定。"; "至少 {count} 处匹配" = "至少 {count} 处匹配"; "至少 1 处匹配" = "至少 1 处匹配"; +"至少绑定一个协议的地址,网关才有上游可用。" = "至少绑定一个协议的地址,网关才有上游可用。"; "智能排序" = "智能排序"; "置顶会话" = "置顶会话"; "中心源副本会保留。" = "中心源副本会保留。"; diff --git a/native/Resources/zh-Hant.lproj/Localizable.strings b/native/Resources/zh-Hant.lproj/Localizable.strings index e72d245e..f314961d 100644 --- a/native/Resources/zh-Hant.lproj/Localizable.strings +++ b/native/Resources/zh-Hant.lproj/Localizable.strings @@ -27,6 +27,7 @@ "{n} 个会话无法建立索引,已显示其余会话" = "{n} 個會話無法建立索引,已顯示其餘會話"; "{n} 个失败" = "{n} 個失敗"; "{n} 个文件" = "{n} 個檔案"; +"{n} 个协议直通" = "{n} 個協定直通"; "{n} 个已存在" = "{n} 個已存在"; "{n} 会话" = "{n} 工作階段"; "{n} 天前" = "{n} 天前"; @@ -67,7 +68,9 @@ "安装到工具" = "安裝至工具"; "安装受支持的 coding agent 后刷新本页。" = "安裝支援的 coding agent 後重新整理此頁面。"; "按 Agent" = "依 Agent"; +"按 API 地址填入该协议的常规地址" = "按 API 位址填入該協定的常規位址"; "按目录" = "依目錄"; +"按需转换" = "按需轉換"; "把客户端模型名精确映射到上游模型;未命中时才使用自动映射。" = "將用戶端模型名稱精確對應到上游模型;未命中時才使用自動對應。"; "把客户端请求的模型名精确映射到上游模型,回包自动改回原名。带 [1m](1M 上下文)等特殊模型务必在此显式映射到支持的上游,避免被自动映射降级。未命中别名时才走「自动映射」。" = "把用戶端請求的模型名稱精確對應到上游模型,回應自動改回原名。帶 [1m](1M 上下文)等特殊模型務必在此明確對應到支援的上游,避免被自動對應降級。未命中別名時才走「自動對應」。"; "版本" = "版本"; @@ -480,6 +483,7 @@ "模型厂商" = "模型廠商"; "模型列表为空或无法解析" = "模型清單為空或無法解析"; "默认" = "預設"; +"默认地址" = "預設位址"; "默认位置不可移除" = "預設位置無法移除"; "目标工具" = "目標工具"; "目标位置已存在同名 Skill,覆盖后原内容将被替换:\n{paths}" = "目標位置已存在同名 Skill,覆蓋後原內容將被取代:\n{paths}"; @@ -565,6 +569,7 @@ "全部范围" = "所有範圍"; "全部更新" = "全部更新"; "全部会话" = "所有會話"; +"全部直通" = "全部直通"; "全部状态" = "所有狀態"; "全局" = "全域"; "全局安装" = "全域安裝"; @@ -583,6 +588,8 @@ "软删除的会话会出现在这里" = "軟刪除的會話會顯示在這裡"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除旧值以免覆盖。" = "若曾在終端機 export ANTHROPIC_BASE_URL,請刪除舊值以免覆蓋。"; "若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。" = "若曾在終端機 export ANTHROPIC_BASE_URL,請刪除以免覆蓋。"; +"三个协议都已绑定,全部直通" = "三個協定都已綁定,全部直通"; +"三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。" = "三個協定都已綁定:每個用戶端都走自己的位址,不做任何轉換。"; "筛选标签 {name}" = "篩選標籤 {name}"; "删除" = "刪除"; "删除 Skill {name}" = "刪除 Skill {name}"; @@ -742,6 +749,8 @@ "未安装 ChatGPT 桌面端" = "未安裝 ChatGPT 桌面端"; "未安装 Claude 桌面端" = "未安裝 Claude 桌面端"; "未安装插件" = "未安裝外掛"; +"未绑定的协议会转换后发往「{protocol}」。" = "未綁定的協定會轉換後發往「{protocol}」。"; +"未绑定的协议由 Bifrost 自动转换" = "未綁定的協定由 Bifrost 自動轉換"; "未标注模型" = "未標示模型"; "未登录" = "未登入"; "未发现插件。把插件目录放到 ~/.ccbud/plugins// 后回到本页。" = "未發現外掛。將外掛目錄放到 ~/.ccbud/plugins// 後回到本頁。"; @@ -1054,8 +1063,10 @@ "直接搜索" = "直接搜尋"; "直连" = "直連"; "直通" = "直通"; +"只填服务商根地址,每个协议各自的地址在下面绑定。" = "只填服務商根位址,每個協定各自的位址在下面綁定。"; "至少 {count} 处匹配" = "至少 {count} 處符合"; "至少 1 处匹配" = "至少 1 處符合"; +"至少绑定一个协议的地址,网关才有上游可用。" = "至少綁定一個協定的位址,閘道才有上游可用。"; "智能排序" = "智慧排序"; "置顶会话" = "置頂工作階段"; "中心源副本会保留。" = "中央來源副本會保留。"; diff --git a/native/Scripts/native-localization-overrides.json b/native/Scripts/native-localization-overrides.json index ec9db074..ed29296b 100644 --- a/native/Scripts/native-localization-overrides.json +++ b/native/Scripts/native-localization-overrides.json @@ -4828,5 +4828,82 @@ "ko": "켜면 큐가 게이트웨이의 업스트림 집합이 됩니다. 클라이언트가 사용하는 프로토콜이 큐에 있으면 해당 공급자로 그대로 전달하고, 없으면 변환해 큐의 첫 번째로 보냅니다. 업스트림이 실패하면 큐 순서대로 다음으로 전환합니다. 끄면 활성화된 공급자만 사용합니다.", "zh-Hans": "开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。", "zh-Hant": "開啟後佇列即是閘道的上游集合:用戶端請求的協定如果佇列裡有,就直接透傳給該供應商;沒有就轉換協定後交給佇列首位。任一上游失敗時按佇列順序自動改用下一個。關閉時只使用目前啟用的供應商。" + }, + "只填服务商根地址,每个协议各自的地址在下面绑定。": { + "en": "Enter the provider's root address only; each protocol's own address is bound below.", + "ja": "サービス提供元のルートアドレスのみを入力します。各プロトコルのアドレスは下で設定します。", + "ko": "공급자의 루트 주소만 입력하세요. 프로토콜별 주소는 아래에서 지정합니다.", + "zh-Hans": "只填服务商根地址,每个协议各自的地址在下面绑定。", + "zh-Hant": "只填服務商根位址,每個協定各自的位址在下面綁定。" + }, + "全部直通": { + "en": "All pass through", + "ja": "すべて直通", + "ko": "모두 통과", + "zh-Hans": "全部直通", + "zh-Hant": "全部直通" + }, + "按需转换": { + "en": "Converts as needed", + "ja": "必要に応じて変換", + "ko": "필요할 때만 변환", + "zh-Hans": "按需转换", + "zh-Hant": "按需轉換" + }, + "默认地址": { + "en": "Default address", + "ja": "既定のアドレス", + "ko": "기본 주소", + "zh-Hans": "默认地址", + "zh-Hant": "預設位址" + }, + "按 API 地址填入该协议的常规地址": { + "en": "Fill in this protocol's conventional address from the API URL", + "ja": "API アドレスからこのプロトコルの標準アドレスを入力します", + "ko": "API 주소를 바탕으로 이 프로토콜의 일반 주소를 채웁니다", + "zh-Hans": "按 API 地址填入该协议的常规地址", + "zh-Hant": "按 API 位址填入該協定的常規位址" + }, + "至少绑定一个协议的地址,网关才有上游可用。": { + "en": "Bind at least one protocol's address so the gateway has an upstream.", + "ja": "ゲートウェイに上流が必要です。少なくとも1つのプロトコルのアドレスを設定してください。", + "ko": "게이트웨이에 업스트림이 있으려면 프로토콜 주소를 하나 이상 지정하세요.", + "zh-Hans": "至少绑定一个协议的地址,网关才有上游可用。", + "zh-Hant": "至少綁定一個協定的位址,閘道才有上游可用。" + }, + "三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。": { + "en": "All three protocols are bound: every client uses its own address and nothing is converted.", + "ja": "3つのプロトコルすべてを設定済みです。各クライアントは自分のアドレスを使い、変換は行われません。", + "ko": "세 프로토콜이 모두 지정되어 각 클라이언트가 자기 주소를 사용하며 변환이 일어나지 않습니다.", + "zh-Hans": "三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。", + "zh-Hant": "三個協定都已綁定:每個用戶端都走自己的位址,不做任何轉換。" + }, + "未绑定的协议会转换后发往「{protocol}」。": { + "en": "Unbound protocols are converted and sent to “{protocol}”.", + "ja": "設定されていないプロトコルは変換されて「{protocol}」に送られます。", + "ko": "지정하지 않은 프로토콜은 변환되어 “{protocol}”(으)로 전송됩니다.", + "zh-Hans": "未绑定的协议会转换后发往「{protocol}」。", + "zh-Hant": "未綁定的協定會轉換後發往「{protocol}」。" + }, + "未绑定的协议由 Bifrost 自动转换": { + "en": "Unbound protocols are converted by Bifrost", + "ja": "設定されていないプロトコルは Bifrost が自動変換します", + "ko": "지정하지 않은 프로토콜은 Bifrost가 자동 변환합니다", + "zh-Hans": "未绑定的协议由 Bifrost 自动转换", + "zh-Hant": "未綁定的協定由 Bifrost 自動轉換" + }, + "三个协议都已绑定,全部直通": { + "en": "All three protocols are bound; everything passes through", + "ja": "3つのプロトコルすべてを設定済みで、すべて直通します", + "ko": "세 프로토콜이 모두 지정되어 전부 통과합니다", + "zh-Hans": "三个协议都已绑定,全部直通", + "zh-Hant": "三個協定都已綁定,全部直通" + }, + "{n} 个协议直通": { + "en": "{n} protocols pass through", + "ja": "{n}個のプロトコルが直通", + "ko": "{n}개 프로토콜 통과", + "zh-Hans": "{n} 个协议直通", + "zh-Hant": "{n} 個協定直通" } } diff --git a/native/Sources/Gateway/BifrostConfiguration.swift b/native/Sources/Gateway/BifrostConfiguration.swift index 46b72b07..96c4fe11 100644 --- a/native/Sources/Gateway/BifrostConfiguration.swift +++ b/native/Sources/Gateway/BifrostConfiguration.swift @@ -325,9 +325,15 @@ enum BifrostConfigBuilder { static let codexPrimaryAliases = ["gpt-5.4", "gpt-5.5-ccbud"] static let codexFastAliases = ["gpt-5.4-mini"] + /// One Bifrost provider entry: a configured provider, one of the protocols it binds, and the + /// address that serves it. A provider that publishes two or three of its own endpoints + /// produces two or three of these, because a Bifrost provider carries exactly one base URL + /// and one wire format. struct RoutedProvider: Equatable { let provider: Provider let bifrostName: String + let wireProtocol: Provider.WireProtocol + let upstreamURL: String } enum BuildError: LocalizedError, Equatable { @@ -356,17 +362,36 @@ enum BifrostConfigBuilder { /// same rule in one place so configuration generation and the Swift request rewriter cannot /// disagree about provider identities. static func routedProviders(from config: AppConfig) -> [RoutedProvider] { - let providers = config.gatewayProviders - guard providers.count > 1 else { - return providers.map { .init(provider: $0, bifrostName: providerName) } + // Queue order across providers, primary protocol first within each, so the head of the + // result stays the upstream that takes every caller nothing else matches. + typealias Upstream = (provider: Provider, wireProtocol: Provider.WireProtocol, url: String) + var upstreams: [Upstream] = [] + for provider in config.gatewayProviders { + for wireProtocol in provider.configuredProtocols { + guard let url = provider.upstreamURL(for: wireProtocol) else { continue } + upstreams.append((provider: provider, wireProtocol: wireProtocol, url: url)) + } + } + guard upstreams.count > 1 else { + return upstreams.map { + .init( + provider: $0.provider, bifrostName: providerName, + wireProtocol: $0.wireProtocol, upstreamURL: $0.url + ) + } } - return providers.enumerated().map { index, provider in - let digest = SHA256.hash(data: Data(provider.id.utf8)).prefix(5).map { + return upstreams.enumerated().map { index, upstream in + // The digest names the address, not just the provider: one provider's Anthropic and + // Chat entries are separate Bifrost providers and must not collide. + let identity = "\(upstream.provider.id)\u{0}\(upstream.wireProtocol.rawValue)" + let digest = SHA256.hash(data: Data(identity.utf8)).prefix(5).map { String(format: "%02x", $0) }.joined() return .init( - provider: provider, - bifrostName: "ccbud-\(index + 1)-\(digest)" + provider: upstream.provider, + bifrostName: "ccbud-\(index + 1)-\(digest)", + wireProtocol: upstream.wireProtocol, + upstreamURL: upstream.url ) } } @@ -380,7 +405,7 @@ enum BifrostConfigBuilder { var document: [String: ModelParametersEntry] = [:] for route in routedProviders { let provider = route.provider - let entry = modelParametersEntry(for: provider.protocol) + let entry = modelParametersEntry(for: route.wireProtocol) for model in modelCatalogModels(for: provider) { // Bifrost normally resolves the provider prefix before consulting its model // catalog. Retaining both forms also keeps this file useful to the compatibility @@ -500,16 +525,22 @@ enum BifrostConfigBuilder { } private static func providerConfiguration( - for provider: Provider, + for route: RoutedProvider, config: AppConfig, retryCount: Int, retryInitial: Int, retryBackoffMax: Int ) throws -> BifrostConfiguration.ProviderConfig { - guard !provider.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw BifrostError.invalidBaseURL - } - let baseType = provider.protocol == .anthropic ? "anthropic" : "openai" + // Bifrost owns the whole upstream path and inserts its own `/v1` segment. Every provider + // preset, and every address a provider's own documentation gives, already ends where its + // version segment begins, so handing one over untouched produced `…/v1/v1/messages` and a + // 404 from every upstream. `GatewayUpstreamURL` reconciles the two. + let upstream = GatewayUpstreamURL.upstream( + for: route.wireProtocol, url: route.upstreamURL + ) + guard !upstream.baseURL.isEmpty else { throw BifrostError.invalidBaseURL } + let provider = route.provider + let baseType = route.wireProtocol == .anthropic ? "anthropic" : "openai" let key = BifrostConfiguration.Key( name: "CC Buddy \(provider.name)", value: provider.authToken.isEmpty ? nil : provider.authToken, @@ -517,17 +548,10 @@ enum BifrostConfigBuilder { models: ["*"], aliases: aliases(for: provider) ) - // Bifrost owns the whole upstream path and inserts its own `/v1` segment. Every provider - // preset, and every base URL a provider's own documentation gives, already ends where its - // version segment begins, so handing one over untouched produced `…/v1/v1/messages` and a - // 404 from every upstream. `GatewayUpstreamURL` reconciles the two. - let overrides = GatewayUpstreamURL.requestPathOverrides( - for: provider.protocol, baseURL: provider.baseUrl - ) return .init( keys: [key], networkConfig: .init( - baseURL: GatewayUpstreamURL.bifrostBaseURL(for: provider.baseUrl), + baseURL: upstream.baseURL, insecureSkipVerify: config.insecureSkipVerify, maxRetries: retryCount, retryBackoffInitial: retryInitial, @@ -535,8 +559,9 @@ enum BifrostConfigBuilder { ), customProviderConfig: .init( baseProviderType: baseType, - allowedRequests: allowedRequests(for: provider.protocol), - requestPathOverrides: overrides.isEmpty ? nil : overrides + allowedRequests: allowedRequests(for: route.wireProtocol), + requestPathOverrides: upstream.requestPathOverrides.isEmpty + ? nil : upstream.requestPathOverrides ), // Raw bodies stay local and power the native monitor inspector. storeRawRequestResponse: true @@ -560,7 +585,7 @@ enum BifrostConfigBuilder { var providerConfigs: [String: BifrostConfiguration.ProviderConfig] = [:] for route in routedProviders { providerConfigs[route.bifrostName] = try providerConfiguration( - for: route.provider, + for: route, config: config, retryCount: retryCount, retryInitial: retryInitial, @@ -608,7 +633,7 @@ enum BifrostConfigBuilder { // conversion for any provider whose models were not enumerated in advance. compat: .init( convertChatToResponses: routedProviders.contains { - $0.provider.protocol == .openAIResponses + $0.wireProtocol == .openAIResponses } ) ), diff --git a/native/Sources/Gateway/BifrostSupervisor.swift b/native/Sources/Gateway/BifrostSupervisor.swift index 98f09328..38bfb7e9 100644 --- a/native/Sources/Gateway/BifrostSupervisor.swift +++ b/native/Sources/Gateway/BifrostSupervisor.swift @@ -1173,13 +1173,19 @@ struct LegacyModelRoutingCompatibility: Sendable { /// the queue still takes over when it fails. Each fallback is mapped through *its own* /// provider's aliases and default models, because a model name that means something on one /// upstream frequently means nothing on the next. + /// + /// Failover is between *providers*. A provider that binds two or three addresses contributes + /// that many routes, and retrying the same provider on a second protocol after it failed on + /// the first buys nothing — so only its first remaining route stands in for it here. private func fallbackModels( for requestedModel: String, excluding pinned: GatewayUpstreamRoute ) -> [String] { guard router.pinsProviderName else { return [] } + var covered: Set = [pinned.provider.id] return router.routes.compactMap { route in - guard route.bifrostName != pinned.bifrostName else { return nil } + guard route.bifrostName != pinned.bifrostName, + covered.insert(route.provider.id).inserted else { return nil } let mapped = map(requestedModel, for: route.provider) let bare = mapped.usesNativeAlias ? requestedModel : mapped.model guard !bare.isEmpty else { return nil } diff --git a/native/Sources/Gateway/GatewayProtocolRouting.swift b/native/Sources/Gateway/GatewayProtocolRouting.swift index 86a70528..4454c3f4 100644 --- a/native/Sources/Gateway/GatewayProtocolRouting.swift +++ b/native/Sources/Gateway/GatewayProtocolRouting.swift @@ -61,21 +61,29 @@ enum GatewayClientProtocol: String, Equatable, Sendable, CaseIterable { struct GatewayUpstreamRoute: Equatable, Sendable { let bifrostName: String let provider: Provider - - var wireProtocol: Provider.WireProtocol { provider.protocol } - - init(bifrostName: String, provider: Provider) { + /// The protocol this route's address speaks. A provider binds up to three addresses and + /// contributes one route per bound protocol, so this is not simply `provider.protocol` — + /// that one names only the route which also takes the callers nothing else matches. + let wireProtocol: Provider.WireProtocol + + init( + bifrostName: String, + provider: Provider, + wireProtocol: Provider.WireProtocol? = nil + ) { self.bifrostName = bifrostName self.provider = provider + self.wireProtocol = wireProtocol ?? provider.primaryProtocol } } /// Decides which configured upstream serves a caller, and whether Bifrost has to convert. /// -/// The rule the gateway promises: +/// The rule the gateway promises, over the addresses configured across every routed provider: /// /// * **Three of three.** Every caller protocol has an upstream that speaks it, so every request -/// is handed to that upstream untouched. Nothing is converted. +/// is handed to that upstream untouched. Nothing is converted. One provider that publishes all +/// three of its own endpoints reaches this on its own. /// * **One or two of three.** A caller whose protocol *is* configured still passes through. A /// caller whose protocol is not configured is routed to the primary upstream (the head of the /// failover queue, or the active provider when no queue is enabled) and Bifrost performs the @@ -95,7 +103,11 @@ struct GatewayProtocolRouter: Equatable, Sendable { init(config: AppConfig) { routes = BifrostConfigBuilder.routedProviders(from: config).map { - GatewayUpstreamRoute(bifrostName: $0.bifrostName, provider: $0.provider) + GatewayUpstreamRoute( + bifrostName: $0.bifrostName, + provider: $0.provider, + wireProtocol: $0.wireProtocol + ) } } diff --git a/native/Sources/Gateway/GatewayUpstreamURL.swift b/native/Sources/Gateway/GatewayUpstreamURL.swift index 7a697b98..9878ce04 100644 --- a/native/Sources/Gateway/GatewayUpstreamURL.swift +++ b/native/Sources/Gateway/GatewayUpstreamURL.swift @@ -54,17 +54,16 @@ enum GatewayUpstreamURL { return shortened.hasSuffix(":/") || shortened.isEmpty ? trimmed : shortened } - /// Paths Bifrost should use verbatim instead of prefixing its own version segment. + /// Every operation path a protocol uses, relative to a base that already carries whatever + /// version segment the upstream expects. /// /// Only endpoints with a fixed path are listed. The Responses lifecycle routes embed a /// response id in the path, so a static override would replace the id along with the prefix; - /// they keep Bifrost's default, which is already correct for every base this returns an empty - /// map for. - static func requestPathOverrides( - for wireProtocol: Provider.WireProtocol, - baseURL: String + /// they keep Bifrost's default `/v1/responses/`, which is correct for every base that + /// gets no override at all and for every pinned endpoint that carried a `/v1` of its own. + private static func relativePaths( + for wireProtocol: Provider.WireProtocol ) -> [String: String] { - guard versioning(of: baseURL) == .foreign else { return [:] } switch wireProtocol { case .anthropic: return [ @@ -88,6 +87,15 @@ enum GatewayUpstreamURL { } } + /// Paths Bifrost should use verbatim instead of prefixing its own version segment. + static func requestPathOverrides( + for wireProtocol: Provider.WireProtocol, + baseURL: String + ) -> [String: String] { + guard versioning(of: baseURL) == .foreign else { return [:] } + return relativePaths(for: wireProtocol) + } + /// The inference path for a protocol, relative to a base URL that already carries its version. static func inferencePath(for wireProtocol: Provider.WireProtocol) -> String { switch wireProtocol { @@ -118,6 +126,84 @@ enum GatewayUpstreamURL { } } + /// The spellings of a protocol's own endpoint a user might type into its address field, + /// longest first so `…/anthropic/v1/messages` is not mistaken for `…/anthropic/v1` + `/messages`. + private static func endpointSuffixes(for wireProtocol: Provider.WireProtocol) -> [String] { + let path = inferencePath(for: wireProtocol) + return ["/v1" + path, path] + } + + /// One configured upstream, resolved into everything the gateway and the connection test need. + struct Upstream: Equatable, Sendable { + /// What goes into Bifrost's `network_config.base_url`. + var baseURL: String + /// Paths Bifrost must use verbatim. Empty when its own `/v1/...` paths already land. + var requestPathOverrides: [String: String] + /// The absolute URL an inference request reaches, or nil when the address is unusable. + var inferenceURL: URL? + + static let unusable = Upstream(baseURL: "", requestPathOverrides: [:], inferenceURL: nil) + } + + /// Resolves the address bound to one protocol. + /// + /// The field takes either spelling people already have in hand: the base their client is + /// pointed at (`https://api.deepseek.com/anthropic`) or the endpoint their client posts to + /// (`https://api.deepseek.com/chat/completions`). An endpoint spelling is taken literally and + /// its exact path pinned, because a provider that publishes `/chat/completions` need not also + /// answer `/v1/chat/completions` — letting Bifrost insert a version segment there would 404 + /// an address the user copied from the vendor's own documentation. A base spelling keeps the + /// original reconciliation, which is what every provider saved before this still relies on. + static func upstream(for wireProtocol: Provider.WireProtocol, url: String) -> Upstream { + let trimmed = trimmedBase(url) + guard !trimmed.isEmpty else { return .unusable } + if let suffix = endpointSuffixes(for: wireProtocol).first(where: { trimmed.hasSuffix($0) }) { + let base = String(trimmed.dropLast(suffix.count)) + // "https://host/v1/messages" must not shorten to "https:/" or to nothing at all. + if !base.isEmpty && !base.hasSuffix(":/") { + let versionPrefix = suffix.hasPrefix("/v1/") ? "/v1" : "" + return Upstream( + baseURL: base, + requestPathOverrides: relativePaths(for: wireProtocol) + .mapValues { versionPrefix + $0 }, + inferenceURL: URL(string: base + suffix) + ) + } + } + return Upstream( + baseURL: bifrostBaseURL(for: trimmed), + requestPathOverrides: requestPathOverrides(for: wireProtocol, baseURL: trimmed), + inferenceURL: endpointURL(baseURL: trimmed, wireProtocol: wireProtocol) + ) + } + + /// The address to offer for a protocol the user has not bound yet, given the provider's base. + /// + /// Only a genuinely bare base can be extended this way. One that already carries a version + /// segment, or that already ends in some endpoint's own path, has been pointed at a specific + /// API, and guessing a sibling path off it produces nonsense like + /// `…/api/anthropic/v1/chat/completions` — so nothing is offered and the field stays empty. + static func derivedURL(for wireProtocol: Provider.WireProtocol, base: String) -> String? { + let trimmed = trimmedBase(base) + guard !trimmed.isEmpty, + let components = URLComponents(string: trimmed), + ["http", "https"].contains(components.scheme?.lowercased() ?? ""), + components.host?.isEmpty == false, + versioning(of: trimmed) == Versioning.none else { return nil } + for candidate in Provider.WireProtocol.allCases { + guard !endpointSuffixes(for: candidate).contains(where: { trimmed.hasSuffix($0) }) + else { return nil } + } + switch wireProtocol { + case .anthropic: + return trimmed.hasSuffix("/anthropic") ? trimmed : trimmed + "/anthropic" + case .openAIChat: + return trimmed + "/chat/completions" + case .openAIResponses: + return trimmed + "/responses" + } + } + private static func trimmedBase(_ baseURL: String) -> String { var value = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) while value.count > 1 && value.hasSuffix("/") { value.removeLast() } diff --git a/native/Sources/Models/AppConfig.swift b/native/Sources/Models/AppConfig.swift index 9bb7814e..b525ac89 100644 --- a/native/Sources/Models/AppConfig.swift +++ b/native/Sources/Models/AppConfig.swift @@ -80,6 +80,22 @@ struct AppConfig: Codable, Equatable { item.name = item.name.trimmingCharacters(in: .whitespacesAndNewlines) if item.name.isEmpty { item.name = "Unnamed" } item.models = item.models.filter { !$0.alias.isEmpty || !$0.upstream.isEmpty } + // A blank per-protocol address is the user saying "this provider does not speak + // that protocol", so it is removed rather than stored as an upstream with no host. + var protocolUrls: [String: String] = [:] + for (key, value) in item.protocolUrls { + guard Provider.WireProtocol(rawValue: key) != nil else { continue } + var url = value.trimmingCharacters(in: .whitespacesAndNewlines) + while url.count > 1 && url.hasSuffix("/") { url.removeLast() } + if !url.isEmpty { protocolUrls[key] = url } + } + item.protocolUrls = protocolUrls + if !protocolUrls.isEmpty, !item.configuredProtocols.contains(item.protocol), + let primary = item.configuredProtocols.first { + // `protocol` takes every caller the provider has no matching upstream for, so it + // must name one of the addresses that survived. + item.protocol = primary + } if item.protocol == .anthropic, item.baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == "https://open.bigmodel.cn/api/anthropic" { diff --git a/native/Sources/Models/Provider.swift b/native/Sources/Models/Provider.swift index 7748eddd..d6971f84 100644 --- a/native/Sources/Models/Provider.swift +++ b/native/Sources/Models/Provider.swift @@ -42,7 +42,21 @@ struct Provider: Codable, Hashable, Identifiable { var id: String var name: String + /// The root the provider is reached at, with nothing protocol-specific on it: + /// `https://api.deepseek.com`, not `https://api.deepseek.com/anthropic/v1`. + /// + /// It is the seed the editor derives per-protocol addresses from and the root model + /// discovery falls back to. `protocolUrls` is what the gateway actually calls; this stays + /// authoritative only for a provider saved before per-protocol addresses existed. var baseUrl: String + /// One upstream URL per wire protocol this provider speaks, keyed by `WireProtocol`. + /// + /// A provider binds up to three: Anthropic Messages, OpenAI Chat Completions and OpenAI + /// Responses. Whatever is present is served to a client speaking that protocol untouched; + /// whatever is absent is converted onto `protocol`. Plenty of vendors publish all three, and + /// converting for them would be work done for nothing — so the conversion follows what the + /// user configured rather than being wired in advance. + var protocolUrls: [String: String] var authToken: String var defaultModel: String var smallFastModel: String @@ -55,12 +69,14 @@ struct Provider: Codable, Hashable, Identifiable { init( id: String = UUID().uuidString.lowercased(), name: String = "", - baseUrl: String = "", authToken: String = "", defaultModel: String = "", + baseUrl: String = "", protocolUrls: [String: String] = [:], + authToken: String = "", defaultModel: String = "", smallFastModel: String = "", mapDefaultModels: Bool = true, protocol: WireProtocol = .anthropic, models: [ModelMapping] = [], icon: String? = nil, backend: Backend = .http, pluginId: String? = nil ) { self.id = id; self.name = name; self.baseUrl = baseUrl + self.protocolUrls = protocolUrls self.authToken = authToken; self.defaultModel = defaultModel self.smallFastModel = smallFastModel; self.mapDefaultModels = mapDefaultModels self.protocol = `protocol`; self.models = models; self.icon = icon @@ -68,7 +84,7 @@ struct Provider: Codable, Hashable, Identifiable { } private enum CodingKeys: String, CodingKey { - case id, name, baseUrl, authToken, defaultModel, smallFastModel + case id, name, baseUrl, protocolUrls, authToken, defaultModel, smallFastModel case mapDefaultModels, `protocol`, models, icon, backend, pluginId } @@ -77,6 +93,7 @@ struct Provider: Codable, Hashable, Identifiable { id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString.lowercased() name = try c.decodeIfPresent(String.self, forKey: .name) ?? "Unnamed" baseUrl = try c.decodeIfPresent(String.self, forKey: .baseUrl) ?? "" + protocolUrls = try c.decodeIfPresent([String: String].self, forKey: .protocolUrls) ?? [:] authToken = try c.decodeIfPresent(String.self, forKey: .authToken) ?? "" defaultModel = try c.decodeIfPresent(String.self, forKey: .defaultModel) ?? "" smallFastModel = try c.decodeIfPresent(String.self, forKey: .smallFastModel) ?? "" @@ -90,3 +107,54 @@ struct Provider: Codable, Hashable, Identifiable { pluginId = try c.decodeIfPresent(String.self, forKey: .pluginId) } } + +extension Provider { + /// Every upstream URL this provider binds, by the protocol that URL speaks. + /// + /// A provider saved before per-protocol addresses existed, or one whose editor was opened and + /// left alone, binds nothing here — those fall back to `baseUrl` under `protocol`, which is + /// exactly the single upstream they have always had. + var configuredUpstreamURLs: [WireProtocol: String] { + var result: [WireProtocol: String] = [:] + for wireProtocol in WireProtocol.allCases { + let url = protocolUrls[wireProtocol.rawValue]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !url.isEmpty { result[wireProtocol] = url } + } + guard result.isEmpty else { return result } + let base = baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) + return base.isEmpty ? [:] : [`protocol`: base] + } + + /// The URL the gateway calls for one protocol, or nil when this provider does not speak it — + /// which is what makes conversion for that caller necessary. + func upstreamURL(for wireProtocol: WireProtocol) -> String? { + configuredUpstreamURLs[wireProtocol] + } + + /// The protocols this provider speaks, primary first. + /// + /// `protocol` names the primary: the upstream that takes callers whose own protocol this + /// provider does not bind. It is kept here rather than trusted blindly, because a URL the + /// user cleared must not go on collecting the traffic nothing else claims. + var configuredProtocols: [WireProtocol] { + let configured = configuredUpstreamURLs + let ordered = WireProtocol.allCases.filter { configured[$0] != nil } + let candidate: WireProtocol? = ordered.contains(`protocol`) ? `protocol` : ordered.first + guard let primary = candidate else { return [] } + return [primary] + ordered.filter { $0 != primary } + } + + var primaryProtocol: WireProtocol { configuredProtocols.first ?? `protocol` } + + /// The address to show when there is room for one: the primary protocol's. + var primaryUpstreamURL: String { + upstreamURL(for: primaryProtocol) + ?? baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// True once every caller shape has an upstream of its own, so the gateway converts nothing. + var servesEveryProtocolDirectly: Bool { + configuredProtocols.count == WireProtocol.allCases.count + } +} diff --git a/native/Sources/Services/ProviderModelDiscoveryService.swift b/native/Sources/Services/ProviderModelDiscoveryService.swift index 3f4e8244..cff3447c 100644 --- a/native/Sources/Services/ProviderModelDiscoveryService.swift +++ b/native/Sources/Services/ProviderModelDiscoveryService.swift @@ -45,13 +45,15 @@ struct ProviderModelDiscoveryService: Sendable { func discover( _ provider: Provider, + wireProtocol: Provider.WireProtocol? = nil, insecureSkipVerify: Bool = false ) async -> ProviderModelCatalog { - let baseURL = provider.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard !baseURL.isEmpty else { + let wire = wireProtocol ?? provider.primaryProtocol + let roots = Self.listingRoots(for: provider, wireProtocol: wire) + guard !roots.isEmpty else { return .init(availability: .unknown, message: "服务地址为空") } - let candidates = Self.candidateURLs(baseURL: baseURL) + let candidates = roots.flatMap { Self.candidateURLs(baseURL: $0) } guard !candidates.isEmpty else { return .init(availability: .unknown, message: "服务地址无效") } @@ -61,9 +63,12 @@ struct ProviderModelDiscoveryService: Sendable { var lastStatus: Int? var lastMessage: String? - for url in candidates { + var visited = Set() + for url in candidates where visited.insert(url.absoluteString).inserted { do { - let response = try await send(to: url, provider: provider, session: session) + let response = try await send( + to: url, provider: provider, wireProtocol: wire, session: session + ) lastStatus = response.statusCode if (200..<300).contains(response.statusCode) { let models = Self.parseModels(from: response.data) @@ -133,6 +138,7 @@ struct ProviderModelDiscoveryService: Sendable { private func send( to url: URL, provider: Provider, + wireProtocol: Provider.WireProtocol, session: URLSession ) async throws -> (statusCode: Int, data: Data) { var request = URLRequest(url: url, timeoutInterval: 20) @@ -144,11 +150,11 @@ struct ProviderModelDiscoveryService: Sendable { let token = provider.authToken.trimmingCharacters(in: .whitespacesAndNewlines) if !token.isEmpty { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - if provider.protocol == .anthropic { + if wireProtocol == .anthropic { request.setValue(token, forHTTPHeaderField: "x-api-key") } } - if provider.protocol == .anthropic { + if wireProtocol == .anthropic { request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") } let (data, response) = try await session.data(for: request) @@ -158,6 +164,26 @@ struct ProviderModelDiscoveryService: Sendable { return (response.statusCode, data) } + /// Where to look for a catalog, most specific first. + /// + /// The protocol's own address is asked first because that is the API whose models the user is + /// binding — `https://api.kimi.com/coding/v1` publishes a catalog that `https://api.kimi.com` + /// does not. The provider's base URL follows, which is the one a vendor publishing a single + /// root catalog answers on. + static func listingRoots( + for provider: Provider, + wireProtocol: Provider.WireProtocol + ) -> [String] { + let addresses: [String?] = [ + provider.upstreamURL(for: wireProtocol).map { + GatewayUpstreamURL.upstream(for: wireProtocol, url: $0).baseURL + }, + provider.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines), + ] + var seen = Set() + return addresses.compactMap { $0 }.filter { !$0.isEmpty && seen.insert($0).inserted } + } + /// The conventional listing paths, ordered so the likelier one for this base URL goes first. static func candidateURLs(baseURL: String) -> [URL] { let base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/native/Sources/Services/ProviderProbeService.swift b/native/Sources/Services/ProviderProbeService.swift index 1759124e..6f4d4d83 100644 --- a/native/Sources/Services/ProviderProbeService.swift +++ b/native/Sources/Services/ProviderProbeService.swift @@ -21,36 +21,46 @@ struct ProviderProbeService: Sendable { injectedSession = session } - func test(_ provider: Provider, insecureSkipVerify: Bool) async -> ProviderProbeResult { - let baseURL = provider.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) + /// Tests one of the provider's bound addresses. Omitting the protocol tests its primary — + /// the address that also serves the callers it publishes nothing for. + func test( + _ provider: Provider, + wireProtocol: Provider.WireProtocol? = nil, + insecureSkipVerify: Bool + ) async -> ProviderProbeResult { + let wire = wireProtocol ?? provider.primaryProtocol + let baseURL = provider.upstreamURL(for: wire) ?? "" guard !baseURL.isEmpty else { return .init(succeeded: false, reason: .baseURLEmpty) } guard let components = URLComponents(string: baseURL), ["http", "https"].contains(components.scheme?.lowercased() ?? ""), components.host != nil, - let primaryURL = endpointURL(baseURL: baseURL, protocol: provider.protocol) + let primaryURL = GatewayUpstreamURL.upstream( + for: wire, url: baseURL + ).inferenceURL else { return .init(succeeded: false, reason: .baseURLInvalid) } let session = injectedSession ?? Self.makeSession(insecureSkipVerify: insecureSkipVerify) - let body = requestBody(for: provider) + let body = requestBody(for: provider, wireProtocol: wire) do { - // Exactly one request, to exactly the URL the gateway will call. The probe used to - // try a second spelling and quietly rewrite the user's base URL when it answered, + // Exactly one request, to exactly the URL the gateway will call, resolved by the + // same `GatewayUpstreamURL` the generated Bifrost configuration uses. The probe used + // to try a second spelling and quietly rewrite the user's address when it answered, // which is how a provider could test healthy against an address the gateway never - // used. `GatewayUpstreamURL` now maps each base URL to a single upstream URL, so - // there is no second spelling to try. + // used. let response = try await send( to: primaryURL, provider: provider, + wireProtocol: wire, body: body, session: session ) return decode( response, - protocol: provider.protocol, + protocol: wire, requestedModel: selectedModel(for: provider) ) } catch let error as URLError where error.code == .timedOut { @@ -63,6 +73,7 @@ struct ProviderProbeService: Sendable { private func send( to url: URL, provider: Provider, + wireProtocol: Provider.WireProtocol, body: Data, session: URLSession ) async throws -> (statusCode: Int, data: Data) { @@ -70,7 +81,7 @@ struct ProviderProbeService: Sendable { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(provider.authToken)", forHTTPHeaderField: "Authorization") - if provider.protocol == .anthropic { + if wireProtocol == .anthropic { request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") } request.httpBody = body @@ -81,10 +92,13 @@ struct ProviderProbeService: Sendable { return (response.statusCode, data) } - private func requestBody(for provider: Provider) -> Data { + private func requestBody( + for provider: Provider, + wireProtocol: Provider.WireProtocol + ) -> Data { let model = selectedModel(for: provider) let object: [String: Any] - switch provider.protocol { + switch wireProtocol { case .openAIResponses: object = ["model": model, "max_output_tokens": 16, "input": "ping"] case .openAIChat, .anthropic: @@ -140,18 +154,6 @@ struct ProviderProbeService: Sendable { ) } - /// The URL a real inference request will reach. - /// - /// Shared with the Bifrost configuration builder on purpose. When the probe computed its own - /// URL, it could report a healthy provider the gateway then answered with 404, because the - /// two disagreed about whether the base URL already carried its version segment. - private func endpointURL( - baseURL: String, - protocol wireProtocol: Provider.WireProtocol - ) -> URL? { - GatewayUpstreamURL.endpointURL(baseURL: baseURL, wireProtocol: wireProtocol) - } - private static func makeSession(insecureSkipVerify: Bool) -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 30 diff --git a/native/Sources/Views/Monitor/MonitorView.swift b/native/Sources/Views/Monitor/MonitorView.swift index 72a8472b..91503128 100644 --- a/native/Sources/Views/Monitor/MonitorView.swift +++ b/native/Sources/Views/Monitor/MonitorView.swift @@ -65,7 +65,7 @@ struct MonitorView: View { MonitorDetailDrawer( store: store, revealsSensitiveData: $revealsSensitiveData, - upstreamProtocol: activeProvider?.protocol, + upstreamProtocol: activeProvider?.primaryProtocol, width: detailExpanded ? proxy.size.width : min(640, max(480, proxy.size.width * 0.82)), @@ -238,8 +238,8 @@ struct MonitorView: View { value: activeProvider?.name ?? "—", subtitle: activeProvider.map { revealsSensitiveData - ? $0.baseUrl - : MonitorPrivacyRedactor.redact($0.baseUrl, language: appLanguage) + ? $0.primaryUpstreamURL + : MonitorPrivacyRedactor.redact($0.primaryUpstreamURL, language: appLanguage) } ?? appLanguage.localized("未选择服务"), accent: Theme.foreground, subtitlePrivacySensitive: true diff --git a/native/Sources/Views/Providers/ProviderEditorSupport.swift b/native/Sources/Views/Providers/ProviderEditorSupport.swift index ee14936f..43fdf35f 100644 --- a/native/Sources/Views/Providers/ProviderEditorSupport.swift +++ b/native/Sources/Views/Providers/ProviderEditorSupport.swift @@ -2,9 +2,11 @@ import AppKit import SwiftUI enum ProviderEditorLayout { - static let sheetSize = CGSize(width: 600, height: 690) + static let sheetSize = CGSize(width: 600, height: 726) static let presetSpacing: CGFloat = 6 - static let apiURLPlaceholder = "https://open.bigmodel.cn/api/anthropic/v1" + /// A bare root, because that is what the field now takes: the per-protocol addresses are + /// derived from it, not typed over it. + static let apiURLPlaceholder = "https://api.deepseek.com" } struct ProviderPresetFlowLayout: Layout { diff --git a/native/Sources/Views/Providers/ProviderEditorView.swift b/native/Sources/Views/Providers/ProviderEditorView.swift index 21d89ac1..7b53e163 100644 --- a/native/Sources/Views/Providers/ProviderEditorView.swift +++ b/native/Sources/Views/Providers/ProviderEditorView.swift @@ -7,6 +7,12 @@ struct ProviderEditorView: View { @Environment(\.appLanguage) private var appLanguage @EnvironmentObject private var model: AppModel @State private var draft: Provider + /// Which protocol's address the field under the segmented control is editing. The control + /// picks what you are looking at, not what the provider "is" — a provider binds up to three. + @State private var editingProtocol: Provider.WireProtocol + /// The base URL the addresses currently offered were derived from, so correcting a typo in + /// the base updates the addresses that came from it and leaves everything typed by hand. + @State private var derivationBase: String @State private var selectedPreset: String? @State private var presetQuery = "" @State private var showsToken = false @@ -26,7 +32,16 @@ struct ProviderEditorView: View { init(provider: Provider?, onSave: @escaping (Provider) -> Void) { var value = provider ?? Provider() if value.models.isEmpty { value.models = [.init(alias: "", upstream: "")] } + // A provider saved before per-protocol addresses has exactly one upstream, implied by its + // base URL. Writing it out means the field shows the address actually in use rather than + // looking empty under a segment that reads as bound. + let base = value.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if value.protocolUrls.isEmpty, !base.isEmpty { + value.protocolUrls[value.protocol.rawValue] = base + } _draft = State(initialValue: value) + _editingProtocol = State(initialValue: value.primaryProtocol) + _derivationBase = State(initialValue: value.baseUrl) self.onSave = onSave } @@ -74,8 +89,14 @@ struct ProviderEditorView: View { // A listing endpoint belongs to one address; pointing the provider somewhere else makes // the previous "this host has no /v1/models" answer meaningless, so the control comes // back enabled rather than staying greyed out against a host never probed. - .onChange(of: draft.baseUrl) { _ in modelCatalog = .unknown } - .onChange(of: draft.protocol) { _ in modelCatalog = .unknown } + .onChange(of: draft.baseUrl) { newValue in + modelCatalog = .unknown + rederiveOfferedAddresses(to: newValue) + } + .onChange(of: editingProtocol) { _ in + modelCatalog = .unknown + testMessage = nil + } .overlay(alignment: .topLeading) { Rectangle() .fill(Color.clear) @@ -111,9 +132,9 @@ struct ProviderEditorView: View { /// A searchable, grouped picker rather than a wall of chips. /// - /// The catalog grew from ten entries to seventy when it was ported from cc-switch, and seventy - /// capsules would fill the sheet before the fields it is meant to prefill. Typing narrows by - /// name, host or model id, which is how people actually look for a provider. + /// Even at two dozen vendors, capsules for all of them would fill the sheet before the fields + /// they are meant to prefill. Typing narrows by name, host, endpoint or model id, which is how + /// people actually look for a provider. private var presets: some View { VStack(alignment: .leading, spacing: Space.sm) { HStack(spacing: Space.sm) { @@ -177,6 +198,8 @@ struct ProviderEditorView: View { : preset.name) { selectedPreset = preset.id preset.apply(to: &draft) + editingProtocol = draft.primaryProtocol + derivationBase = draft.baseUrl testMessage = nil // Availability was measured against the previous address. modelCatalog = .unknown @@ -236,33 +259,154 @@ struct ProviderEditorView: View { } private var identityFields: some View { - HStack(alignment: .top, spacing: 11) { - editorField("名称") { TextField("GLM", text: $draft.name) } - editorField("API 地址") { - TextField(ProviderEditorLayout.apiURLPlaceholder, text: $draft.baseUrl) + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .top, spacing: 11) { + editorField("名称") { TextField("GLM", text: $draft.name) } + editorField("API 地址") { + TextField(ProviderEditorLayout.apiURLPlaceholder, text: $draft.baseUrl) + } } + Text("只填服务商根地址,每个协议各自的地址在下面绑定。") + .font(.system(size: 11)) + .foregroundStyle(Theme.mutedForeground) } } + /// The three addresses one provider binds. + /// + /// The segmented control chooses which one the field below is editing, not what the provider + /// "is": a vendor publishing Anthropic Messages, Chat Completions and Responses of its own + /// gets all three filled in and every client reaches it untouched. Leave one blank and the + /// clients that speak it are converted onto the address that is filled in — which is the + /// whole point of doing it per address rather than per provider. private var protocolField: some View { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 7) { fieldLabel("上游协议") - Text(appLanguage.localized(draft.protocol == .anthropic ? "直通" : "自动转换")) + Text(appLanguage.localized(draft.servesEveryProtocolDirectly ? "全部直通" : "按需转换")) .font(.system(size: 9.5, weight: .semibold)) - .foregroundStyle(draft.protocol == .anthropic ? Theme.mutedForeground : Theme.accentText) + .foregroundStyle( + draft.servesEveryProtocolDirectly ? Theme.mutedForeground : Theme.accentText + ) .padding(.horizontal, 7).padding(.vertical, 2) - .background(draft.protocol == .anthropic ? Theme.foreground.opacity(0.05) : Theme.accentSoft) + .background( + draft.servesEveryProtocolDirectly + ? Theme.foreground.opacity(0.05) : Theme.accentSoft + ) .clipShape(Capsule()) } - Picker("上游协议", selection: $draft.protocol) { - Text("Anthropic Messages").tag(Provider.WireProtocol.anthropic) - Text("OpenAI Chat").tag(Provider.WireProtocol.openAIChat) - Text("OpenAI Responses").tag(Provider.WireProtocol.openAIResponses) + Picker("上游协议", selection: $editingProtocol) { + ForEach(Provider.WireProtocol.allCases) { wireProtocol in + Text(segmentTitle(wireProtocol)).tag(wireProtocol) + } } .labelsHidden() .pickerStyle(.segmented) + .accessibilityIdentifier("provider.editor.protocol") + + HStack(spacing: 7) { + TextField( + derivedAddress ?? ProviderEditorLayout.apiURLPlaceholder, + text: editedProtocolURL + ) + .providerEditorTextField() + .accessibilityIdentifier("provider.editor.protocolURL") + if let derivedAddress, derivedAddress != draft.upstreamURL(for: editingProtocol) { + Button(appLanguage.localized("默认地址")) { + draft.protocolUrls[editingProtocol.rawValue] = derivedAddress + keepPrimaryOnAConfiguredProtocol() + } + .buttonStyle(CompactActionButtonStyle()) + .help(appLanguage.localized("按 API 地址填入该协议的常规地址")) + .accessibilityIdentifier("provider.editor.fillProtocolURL") + } + } + + Text(appLanguage.localized(routingSummary)) + .font(.system(size: 11)) + .foregroundStyle(Theme.mutedForeground) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// A bound protocol is marked, so the three addresses are readable without clicking through. + private func segmentTitle(_ wireProtocol: Provider.WireProtocol) -> String { + let title: String + switch wireProtocol { + case .anthropic: title = "Anthropic Messages" + case .openAIChat: title = "OpenAI Chat" + case .openAIResponses: title = "OpenAI Responses" } + return draft.upstreamURL(for: wireProtocol) == nil ? title : "● " + title + } + + private var derivedAddress: String? { + GatewayUpstreamURL.derivedURL(for: editingProtocol, base: draft.baseUrl) + } + + /// Clearing the field unbinds the protocol, which is how a client speaking it comes to be + /// converted; the raw text is kept while typing so a half-written address is not swallowed. + private var editedProtocolURL: Binding { + Binding( + get: { draft.protocolUrls[editingProtocol.rawValue] ?? "" }, + set: { value in + if value.isEmpty { + draft.protocolUrls.removeValue(forKey: editingProtocol.rawValue) + } else { + draft.protocolUrls[editingProtocol.rawValue] = value + } + keepPrimaryOnAConfiguredProtocol() + } + ) + } + + private var routingSummary: String { + let bound = draft.configuredProtocols + guard let primary = bound.first else { + return "至少绑定一个协议的地址,网关才有上游可用。" + } + if bound.count == Provider.WireProtocol.allCases.count { + return "三个协议都已绑定:每个客户端都走自己的地址,不做任何转换。" + } + return "未绑定的协议会转换后发往「\(primary.title)」。" + } + + /// `protocol` names the address that also takes callers this provider publishes nothing for, + /// so it has to keep naming one that is still bound. + private func keepPrimaryOnAConfiguredProtocol() { + guard let primary = draft.configuredProtocols.first else { return } + if draft.protocol != primary { draft.protocol = primary } + } + + /// Follows the base URL: re-offers the addresses that came from it, fills in the one on + /// screen if it is still blank, and leaves everything typed by hand alone. + /// + /// Filling the selected protocol matters — without it, a provider whose address field was + /// never touched would fall back to the bare base URL and the gateway would ask + /// `https://api.deepseek.com/v1/messages`, an address DeepSeek does not serve, while the + /// editor showed an empty field under a segment marked as bound. + private func rederiveOfferedAddresses(to newBase: String) { + defer { derivationBase = newBase } + guard derivationBase != newBase else { return } + for wireProtocol in Provider.WireProtocol.allCases { + guard let previous = GatewayUpstreamURL.derivedURL( + for: wireProtocol, base: derivationBase + ), draft.protocolUrls[wireProtocol.rawValue] == previous else { continue } + if let replacement = GatewayUpstreamURL.derivedURL( + for: wireProtocol, base: newBase + ) { + draft.protocolUrls[wireProtocol.rawValue] = replacement + } else { + draft.protocolUrls.removeValue(forKey: wireProtocol.rawValue) + } + } + if (draft.protocolUrls[editingProtocol.rawValue] ?? "").isEmpty, + let offered = GatewayUpstreamURL.derivedURL( + for: editingProtocol, base: newBase + ) { + draft.protocolUrls[editingProtocol.rawValue] = offered + } + keepPrimaryOnAConfiguredProtocol() } private var tokenField: some View { @@ -440,17 +584,33 @@ struct ProviderEditorView: View { ) } + /// Every bound address has to be usable, not just the one currently on screen: saving a + /// provider whose second address is a typo would leave the gateway refusing to start. private var isValid: Bool { - !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && ["http", "https"].contains(URLComponents(string: draft.baseUrl)?.scheme?.lowercased() ?? "") + guard !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + let addresses = draft.configuredUpstreamURLs + guard !addresses.isEmpty else { return false } + return addresses.values.allSatisfy { address in + let components = URLComponents( + string: address.trimmingCharacters(in: .whitespacesAndNewlines) + ) + return ["http", "https"].contains(components?.scheme?.lowercased() ?? "") + && components?.host?.isEmpty == false + } } + /// Tests the address on screen rather than the provider as a whole: three bound addresses can + /// fail independently, and a single verdict would hide which one. private func runProbe() { testing = true testMessage = nil + let wireProtocol = editingProtocol Task { let result = await ProviderProbeService().test( draft, + wireProtocol: wireProtocol, insecureSkipVerify: model.config.insecureSkipVerify ) await MainActor.run { @@ -480,10 +640,12 @@ struct ProviderEditorView: View { private func runModelDiscovery() { discovering = true let provider = draft + let wireProtocol = editingProtocol let insecure = model.config.insecureSkipVerify Task { let catalog = await ProviderModelDiscoveryService().discover( provider, + wireProtocol: wireProtocol, insecureSkipVerify: insecure ) await MainActor.run { diff --git a/native/Sources/Views/Providers/ProviderPresetCatalog.swift b/native/Sources/Views/Providers/ProviderPresetCatalog.swift index d489bda3..de5c42bb 100644 --- a/native/Sources/Views/Providers/ProviderPresetCatalog.swift +++ b/native/Sources/Views/Providers/ProviderPresetCatalog.swift @@ -2,10 +2,11 @@ import Foundation /// Upstream services the gateway knows how to reach. /// -/// The list is ported from cc-switch's provider catalog, which tracks the endpoints and model ids -/// of the Anthropic-compatible services people actually use; keeping ten hand-written entries here -/// meant most users had to look every one of them up by hand. Referral parameters were stripped -/// from the website links — those belong to cc-switch, not to this app. +/// Only vendors that run the models themselves are listed: Anthropic, OpenAI and Google alongside +/// the model vendors with a first-party coding endpoint. The aggregators and resellers this list +/// used to carry were removed — their endpoints move, their protocol support is whatever their +/// own upstream happens to expose that week, and shipping them as presets implied a degree of +/// vetting this app cannot do. Anything not listed is still one `自定义` entry away. /// /// Presets are a starting point, not a contract: they fill the editor's fields and the user can /// change anything afterwards. Entries needing OAuth, per-user template values, or a wire format @@ -14,8 +15,6 @@ struct ProviderPreset: Identifiable, Hashable { enum Category: String, CaseIterable, Identifiable { case official case vendor - case aggregator - case thirdParty case custom var id: String { rawValue } @@ -24,8 +23,6 @@ struct ProviderPreset: Identifiable, Hashable { switch self { case .official: "官方" case .vendor: "模型厂商" - case .aggregator: "聚合平台" - case .thirdParty: "第三方中转" case .custom: "自定义" } } @@ -33,16 +30,33 @@ struct ProviderPreset: Identifiable, Hashable { let id: String let name: String + /// The vendor's root, with nothing protocol-specific on it. It seeds the editor's per-protocol + /// derivations and is where model discovery looks first. let baseURL: String + /// The endpoints this vendor publishes, by protocol. A vendor documenting two of them binds + /// two, and a client speaking either is passed straight through; the third is converted. + var endpoints: [Provider.WireProtocol: String] = [:] let defaultModel: String let smallModel: String + /// The endpoint that also takes callers this vendor publishes nothing for. let wireProtocol: Provider.WireProtocol - var category: Category = .thirdParty + var category: Category = .vendor var website: String = "" + /// Falls back to the base URL under the primary protocol, so a preset that names no endpoint + /// still configures the one upstream it means. + var resolvedEndpoints: [Provider.WireProtocol: String] { + endpoints.isEmpty ? [wireProtocol: baseURL] : endpoints + } + func apply(to provider: inout Provider) { provider.name = name provider.baseUrl = baseURL + provider.protocolUrls = Dictionary( + uniqueKeysWithValues: resolvedEndpoints + .filter { !$0.value.isEmpty } + .map { ($0.key.rawValue, $0.value) } + ) provider.defaultModel = defaultModel provider.smallFastModel = smallModel provider.protocol = wireProtocol @@ -57,6 +71,7 @@ struct ProviderPreset: Identifiable, Hashable { return name.lowercased().contains(needle) || baseURL.lowercased().contains(needle) || defaultModel.lowercased().contains(needle) + || resolvedEndpoints.values.contains { $0.lowercased().contains(needle) } } static let custom = ProviderPreset( @@ -70,83 +85,215 @@ struct ProviderPreset: Identifiable, Hashable { ) static let all: [ProviderPreset] = [ - .init(id: "anthropic", name: "Anthropic", baseURL: "https://api.anthropic.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .official, website: "https://www.anthropic.com/claude-code"), - .init(id: "openai", name: "OpenAI", baseURL: "https://api.openai.com/v1", defaultModel: "gpt-5.2", smallModel: "gpt-5.2-mini", wireProtocol: .openAIResponses, category: .official, website: "https://platform.openai.com"), - .init(id: "google-ai-studio", name: "Google AI Studio", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-3.5-flash", smallModel: "gemini-3.1-flash-lite", wireProtocol: .openAIChat, category: .official, website: "https://aistudio.google.com"), - .init(id: "kimi", name: "Kimi", baseURL: "https://api.moonshot.cn/anthropic/v1", defaultModel: "kimi-k2.7-code", smallModel: "kimi-k2.7-code", wireProtocol: .anthropic, category: .vendor, website: "https://platform.kimi.com"), - .init(id: "kimi-for-coding", name: "Kimi For Coding", baseURL: "https://api.kimi.com/coding/v1", defaultModel: "kimi-for-coding", smallModel: "kimi-for-coding", wireProtocol: .anthropic, category: .vendor, website: "https://www.kimi.com/code/"), - .init(id: "packycode", name: "PackyCode", baseURL: "https://www.packyapi.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://www.packyapi.ai"), - .init(id: "zetaapi", name: "ZetaAPI", baseURL: "https://api.zetaapi.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://zetaapi.ai"), - .init(id: "apinebula", name: "APINebula", baseURL: "https://apinebula.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://apinebula.ai"), - .init(id: "aicodemirror", name: "AICodeMirror", baseURL: "https://api.aicodemirror.ai/api/claudecode/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://www.aicodemirror.ai"), - .init(id: "patewayai", name: "PatewayAI", baseURL: "https://api.pateway.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://pateway.ai"), - .init(id: "fennoai", name: "FennoAI", baseURL: "https://api.fenno.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://api.fenno.ai"), - .init(id: "runapi", name: "RunAPI", baseURL: "https://runapi.host/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://runapi.host"), - .init(id: "shengsuanyun", name: "Shengsuanyun", baseURL: "https://router.shengsuanyun.com/api/v1", defaultModel: "anthropic/claude-sonnet-5", smallModel: "anthropic/claude-haiku-4.5", wireProtocol: .anthropic, category: .aggregator, website: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF"), - .init(id: "aigocode", name: "AIGoCode", baseURL: "https://api.aigocode.app/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://aigocode.app"), - .init(id: "qiniu", name: "Qiniu", baseURL: "https://api.qnaigc.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://s.qiniu.com/nMvAvy"), - .init(id: "aicoding", name: "AICoding", baseURL: "https://api.aicoding.inc/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://aicoding.inc"), - .init(id: "subrouter", name: "SubRouter", baseURL: "https://subrouter.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://subrouter.ai"), - .init(id: "apikey-fun", name: "APIKEY.FUN", baseURL: "https://api.apikey.fun/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://apikey.fun"), - .init(id: "claudeapi", name: "ClaudeAPI", baseURL: "https://gw.apito.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://www.apito.ai"), - .init(id: "code0", name: "Code0", baseURL: "https://code0.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://code0.ai"), - .init(id: "teamorouter", name: "TeamoRouter", baseURL: "https://api.teamorouter.cn/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://teamorouter.cn"), - .init(id: "ppio", name: "PPIO", baseURL: "https://api.ppio.com/anthropic/v1", defaultModel: "deepseek/deepseek-v4-flash-0731", smallModel: "deepseek/deepseek-v4-flash-0731", wireProtocol: .anthropic, category: .aggregator, website: "https://ppio.com"), - .init(id: "claudecn", name: "ClaudeCN", baseURL: "https://claudecn.top/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://claudecn.top"), - .init(id: "agent-plan", name: "火山 Agent Plan", baseURL: "https://ark.cn-beijing.volces.com/api/plan/v1", defaultModel: "ark-code-latest", smallModel: "ark-code-latest", wireProtocol: .anthropic, category: .vendor, website: "https://www.volcengine.com/activity/agentplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_source=OWO&utm_medium=devrel-1&utm_campaign=hw&utm_term=ccswitch&utm_content=hw"), - .init(id: "coding-plan", name: "火山 Coding Plan", baseURL: "https://ark.cn-beijing.volces.com/api/coding/v1", defaultModel: "ark-code-latest", smallModel: "ark-code-latest", wireProtocol: .anthropic, category: .vendor, website: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"), - .init(id: "byteplus", name: "BytePlus", baseURL: "https://ark.ap-southeast.bytepluses.com/api/coding/v1", defaultModel: "ark-code-latest", smallModel: "ark-code-latest", wireProtocol: .anthropic, category: .vendor, website: "https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"), - .init(id: "doubaoseed", name: "DouBaoSeed", baseURL: "https://ark.cn-beijing.volces.com/api/compatible/v1", defaultModel: "doubao-seed-2-1-pro-260628", smallModel: "doubao-seed-2-1-pro-260628", wireProtocol: .anthropic, category: .vendor, website: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"), - .init(id: "siliconflow", name: "SiliconFlow", baseURL: "https://api.siliconflow.cn/v1", defaultModel: "Pro/MiniMaxAI/MiniMax-M2.5", smallModel: "Pro/MiniMaxAI/MiniMax-M2.5", wireProtocol: .anthropic, category: .aggregator, website: "https://siliconflow.cn"), - .init(id: "siliconflow-en", name: "SiliconFlow en", baseURL: "https://api.siliconflow.com/v1", defaultModel: "MiniMaxAI/MiniMax-M3", smallModel: "MiniMaxAI/MiniMax-M3", wireProtocol: .anthropic, category: .aggregator, website: "https://siliconflow.com"), - .init(id: "a6api", name: "A6API", baseURL: "https://api.a6api.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://www.a6api.com"), - .init(id: "atlascloud", name: "AtlasCloud", baseURL: "https://api.atlascloud.ai/v1", defaultModel: "zai-org/glm-5.1", smallModel: "zai-org/glm-5.1", wireProtocol: .anthropic, category: .aggregator, website: "https://www.atlascloud.ai/console/coding-plan"), - .init(id: "compshare", name: "Compshare", baseURL: "https://api.modelverse.cn/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://www.compshare.cn"), - .init(id: "compshare-coding-plan", name: "Compshare Coding Plan", baseURL: "https://cp.compshare.cn/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://www.compshare.cn"), - .init(id: "ccsub", name: "CCSub", baseURL: "https://www.ccsub.net/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://www.ccsub.net"), - .init(id: "sssaicode", name: "SSSAiCode", baseURL: "https://node-hk.sssaicodeapi.com/api/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://sssaicodeapi.com"), - .init(id: "micu", name: "Micu", baseURL: "https://www.micuapi.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://www.micuapi.ai"), - .init(id: "rightcode", name: "RightCode", baseURL: "https://www.rightapi.ai/claude/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://www.rightapi.ai"), - .init(id: "etok-ai", name: "ETok.ai", baseURL: "https://api.etok.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://etok.ai"), - .init(id: "cubence", name: "Cubence", baseURL: "https://api.cubence.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://cubence.com"), - .init(id: "crazyrouter", name: "CrazyRouter", baseURL: "https://cn.crazyrouter.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://www.crazyrouter.com"), - .init(id: "dmxapi", name: "DMXAPI", baseURL: "https://www.dmxapi.cn/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://www.dmxapi.cn"), - .init(id: "sudocode-chat", name: "SudoCode.chat", baseURL: "https://api.sudocode.chat/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://sudocode.chat"), - .init(id: "sudocode-us", name: "SudoCode.us", baseURL: "https://sudocode.us/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://sudocode.us"), - .init(id: "xycai", name: "XycAi", baseURL: "https://apicdn.xycai.us/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://xycai.us"), - .init(id: "amux", name: "Amux", baseURL: "https://api.amux.ai/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://amux.ai"), - .init(id: "deepseek", name: "DeepSeek", baseURL: "https://api.deepseek.com/anthropic/v1", defaultModel: "deepseek-v4-pro", smallModel: "deepseek-v4-flash", wireProtocol: .anthropic, category: .vendor, website: "https://platform.deepseek.com"), - .init(id: "opencode-go", name: "OpenCode Go", baseURL: "https://opencode.ai/zen/go/v1", defaultModel: "deepseek-v4-flash", smallModel: "deepseek-v4-flash", wireProtocol: .anthropic, category: .thirdParty, website: "https://opencode.ai/go"), - .init(id: "zhipu-glm", name: "Zhipu GLM", baseURL: "https://open.bigmodel.cn/api/anthropic/v1", defaultModel: "glm-5.1", smallModel: "glm-5.1", wireProtocol: .anthropic, category: .vendor, website: "https://open.bigmodel.cn"), - .init(id: "zhipu-glm-en", name: "Zhipu GLM en", baseURL: "https://api.z.ai/api/anthropic/v1", defaultModel: "glm-5.1", smallModel: "glm-5.1", wireProtocol: .anthropic, category: .vendor, website: "https://z.ai"), - .init(id: "baidu-qianfan-coding-plan", name: "Baidu Qianfan Coding Plan", baseURL: "https://qianfan.baidubce.com/anthropic/coding/v1", defaultModel: "qianfan-code-latest", smallModel: "qianfan-code-latest", wireProtocol: .anthropic, category: .vendor, website: "https://cloud.baidu.com/product/qianfan_modelbuilder"), - .init(id: "baidu-qianfan-token-plan", name: "Baidu Qianfan Token Plan", baseURL: "https://qianfan.baidubce.com/anthropic/tokenplan/personal/v1", defaultModel: "deepseek-v4-pro", smallModel: "deepseek-v4-pro", wireProtocol: .anthropic, category: .vendor, website: "https://cloud.baidu.com/product/codingplan.html"), - .init(id: "bailian", name: "Bailian", baseURL: "https://dashscope.aliyuncs.com/apps/anthropic/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .vendor, website: "https://bailian.console.aliyun.com"), - .init(id: "bailian-for-coding", name: "Bailian For Coding", baseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .vendor, website: "https://bailian.console.aliyun.com"), - .init(id: "stepfun", name: "StepFun", baseURL: "https://api.stepfun.com/step_plan/v1", defaultModel: "step-3.5-flash-2603", smallModel: "step-3.5-flash-2603", wireProtocol: .anthropic, category: .vendor, website: "https://platform.stepfun.com/step-plan"), - .init(id: "stepfun-en", name: "StepFun en", baseURL: "https://api.stepfun.ai/step_plan/v1", defaultModel: "step-3.5-flash-2603", smallModel: "step-3.5-flash-2603", wireProtocol: .anthropic, category: .vendor, website: "https://platform.stepfun.ai/step-plan"), - .init(id: "modelscope", name: "ModelScope", baseURL: "https://api-inference.modelscope.cn/v1", defaultModel: "ZhipuAI/GLM-5.2", smallModel: "ZhipuAI/GLM-5.2", wireProtocol: .anthropic, category: .aggregator, website: "https://modelscope.cn"), - .init(id: "longcat", name: "Longcat", baseURL: "https://api.longcat.chat/anthropic/v1", defaultModel: "LongCat-2.0", smallModel: "LongCat-2.0", wireProtocol: .anthropic, category: .vendor, website: "https://longcat.chat/platform"), - .init(id: "minimax", name: "MiniMax", baseURL: "https://api.minimaxi.com/anthropic/v1", defaultModel: "MiniMax-M2.7", smallModel: "MiniMax-M2.7", wireProtocol: .anthropic, category: .vendor, website: "https://platform.minimaxi.com"), - .init(id: "minimax-en", name: "MiniMax en", baseURL: "https://api.minimax.io/anthropic/v1", defaultModel: "MiniMax-M2.7", smallModel: "MiniMax-M2.7", wireProtocol: .anthropic, category: .vendor, website: "https://platform.minimax.io"), - .init(id: "bailing", name: "BaiLing", baseURL: "https://api.tbox.cn/api/anthropic/v1", defaultModel: "Ling-2.5-1T", smallModel: "Ling-2.5-1T", wireProtocol: .anthropic, category: .vendor, website: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started"), - .init(id: "aihubmix", name: "AiHubMix", baseURL: "https://aihubmix.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .aggregator, website: "https://aihubmix.com"), - .init(id: "cherryin", name: "CherryIN", baseURL: "https://open.cherryin.net/v1", defaultModel: "anthropic/claude-sonnet-5", smallModel: "anthropic/claude-haiku-4.5", wireProtocol: .anthropic, category: .aggregator, website: "https://open.cherryin.ai"), - .init(id: "relaxycode", name: "RelaxyCode", baseURL: "https://www.relaxycode.com/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://www.relaxycode.com"), - .init(id: "e-flowcode", name: "E-FlowCode", baseURL: "https://e-flowcode.cc/v1", defaultModel: "", smallModel: "", wireProtocol: .anthropic, category: .thirdParty, website: "https://e-flowcode.cc"), - .init(id: "openrouter", name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", defaultModel: "anthropic/claude-sonnet-5", smallModel: "anthropic/claude-haiku-4.5", wireProtocol: .anthropic, category: .aggregator, website: "https://openrouter.ai"), - .init(id: "therouter", name: "TheRouter", baseURL: "https://api.therouter.ai/v1", defaultModel: "anthropic/claude-sonnet-5", smallModel: "anthropic/claude-haiku-4.5", wireProtocol: .anthropic, category: .aggregator, website: "https://therouter.ai"), - .init(id: "novita-ai", name: "Novita AI", baseURL: "https://api.novita.ai/anthropic/v1", defaultModel: "zai-org/glm-5.1", smallModel: "zai-org/glm-5.1", wireProtocol: .anthropic, category: .aggregator, website: "https://novita.ai"), - .init(id: "nvidia", name: "Nvidia", baseURL: "https://integrate.api.nvidia.com/v1", defaultModel: "moonshotai/kimi-k2.5", smallModel: "moonshotai/kimi-k2.5", wireProtocol: .openAIChat, category: .aggregator, website: "https://build.nvidia.com"), - .init(id: "pipellm", name: "PIPELLM", baseURL: "https://cc-api.pipellm.ai/v1", defaultModel: "claude-opus-5", smallModel: "claude-haiku-4-5-20251001", wireProtocol: .anthropic, category: .aggregator, website: "https://code.pipellm.ai"), - .init(id: "xiaomi-mimo", name: "Xiaomi MiMo", baseURL: "https://api.xiaomimimo.com/anthropic/v1", defaultModel: "mimo-v2.5-pro", smallModel: "mimo-v2.5-pro", wireProtocol: .anthropic, category: .vendor, website: "https://platform.xiaomimimo.com"), - .init(id: "xiaomi-mimo-token-plan-china", name: "Xiaomi MiMo Token Plan (China)", baseURL: "https://token-plan-cn.xiaomimimo.com/anthropic/v1", defaultModel: "mimo-v2.5-pro", smallModel: "mimo-v2.5-pro", wireProtocol: .anthropic, category: .vendor, website: "https://platform.xiaomimimo.com/#/token-plan"), - .init(id: "jiekou-ai", name: "JieKou AI", baseURL: "https://api.jiekou.ai/anthropic/v1", defaultModel: "claude-fable-5", smallModel: "claude-fable-5", wireProtocol: .anthropic, category: .aggregator, website: "https://jiekou.ai/#model-library"), + .init( + id: "anthropic", name: "Anthropic", + baseURL: "https://api.anthropic.com", + endpoints: [.anthropic: "https://api.anthropic.com/v1"], + defaultModel: "", smallModel: "", wireProtocol: .anthropic, + category: .official, website: "https://www.anthropic.com/claude-code" + ), + .init( + id: "openai", name: "OpenAI", + baseURL: "https://api.openai.com", + endpoints: [ + .openAIResponses: "https://api.openai.com/v1/responses", + .openAIChat: "https://api.openai.com/v1/chat/completions", + ], + defaultModel: "gpt-5.2", smallModel: "gpt-5.2-mini", + wireProtocol: .openAIResponses, + category: .official, website: "https://platform.openai.com" + ), + .init( + id: "google-ai-studio", name: "Google AI Studio", + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + endpoints: [ + .openAIChat: "https://generativelanguage.googleapis.com/v1beta/openai", + ], + defaultModel: "gemini-3.5-flash", smallModel: "gemini-3.1-flash-lite", + wireProtocol: .openAIChat, + category: .official, website: "https://aistudio.google.com" + ), + .init( + id: "kimi", name: "Kimi", + baseURL: "https://api.moonshot.cn", + endpoints: [ + .anthropic: "https://api.moonshot.cn/anthropic/v1", + .openAIChat: "https://api.moonshot.cn/v1/chat/completions", + ], + defaultModel: "kimi-k2.7-code", smallModel: "kimi-k2.7-code", + wireProtocol: .anthropic, + category: .vendor, website: "https://platform.kimi.com" + ), + .init( + id: "kimi-for-coding", name: "Kimi For Coding", + baseURL: "https://api.kimi.com", + endpoints: [.anthropic: "https://api.kimi.com/coding/v1"], + defaultModel: "kimi-for-coding", smallModel: "kimi-for-coding", + wireProtocol: .anthropic, + category: .vendor, website: "https://www.kimi.com/code/" + ), + .init( + id: "agent-plan", name: "火山 Agent Plan", + baseURL: "https://ark.cn-beijing.volces.com", + endpoints: [.anthropic: "https://ark.cn-beijing.volces.com/api/plan/v1"], + defaultModel: "ark-code-latest", smallModel: "ark-code-latest", + wireProtocol: .anthropic, + category: .vendor, + website: "https://www.volcengine.com/activity/agentplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_source=OWO&utm_medium=devrel-1&utm_campaign=hw&utm_term=ccswitch&utm_content=hw" + ), + .init( + id: "coding-plan", name: "火山 Coding Plan", + baseURL: "https://ark.cn-beijing.volces.com", + endpoints: [.anthropic: "https://ark.cn-beijing.volces.com/api/coding/v1"], + defaultModel: "ark-code-latest", smallModel: "ark-code-latest", + wireProtocol: .anthropic, + category: .vendor, + website: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch" + ), + .init( + id: "byteplus", name: "BytePlus", + baseURL: "https://ark.ap-southeast.bytepluses.com", + endpoints: [.anthropic: "https://ark.ap-southeast.bytepluses.com/api/coding/v1"], + defaultModel: "ark-code-latest", smallModel: "ark-code-latest", + wireProtocol: .anthropic, + category: .vendor, + website: "https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch" + ), + .init( + id: "doubaoseed", name: "DouBaoSeed", + baseURL: "https://ark.cn-beijing.volces.com", + endpoints: [.anthropic: "https://ark.cn-beijing.volces.com/api/compatible/v1"], + defaultModel: "doubao-seed-2-1-pro-260628", smallModel: "doubao-seed-2-1-pro-260628", + wireProtocol: .anthropic, + category: .vendor, + website: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch" + ), + .init( + id: "deepseek", name: "DeepSeek", + baseURL: "https://api.deepseek.com", + endpoints: [ + .anthropic: "https://api.deepseek.com/anthropic", + .openAIChat: "https://api.deepseek.com/chat/completions", + ], + defaultModel: "deepseek-v4-pro", smallModel: "deepseek-v4-flash", + wireProtocol: .anthropic, + category: .vendor, website: "https://platform.deepseek.com" + ), + .init( + id: "zhipu-glm", name: "Zhipu GLM", + baseURL: "https://open.bigmodel.cn", + endpoints: [ + .anthropic: "https://open.bigmodel.cn/api/anthropic/v1", + .openAIChat: "https://open.bigmodel.cn/api/paas/v4/chat/completions", + ], + defaultModel: "glm-5.1", smallModel: "glm-5.1", wireProtocol: .anthropic, + category: .vendor, website: "https://open.bigmodel.cn" + ), + .init( + id: "zhipu-glm-en", name: "Zhipu GLM en", + baseURL: "https://api.z.ai", + endpoints: [ + .anthropic: "https://api.z.ai/api/anthropic/v1", + .openAIChat: "https://api.z.ai/api/paas/v4/chat/completions", + ], + defaultModel: "glm-5.1", smallModel: "glm-5.1", wireProtocol: .anthropic, + category: .vendor, website: "https://z.ai" + ), + .init( + id: "baidu-qianfan-coding-plan", name: "Baidu Qianfan Coding Plan", + baseURL: "https://qianfan.baidubce.com", + endpoints: [.anthropic: "https://qianfan.baidubce.com/anthropic/coding/v1"], + defaultModel: "qianfan-code-latest", smallModel: "qianfan-code-latest", + wireProtocol: .anthropic, + category: .vendor, website: "https://cloud.baidu.com/product/qianfan_modelbuilder" + ), + .init( + id: "baidu-qianfan-token-plan", name: "Baidu Qianfan Token Plan", + baseURL: "https://qianfan.baidubce.com", + endpoints: [ + .anthropic: "https://qianfan.baidubce.com/anthropic/tokenplan/personal/v1", + ], + defaultModel: "deepseek-v4-pro", smallModel: "deepseek-v4-pro", + wireProtocol: .anthropic, + category: .vendor, website: "https://cloud.baidu.com/product/codingplan.html" + ), + .init( + id: "bailian", name: "Bailian", + baseURL: "https://dashscope.aliyuncs.com", + endpoints: [.anthropic: "https://dashscope.aliyuncs.com/apps/anthropic/v1"], + defaultModel: "", smallModel: "", wireProtocol: .anthropic, + category: .vendor, website: "https://bailian.console.aliyun.com" + ), + .init( + id: "bailian-for-coding", name: "Bailian For Coding", + baseURL: "https://coding.dashscope.aliyuncs.com", + endpoints: [.anthropic: "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1"], + defaultModel: "", smallModel: "", wireProtocol: .anthropic, + category: .vendor, website: "https://bailian.console.aliyun.com" + ), + .init( + id: "stepfun", name: "StepFun", + baseURL: "https://api.stepfun.com", + endpoints: [.anthropic: "https://api.stepfun.com/step_plan/v1"], + defaultModel: "step-3.5-flash-2603", smallModel: "step-3.5-flash-2603", + wireProtocol: .anthropic, + category: .vendor, website: "https://platform.stepfun.com/step-plan" + ), + .init( + id: "stepfun-en", name: "StepFun en", + baseURL: "https://api.stepfun.ai", + endpoints: [.anthropic: "https://api.stepfun.ai/step_plan/v1"], + defaultModel: "step-3.5-flash-2603", smallModel: "step-3.5-flash-2603", + wireProtocol: .anthropic, + category: .vendor, website: "https://platform.stepfun.ai/step-plan" + ), + .init( + id: "longcat", name: "Longcat", + baseURL: "https://api.longcat.chat", + endpoints: [.anthropic: "https://api.longcat.chat/anthropic/v1"], + defaultModel: "LongCat-2.0", smallModel: "LongCat-2.0", wireProtocol: .anthropic, + category: .vendor, website: "https://longcat.chat/platform" + ), + .init( + id: "minimax", name: "MiniMax", + baseURL: "https://api.minimaxi.com", + endpoints: [.anthropic: "https://api.minimaxi.com/anthropic/v1"], + defaultModel: "MiniMax-M2.7", smallModel: "MiniMax-M2.7", wireProtocol: .anthropic, + category: .vendor, website: "https://platform.minimaxi.com" + ), + .init( + id: "minimax-en", name: "MiniMax en", + baseURL: "https://api.minimax.io", + endpoints: [.anthropic: "https://api.minimax.io/anthropic/v1"], + defaultModel: "MiniMax-M2.7", smallModel: "MiniMax-M2.7", wireProtocol: .anthropic, + category: .vendor, website: "https://platform.minimax.io" + ), + .init( + id: "bailing", name: "BaiLing", + baseURL: "https://api.tbox.cn", + endpoints: [.anthropic: "https://api.tbox.cn/api/anthropic/v1"], + defaultModel: "Ling-2.5-1T", smallModel: "Ling-2.5-1T", wireProtocol: .anthropic, + category: .vendor, website: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started" + ), + .init( + id: "xiaomi-mimo", name: "Xiaomi MiMo", + baseURL: "https://api.xiaomimimo.com", + endpoints: [.anthropic: "https://api.xiaomimimo.com/anthropic/v1"], + defaultModel: "mimo-v2.5-pro", smallModel: "mimo-v2.5-pro", wireProtocol: .anthropic, + category: .vendor, website: "https://platform.xiaomimimo.com" + ), + .init( + id: "xiaomi-mimo-token-plan-china", name: "Xiaomi MiMo Token Plan (China)", + baseURL: "https://token-plan-cn.xiaomimimo.com", + endpoints: [.anthropic: "https://token-plan-cn.xiaomimimo.com/anthropic/v1"], + defaultModel: "mimo-v2.5-pro", smallModel: "mimo-v2.5-pro", wireProtocol: .anthropic, + category: .vendor, website: "https://platform.xiaomimimo.com/#/token-plan" + ), custom, ] /// Presentation order: the services you are most likely to be reaching for first. - static let categoryOrder: [Category] = [.official, .vendor, .aggregator, .thirdParty] + static let categoryOrder: [Category] = [.official, .vendor] static func grouped(matching query: String) -> [(category: Category, presets: [ProviderPreset])] { categoryOrder.compactMap { category in diff --git a/native/Sources/Views/Providers/ProviderRow.swift b/native/Sources/Views/Providers/ProviderRow.swift index df2a306b..f74c46fd 100644 --- a/native/Sources/Views/Providers/ProviderRow.swift +++ b/native/Sources/Views/Providers/ProviderRow.swift @@ -171,8 +171,10 @@ struct ProviderRow: View { } } + /// What this provider does to a client's protocol, which is now a property of how many + /// addresses it binds rather than of which single one it had. private var protocolBadge: some View { - let translated = provider.protocol != .anthropic + let translated = !provider.servesEveryProtocolDirectly return Text(protocolLabel) .font(.ccLabel(.medium)) .foregroundStyle(translated ? Theme.accentText : Theme.mutedForeground) @@ -181,15 +183,22 @@ struct ProviderRow: View { .background(translated ? Theme.accentSoft : Theme.fill) .clipShape(RoundedRectangle(cornerRadius: Radius.badge, style: .continuous)) .help(appLanguage.localized( - translated ? "由 Bifrost 自动转换协议" : "Anthropic 协议直通" + translated ? "未绑定的协议由 Bifrost 自动转换" : "三个协议都已绑定,全部直通" )) } private var protocolLabel: String { - switch provider.protocol { - case .anthropic: "Anthropic" - case .openAIChat: "OpenAI Chat" - case .openAIResponses: "OpenAI Responses" + let bound = provider.configuredProtocols + if bound.count >= Provider.WireProtocol.allCases.count { + return appLanguage.localized("全部直通") + } + if bound.count > 1 { + return appLanguage.localized("\(bound.count) 个协议直通") + } + switch provider.primaryProtocol { + case .anthropic: return "Anthropic" + case .openAIChat: return "OpenAI Chat" + case .openAIResponses: return "OpenAI Responses" } } @@ -251,7 +260,7 @@ struct ProviderRow: View { } private var displayURL: String { - provider.baseUrl + provider.primaryUpstreamURL .replacingOccurrences(of: "https://", with: "") .replacingOccurrences(of: "http://", with: "") } diff --git a/native/Sources/Views/Settings/GatewaySettingsPane.swift b/native/Sources/Views/Settings/GatewaySettingsPane.swift index 5c7293a2..c15d5a4c 100644 --- a/native/Sources/Views/Settings/GatewaySettingsPane.swift +++ b/native/Sources/Views/Settings/GatewaySettingsPane.swift @@ -191,7 +191,7 @@ struct GatewaySettingsPane: View { Text(provider?.name ?? providerID) .font(.ccBody(.medium)) .lineLimit(1) - if let url = provider?.baseUrl, !url.isEmpty { + if let url = provider?.primaryUpstreamURL, !url.isEmpty { Text(url) .font(.ccLabel()) .foregroundStyle(Theme.mutedForeground) diff --git a/native/Tests/Unit/AppConfigTests.swift b/native/Tests/Unit/AppConfigTests.swift index 4b98cb86..85648013 100644 --- a/native/Tests/Unit/AppConfigTests.swift +++ b/native/Tests/Unit/AppConfigTests.swift @@ -78,6 +78,52 @@ final class AppConfigTests: XCTestCase { XCTAssertEqual(config.providers[0].baseUrl, "https://open.bigmodel.cn/api/anthropic/v1") } + /// Per-protocol addresses decide what the gateway converts, so a blank one has to mean "this + /// provider does not speak that protocol" rather than "this provider has an upstream with no + /// host". And `protocol` — the address unmatched callers fall to — has to survive naming one + /// that was cleared. + func testNormalizationDropsBlankProtocolAddressesAndKeepsThePrimaryOnABoundOne() throws { + let data = Data(#""" + {"providers":[{"id":"p","name":"DeepSeek","baseUrl":"https://api.deepseek.com", + "protocol":"openai-responses","protocolUrls":{ + "anthropic":" https://api.deepseek.com/anthropic/ ","openai-chat":" ", + "openai-responses":"","grpc":"https://nope.example.com"}}]} + """#.utf8) + let config = try JSONDecoder().decode(AppConfig.self, from: data) + let provider = try XCTUnwrap(config.providers.first) + + XCTAssertEqual( + provider.protocolUrls, ["anthropic": "https://api.deepseek.com/anthropic"] + ) + XCTAssertEqual(provider.configuredProtocols, [.anthropic]) + XCTAssertEqual( + provider.protocol, .anthropic, + "the Responses address was cleared, so it cannot go on collecting unmatched callers" + ) + XCTAssertFalse(provider.servesEveryProtocolDirectly) + } + + /// A provider written before per-protocol addresses existed keeps exactly the one upstream it + /// had, and an upgrade must not silently invent two more. + func testAProviderWithNoProtocolAddressesFallsBackToItsBaseURL() throws { + let data = Data( + #"{"providers":[{"id":"p","name":"GLM","baseUrl":"https://open.bigmodel.cn/api/anthropic/v1","protocol":"anthropic"}]}"#.utf8 + ) + let config = try JSONDecoder().decode(AppConfig.self, from: data) + let provider = try XCTUnwrap(config.providers.first) + + XCTAssertTrue(provider.protocolUrls.isEmpty) + XCTAssertEqual( + provider.configuredUpstreamURLs, + [.anthropic: "https://open.bigmodel.cn/api/anthropic/v1"] + ) + XCTAssertEqual(provider.primaryProtocol, .anthropic) + XCTAssertEqual( + provider.primaryUpstreamURL, "https://open.bigmodel.cn/api/anthropic/v1" + ) + XCTAssertNil(provider.upstreamURL(for: .openAIChat)) + } + func testRealisticLegacyConfigDecodesPartialObjectsAndRoundTripsWithoutFieldLoss() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("ccbud-config-migration-\(UUID().uuidString)", isDirectory: true) diff --git a/native/Tests/Unit/BifrostConfigTests.swift b/native/Tests/Unit/BifrostConfigTests.swift index 8a3e7626..ba0d9f49 100644 --- a/native/Tests/Unit/BifrostConfigTests.swift +++ b/native/Tests/Unit/BifrostConfigTests.swift @@ -347,6 +347,60 @@ final class BifrostConfigTests: XCTestCase { XCTAssertEqual(String(decoding: try BifrostConfigBuilder.modelParametersData(from: config), as: UTF8.self), "{}") } + /// A vendor publishing all three of its own endpoints becomes three Bifrost providers, one + /// per address, because a Bifrost provider carries exactly one base URL and one wire format. + func testOneProviderBindingThreeAddressesBecomesThreeBifrostProviders() throws { + var config = AppConfig.fixture + config.providers[0].baseUrl = "https://api.deepseek.com" + config.providers[0].protocolUrls = [ + "anthropic": "https://api.deepseek.com/anthropic", + "openai-chat": "https://api.deepseek.com/chat/completions", + "openai-responses": "https://api.deepseek.com/responses", + ] + config.normalize() + + let routes = BifrostConfigBuilder.routedProviders(from: config) + XCTAssertEqual(routes.map(\.wireProtocol), [.anthropic, .openAIChat, .openAIResponses]) + XCTAssertEqual(Set(routes.map(\.bifrostName)).count, 3, "names must not collide") + + let output = try BifrostConfigBuilder.build( + from: config, + logDatabaseURL: URL(fileURLWithPath: "/tmp/logs.db"), + managementCredentials: managementCredentials + ) + XCTAssertEqual(output.providers.count, 3) + + let anthropic = try XCTUnwrap(output.providers[routes[0].bifrostName]) + XCTAssertEqual(anthropic.customProviderConfig.baseProviderType, "anthropic") + XCTAssertEqual(anthropic.networkConfig.baseURL, "https://api.deepseek.com/anthropic") + XCTAssertNil( + anthropic.customProviderConfig.requestPathOverrides, + "a base spelling lets Bifrost supply its own /v1" + ) + + let chat = try XCTUnwrap(output.providers[routes[1].bifrostName]) + XCTAssertEqual(chat.customProviderConfig.baseProviderType, "openai") + XCTAssertEqual(chat.networkConfig.baseURL, "https://api.deepseek.com") + XCTAssertEqual( + chat.customProviderConfig.requestPathOverrides?["chat_completion"], + "/chat/completions", + "the address was typed as an endpoint, so its exact path is pinned" + ) + XCTAssertTrue(chat.customProviderConfig.allowedRequests.chatCompletion) + XCTAssertFalse(chat.customProviderConfig.allowedRequests.responses) + + let responses = try XCTUnwrap(output.providers[routes[2].bifrostName]) + XCTAssertTrue(responses.customProviderConfig.allowedRequests.responses) + XCTAssertTrue( + output.client.compat.convertChatToResponses, + "a Responses upstream is configured, so the Chat conversion hook stays available" + ) + // Every entry authenticates with the provider's one key. + for name in routes.map(\.bifrostName) { + XCTAssertEqual(output.providers[name]?.keys.first?.value, "sk-testtoken1234") + } + } + func testNormalizeInferenceTokenIsPurePrefixMigrationAndRejectsEmptyInput() { XCTAssertNil(normalizeInferenceToken("")) XCTAssertNil(normalizeInferenceToken(" \n\t ")) diff --git a/native/Tests/Unit/GatewayProtocolRoutingTests.swift b/native/Tests/Unit/GatewayProtocolRoutingTests.swift index 59287ae6..02cb63b2 100644 --- a/native/Tests/Unit/GatewayProtocolRoutingTests.swift +++ b/native/Tests/Unit/GatewayProtocolRoutingTests.swift @@ -221,6 +221,102 @@ final class GatewayProtocolRoutingTests: XCTestCase { XCTAssertEqual(route.wireModel, "ccbud-1-abc/gpt-5.4") } + /// One vendor publishing all three of its own endpoints is the case worth not converting for. + /// + /// DeepSeek answers Anthropic Messages, Chat Completions and Responses itself. Before + /// addresses were bound per protocol, a provider was one protocol, so two of those three + /// callers were translated on the way to an upstream that spoke their language natively. + func testOneProviderBindingThreeAddressesConvertsForNobody() { + var deepSeek = provider("ds", .anthropic, primary: "deepseek-chat") + deepSeek.baseUrl = "https://api.deepseek.com" + deepSeek.protocolUrls = [ + "anthropic": "https://api.deepseek.com/anthropic", + "openai-chat": "https://api.deepseek.com/chat/completions", + "openai-responses": "https://api.deepseek.com/responses", + ] + let router = GatewayProtocolRouter(config: config([deepSeek])) + + XCTAssertEqual(router.routes.count, 3) + XCTAssertTrue(router.servesEveryProtocolDirectly) + XCTAssertTrue( + router.pinsProviderName, + "three Bifrost entries share one provider, so the request has to name which" + ) + for clientProtocol in GatewayClientProtocol.allCases { + XCTAssertEqual(router.route(for: clientProtocol)?.provider.id, "ds") + XCTAssertEqual( + router.route(for: clientProtocol)?.wireProtocol, + clientProtocol.passthroughProtocol + ) + XCTAssertFalse(router.requiresConversion(for: clientProtocol), clientProtocol.rawValue) + } + // The primary — the address that would take an unmatched caller — heads the list. + XCTAssertEqual(router.primaryRoute?.wireProtocol, .anthropic) + } + + /// Binding two of three is the on-demand case: the caller the vendor publishes nothing for is + /// the only one converted. + func testAProviderBindingTwoAddressesConvertsOnlyTheThirdCaller() { + var deepSeek = provider("ds", .anthropic, primary: "deepseek-chat") + deepSeek.baseUrl = "https://api.deepseek.com" + deepSeek.protocolUrls = [ + "anthropic": "https://api.deepseek.com/anthropic", + "openai-chat": "https://api.deepseek.com/chat/completions", + ] + let router = GatewayProtocolRouter(config: config([deepSeek])) + + XCTAssertFalse(router.servesEveryProtocolDirectly) + XCTAssertFalse(router.requiresConversion(for: .anthropic)) + XCTAssertFalse(router.requiresConversion(for: .openAIChat)) + XCTAssertTrue( + router.requiresConversion(for: .openAIResponses), + "a Codex client has no Responses upstream here, so Bifrost has to translate" + ) + XCTAssertEqual(router.route(for: .openAIResponses)?.wireProtocol, .anthropic) + } + + /// Failover is between providers. A provider contributing three routes must not occupy the + /// whole fallback chain with itself, or the second provider in the queue never gets tried. + func testFallbacksStillWalkTheProviderQueueWhenOneProviderBindsEveryProtocol() throws { + var multi = provider("multi", .anthropic, primary: "multi-up") + multi.protocolUrls = [ + "anthropic": "https://multi.example.com/anthropic", + "openai-chat": "https://multi.example.com/chat/completions", + "openai-responses": "https://multi.example.com/responses", + ] + let config = config([multi, chatProvider]) + let router = GatewayProtocolRouter(config: config) + let routing = LegacyModelRoutingCompatibility(config: config) + let backup = try XCTUnwrap( + router.routes.first { $0.provider.id == "chat" }?.bifrostName + ) + let pinned = try XCTUnwrap(router.routes.first { + $0.provider.id == "multi" && $0.wireProtocol == .openAIResponses + }?.bifrostName) + + let route = routing.resolve("claude-sonnet-5", clientProtocol: .openAIResponses) + XCTAssertEqual(route?.pinnedProviderName, pinned) + XCTAssertEqual( + route?.fallbackModels, ["\(backup)/chat-up"], + "the other provider is the fallback; the same provider's other addresses are not" + ) + } + + /// A provider whose per-protocol addresses were never filled in keeps the single upstream it + /// has always had, addressed by its base URL. + func testAProviderSavedBeforePerProtocolAddressesKeepsItsSingleUpstream() { + let legacy = provider("legacy", .openAIChat, primary: "chat-up") + XCTAssertTrue(legacy.protocolUrls.isEmpty) + XCTAssertEqual(legacy.configuredProtocols, [.openAIChat]) + XCTAssertEqual(legacy.upstreamURL(for: .openAIChat), "https://legacy.example.com") + XCTAssertNil(legacy.upstreamURL(for: .anthropic)) + + let router = GatewayProtocolRouter(config: config([legacy])) + XCTAssertEqual(router.routes.count, 1) + XCTAssertFalse(router.pinsProviderName) + XCTAssertTrue(router.requiresConversion(for: .anthropic)) + } + func testNoConfiguredProviderProducesNoRoute() { let router = GatewayProtocolRouter(config: AppConfig()) XCTAssertNil(router.route(for: .anthropic)) diff --git a/native/Tests/Unit/GatewayUpstreamURLTests.swift b/native/Tests/Unit/GatewayUpstreamURLTests.swift index 5a0e84aa..5ea5da6e 100644 --- a/native/Tests/Unit/GatewayUpstreamURLTests.swift +++ b/native/Tests/Unit/GatewayUpstreamURLTests.swift @@ -5,10 +5,10 @@ import XCTest /// One character of path decided whether the gateway worked at all. /// /// Bifrost builds the whole upstream path and inserts its own `/v1`. Every provider preset ships -/// the base URL its vendor documents, and those already end where the vendor's version segment +/// the addresses its vendor documents, and those already end where the vendor's version segment /// begins. Handed over untouched, that produced `…/v1/v1/messages`, which every upstream answers -/// with 404 — for seventy-one of the seventy-two presets. The connection test did not catch it, -/// because it computed its own URL and happened to compute the right one. +/// with 404 — for seventy-one of the seventy-two presets shipping at the time. The connection +/// test did not catch it, because it computed its own URL and happened to compute the right one. final class GatewayUpstreamURLTests: XCTestCase { func testABaseEndingInTheSegmentBifrostAddsIsClassifiedAsSuch() { for base in [ @@ -119,35 +119,135 @@ final class GatewayUpstreamURLTests: XCTestCase { /// What the generated configuration and the connection test each resolve to must be the same /// URL. They disagreeing is how a provider could test healthy and then answer every real /// request with 404. - func testEveryShippedPresetResolvesIdenticallyForTheGatewayAndTheProbe() { + func testEveryShippedPresetEndpointResolvesIdenticallyForTheGatewayAndTheProbe() { let presets = ProviderPreset.all.filter { !$0.baseURL.isEmpty } - XCTAssertGreaterThan(presets.count, 60, "the catalog should not have quietly emptied") + XCTAssertGreaterThan(presets.count, 15, "the catalog should not have quietly emptied") for preset in presets { - let bifrostBase = GatewayUpstreamURL.bifrostBaseURL(for: preset.baseURL) - let overrides = GatewayUpstreamURL.requestPathOverrides( - for: preset.wireProtocol, baseURL: preset.baseURL - ) - let defaultPath: String - let overrideKey: String - switch preset.wireProtocol { - case .anthropic: - defaultPath = "/v1/messages" - overrideKey = "chat_completion" - case .openAIChat: - defaultPath = "/v1/chat/completions" - overrideKey = "chat_completion" - case .openAIResponses: - defaultPath = "/v1/responses" - overrideKey = "responses" + for (wireProtocol, address) in preset.resolvedEndpoints { + let upstream = GatewayUpstreamURL.upstream(for: wireProtocol, url: address) + let overrideKey = wireProtocol == .openAIResponses + ? "responses" : "chat_completion" + let defaultPath: String + switch wireProtocol { + case .anthropic: defaultPath = "/v1/messages" + case .openAIChat: defaultPath = "/v1/chat/completions" + case .openAIResponses: defaultPath = "/v1/responses" + } + let gateway = upstream.baseURL + + (upstream.requestPathOverrides[overrideKey] ?? defaultPath) + XCTAssertEqual( + gateway, upstream.inferenceURL?.absoluteString, + "\(preset.name) \(wireProtocol.rawValue)" + ) + XCTAssertFalse( + gateway.contains("/v1/v1/"), + "\(preset.name) would ask \(address) for a doubled version segment" + ) } - let gateway = bifrostBase + (overrides[overrideKey] ?? defaultPath) - let probe = GatewayUpstreamURL.endpointURL( - baseURL: preset.baseURL, wireProtocol: preset.wireProtocol - )?.absoluteString - XCTAssertEqual(gateway, probe, preset.name) - XCTAssertFalse( - gateway.contains("/v1/v1/"), - "\(preset.name) would ask \(preset.baseURL) for a doubled version segment" + } + } + + /// The address field takes whichever spelling the vendor's own documentation gives, and a + /// vendor publishing `/chat/completions` need not also answer `/v1/chat/completions` — so an + /// endpoint spelling is called exactly as typed rather than rebuilt with Bifrost's `/v1`. + func testAnEndpointSpellingIsCalledExactlyAsTyped() { + let chat = GatewayUpstreamURL.upstream( + for: .openAIChat, url: "https://api.deepseek.com/chat/completions" + ) + XCTAssertEqual(chat.baseURL, "https://api.deepseek.com") + XCTAssertEqual(chat.inferenceURL?.absoluteString, "https://api.deepseek.com/chat/completions") + XCTAssertEqual(chat.requestPathOverrides["chat_completion"], "/chat/completions") + XCTAssertEqual(chat.requestPathOverrides["list_models"], "/models") + + // A `/v1` the user typed belongs to the upstream, so it stays on every sibling path too. + let responses = GatewayUpstreamURL.upstream( + for: .openAIResponses, url: "https://api.openai.com/v1/responses" + ) + XCTAssertEqual(responses.baseURL, "https://api.openai.com") + XCTAssertEqual( + responses.inferenceURL?.absoluteString, "https://api.openai.com/v1/responses" + ) + XCTAssertEqual(responses.requestPathOverrides["responses"], "/v1/responses") + XCTAssertEqual(responses.requestPathOverrides["list_models"], "/v1/models") + + let messages = GatewayUpstreamURL.upstream( + for: .anthropic, url: "https://api.deepseek.com/anthropic/v1/messages" + ) + XCTAssertEqual(messages.baseURL, "https://api.deepseek.com/anthropic") + XCTAssertEqual(messages.requestPathOverrides["chat_completion"], "/v1/messages") + } + + /// A base spelling keeps the original reconciliation, which is what every provider saved + /// before per-protocol addresses existed still depends on. + func testABaseSpellingKeepsTheOriginalReconciliation() { + let glm = GatewayUpstreamURL.upstream( + for: .anthropic, url: "https://open.bigmodel.cn/api/anthropic/v1" + ) + XCTAssertEqual(glm.baseURL, "https://open.bigmodel.cn/api/anthropic") + XCTAssertTrue(glm.requestPathOverrides.isEmpty) + XCTAssertEqual( + glm.inferenceURL?.absoluteString, + "https://open.bigmodel.cn/api/anthropic/v1/messages" + ) + + let deepSeek = GatewayUpstreamURL.upstream( + for: .anthropic, url: "https://api.deepseek.com/anthropic" + ) + XCTAssertEqual(deepSeek.baseURL, "https://api.deepseek.com/anthropic") + XCTAssertEqual( + deepSeek.inferenceURL?.absoluteString, + "https://api.deepseek.com/anthropic/v1/messages" + ) + + // An address with nothing in it resolves to nothing, and the configuration builder + // rejects it rather than writing an upstream with no host. + for unusable in ["", " ", "\n"] { + let upstream = GatewayUpstreamURL.upstream(for: .anthropic, url: unusable) + XCTAssertEqual(upstream, GatewayUpstreamURL.Upstream.unusable, unusable) + XCTAssertTrue(upstream.baseURL.isEmpty, unusable) + XCTAssertNil(upstream.inferenceURL, unusable) + } + // A host that is only a scheme must not be shortened into one: `https://messages` ends + // with the Anthropic inference path without being an endpoint. + let schemeOnly = GatewayUpstreamURL.upstream(for: .anthropic, url: "https://messages") + XCTAssertEqual(schemeOnly.baseURL, "https://messages") + XCTAssertEqual( + schemeOnly.inferenceURL?.absoluteString, "https://messages/v1/messages" + ) + } + + /// What the editor offers for a protocol the user has not bound yet. + func testAddressesAreOfferedOnlyForABaseThatIsActuallyBare() { + let base = "https://api.deepseek.com" + XCTAssertEqual( + GatewayUpstreamURL.derivedURL(for: .anthropic, base: base), + "https://api.deepseek.com/anthropic" + ) + XCTAssertEqual( + GatewayUpstreamURL.derivedURL(for: .openAIChat, base: base), + "https://api.deepseek.com/chat/completions" + ) + XCTAssertEqual( + GatewayUpstreamURL.derivedURL(for: .openAIResponses, base: "\(base)/"), + "https://api.deepseek.com/responses" + ) + // Already an Anthropic base: offering `…/anthropic/anthropic` would be nonsense. + XCTAssertEqual( + GatewayUpstreamURL.derivedURL(for: .anthropic, base: "\(base)/anthropic"), + "https://api.deepseek.com/anthropic" + ) + // A base pointed at one specific API cannot have a sibling path guessed off it. + for pointed in [ + "https://api.anthropic.com/v1", + "https://open.bigmodel.cn/api/paas/v4", + "https://generativelanguage.googleapis.com/v1beta/openai", + "https://api.deepseek.com/chat/completions", + "", + "not a url", + "ftp://api.deepseek.com", + ] { + XCTAssertNil( + GatewayUpstreamURL.derivedURL(for: .openAIChat, base: pointed), pointed ) } } diff --git a/native/Tests/Unit/ProviderHeroUsageTests.swift b/native/Tests/Unit/ProviderHeroUsageTests.swift index 99de36fb..2323b704 100644 --- a/native/Tests/Unit/ProviderHeroUsageTests.swift +++ b/native/Tests/Unit/ProviderHeroUsageTests.swift @@ -2,44 +2,63 @@ import XCTest @testable import CCBuddy final class ProviderHeroUsageTests: XCTestCase { - func testEditorPlaceholderIsARealCatalogEndpoint() throws { - // The placeholder is the shape people copy when filling the field by hand, so it has to be a - // live endpoint in the catalog rather than a URL that drifted out of date on its own. + func testEditorPlaceholderIsARealCatalogRoot() throws { + // The placeholder is the shape people copy when filling the field by hand. The field now + // takes a bare root, so the placeholder has to be one a real vendor answers on rather than + // one protocol's endpoint. XCTAssertTrue( ProviderPreset.all.contains { $0.baseURL == ProviderEditorLayout.apiURLPlaceholder }, - "placeholder \(ProviderEditorLayout.apiURLPlaceholder) is not any preset's endpoint" + "placeholder \(ProviderEditorLayout.apiURLPlaceholder) is not any preset's root" + ) + XCTAssertNotNil( + GatewayUpstreamURL.derivedURL( + for: .anthropic, base: ProviderEditorLayout.apiURLPlaceholder + ), + "the placeholder must be bare enough for the editor to derive addresses from" ) } - /// cc-switch stores the base URL a *client* appends `/v1/messages` to; this app stores the base - /// the gateway appends `/messages` to. Porting the catalog verbatim would have shipped seventy - /// presets that each 404 until the user happened to press "test" and let the probe repair them. - func testAnthropicEndpointsCarryTheVersionSegmentThisAppExpects() { - for preset in ProviderPreset.all - where preset.wireProtocol == .anthropic && !preset.baseURL.isEmpty { - let segments = preset.baseURL.split(separator: "/").map(String.init) - let last = try? XCTUnwrap(segments.last) + /// cc-switch stores the base URL a *client* appends `/v1/messages` to; this app stores the + /// address the gateway is pointed at and resolves the rest. Porting the catalog verbatim would + /// have shipped presets that each 404 until the user happened to press "test" and let the + /// probe repair them. + func testEveryAnthropicEndpointResolvesToASingleVersionedMessagesPath() throws { + for preset in ProviderPreset.all { + guard let address = preset.resolvedEndpoints[.anthropic], !address.isEmpty else { + continue + } + let resolved = try XCTUnwrap( + GatewayUpstreamURL.upstream(for: .anthropic, url: address).inferenceURL, + preset.name + ) XCTAssertTrue( - (last?.first == "v") && (last?.dropFirst().first?.isNumber == true), - "\(preset.name) endpoint is missing its version segment: \(preset.baseURL)" + resolved.path.hasSuffix("/messages"), + "\(preset.name) does not resolve to a Messages endpoint: \(resolved)" + ) + let versions = resolved.path.split(separator: "/").filter { + $0.first == "v" && $0.dropFirst().first?.isNumber == true + } + XCTAssertEqual( + versions.count, 1, + "\(preset.name) resolves to \(resolved), which is not versioned exactly once" ) } } - func testTheProvidersTheHandWrittenListCoveredSurvivedThePort() throws { - // Every vendor the old ten-entry table reached must still be reachable, under whichever name - // the upstream catalog uses for it. + func testTheFirstPartyVendorsPeopleReachForAreStillInTheCatalog() throws { + // Aggregators and resellers were dropped on purpose; the vendors running their own models + // must all still be reachable, under whichever name the catalog uses for them. let required = [ "https://open.bigmodel.cn/api/anthropic/v1", - "https://api.deepseek.com/anthropic/v1", - "https://api.openai.com/v1", - "https://openrouter.ai/api/v1", - "https://integrate.api.nvidia.com/v1", + "https://api.deepseek.com/anthropic", + "https://api.deepseek.com/chat/completions", + "https://api.openai.com/v1/responses", + "https://api.anthropic.com/v1", "https://generativelanguage.googleapis.com/v1beta/openai", ] - let endpoints = Set(ProviderPreset.all.map(\.baseURL)) - for endpoint in required { - XCTAssertTrue(endpoints.contains(endpoint), "lost \(endpoint) in the catalog port") + let addresses = Set(ProviderPreset.all.flatMap { $0.resolvedEndpoints.values }) + for address in required { + XCTAssertTrue(addresses.contains(address), "lost \(address) from the catalog") } XCTAssertTrue(ProviderPreset.all.contains { $0.name.localizedCaseInsensitiveContains("kimi") }) XCTAssertTrue(ProviderPreset.all.contains { $0.name.localizedCaseInsensitiveContains("minimax") }) diff --git a/native/Tests/Unit/ProviderModelDiscoveryTests.swift b/native/Tests/Unit/ProviderModelDiscoveryTests.swift index fc083cbf..5e533544 100644 --- a/native/Tests/Unit/ProviderModelDiscoveryTests.swift +++ b/native/Tests/Unit/ProviderModelDiscoveryTests.swift @@ -32,6 +32,44 @@ final class ProviderModelDiscoveryTests: XCTestCase { } } + /// The catalog lives with the API whose models are being bound, not necessarily at the + /// provider root: `https://api.kimi.com/coding/v1` lists models `https://api.kimi.com` does + /// not. Both are tried, most specific first. + func testListingLooksAtTheProtocolAddressBeforeTheProviderRoot() { + let provider = Provider( + baseUrl: "https://api.kimi.com", + protocolUrls: ["anthropic": "https://api.kimi.com/coding/v1"], + protocol: .anthropic + ) + XCTAssertEqual( + ProviderModelDiscoveryService.listingRoots(for: provider, wireProtocol: .anthropic), + ["https://api.kimi.com/coding", "https://api.kimi.com"] + ) + + // An endpoint spelling resolves to the root that serves it, which is where /models lives. + let deepSeek = Provider( + baseUrl: "https://api.deepseek.com", + protocolUrls: ["openai-chat": "https://api.deepseek.com/chat/completions"], + protocol: .openAIChat + ) + XCTAssertEqual( + ProviderModelDiscoveryService.listingRoots(for: deepSeek, wireProtocol: .openAIChat), + ["https://api.deepseek.com"], + "the protocol address and the provider root collapse to one probe, not two" + ) + + // A provider saved before per-protocol addresses still has exactly its base URL. + let legacy = Provider(baseUrl: "https://api.example.com/v1", protocol: .anthropic) + XCTAssertEqual( + ProviderModelDiscoveryService.listingRoots(for: legacy, wireProtocol: .anthropic), + ["https://api.example.com/v1"] + ) + XCTAssertTrue( + ProviderModelDiscoveryService.listingRoots(for: Provider(), wireProtocol: .anthropic) + .isEmpty + ) + } + func testEveryCatalogShapeTheseEndpointsAreServedInIsParsed() { let openAI = #"{"object":"list","data":[{"id":"gpt-5.4"},{"id":"gpt-5.4-mini"}]}"# XCTAssertEqual( diff --git a/native/Tests/Unit/ProviderPresetCatalogTests.swift b/native/Tests/Unit/ProviderPresetCatalogTests.swift index 50f159cc..5328b42b 100644 --- a/native/Tests/Unit/ProviderPresetCatalogTests.swift +++ b/native/Tests/Unit/ProviderPresetCatalogTests.swift @@ -21,9 +21,62 @@ final class ProviderPresetCatalogTests: XCTestCase { preset.baseURL.hasPrefix("https://"), "\(preset.name) would carry its API key over plaintext: \(preset.baseURL)" ) + for (wireProtocol, endpoint) in preset.resolvedEndpoints { + XCTAssertTrue( + endpoint.hasPrefix("https://"), + "\(preset.name) \(wireProtocol.rawValue): \(endpoint)" + ) + } } } + /// The base URL is the root the editor derives from and discovery falls back to, so it must + /// not itself be one protocol's endpoint — that is what the per-protocol addresses are for. + func testBaseURLsCarryNoProtocolSpecificPath() { + for preset in ProviderPreset.all where !preset.baseURL.isEmpty { + for suffix in ["/messages", "/chat/completions", "/responses", "/anthropic"] { + XCTAssertFalse( + preset.baseURL.hasSuffix(suffix), + "\(preset.name) put a protocol path in its base URL: \(preset.baseURL)" + ) + } + } + } + + /// Every preset names the endpoint that also takes the callers it publishes nothing for. + func testThePrimaryProtocolIsAlwaysOneOfTheBoundEndpoints() { + for preset in ProviderPreset.all where !preset.baseURL.isEmpty { + XCTAssertNotNil( + preset.resolvedEndpoints[preset.wireProtocol], + "\(preset.name) would convert every caller onto an address it does not bind" + ) + } + } + + /// Applying a preset is what puts a provider into the "three of three" or "one of three" + /// state the gateway routes on, so the addresses have to survive the copy. + func testApplyingAPresetBindsEveryEndpointItPublishes() throws { + let deepSeek = try XCTUnwrap(ProviderPreset.all.first { $0.id == "deepseek" }) + var draft = Provider( + baseUrl: "https://old", protocolUrls: ["openai-responses": "https://old/responses"] + ) + deepSeek.apply(to: &draft) + + XCTAssertEqual(draft.baseUrl, "https://api.deepseek.com") + XCTAssertEqual( + draft.upstreamURL(for: .anthropic), "https://api.deepseek.com/anthropic" + ) + XCTAssertEqual( + draft.upstreamURL(for: .openAIChat), "https://api.deepseek.com/chat/completions" + ) + XCTAssertNil( + draft.upstreamURL(for: .openAIResponses), + "the previous provider's address must not survive into this one" + ) + XCTAssertEqual(draft.configuredProtocols, [.anthropic, .openAIChat]) + XCTAssertFalse(draft.servesEveryProtocolDirectly) + } + func testWebsiteLinksCarryNoReferralParameters() { for preset in ProviderPreset.all { XCTAssertFalse( @@ -38,9 +91,15 @@ final class ProviderPresetCatalogTests: XCTestCase { XCTAssertEqual(endpointless.map(\.id), ["custom"]) } - func testCatalogIsSubstantiallyLargerThanTheOldHandWrittenList() { - // The hand-written list had ten entries and covered almost nothing people actually use. - XCTAssertGreaterThan(ProviderPreset.all.count, 50) + func testCatalogListsOnlyFirstPartyVendors() { + // Aggregators and resellers were dropped: their endpoints move, their protocol support is + // whatever their own upstream exposes that week, and listing them implied vetting this + // app cannot do. What is left is vendors serving their own models. + XCTAssertGreaterThan(ProviderPreset.all.count, 15) + XCTAssertEqual(Set(ProviderPreset.all.map(\.category)), [.official, .vendor, .custom]) + for removed in ["packycode", "openrouter", "aihubmix", "siliconflow", "dmxapi"] { + XCTAssertNil(ProviderPreset.all.first { $0.id == removed }, removed) + } } func testGroupingCoversEveryNonCustomPresetExactlyOnce() { @@ -65,6 +124,10 @@ final class ProviderPresetCatalogTests: XCTestCase { let byModel = ProviderPreset.all.filter { $0.matches("kimi-for-coding") } XCTAssertFalse(byModel.isEmpty, "searching by model id should find the vendor") + + // The address people have in hand is often one protocol's endpoint, not the root. + let byEndpoint = ProviderPreset.all.filter { $0.matches("api/paas/v4") } + XCTAssertEqual(byEndpoint.map(\.id).sorted(), ["zhipu-glm", "zhipu-glm-en"]) } func testEmptyQueryReturnsEveryGroup() { @@ -81,6 +144,7 @@ final class ProviderPresetCatalogTests: XCTestCase { XCTAssertEqual(draft.name, preset.name) XCTAssertEqual(draft.baseUrl, preset.baseURL) + XCTAssertEqual(draft.configuredUpstreamURLs, preset.resolvedEndpoints) XCTAssertEqual(draft.defaultModel, preset.defaultModel) XCTAssertEqual(draft.smallFastModel, preset.smallModel) XCTAssertEqual(draft.protocol, preset.wireProtocol) diff --git a/native/Tests/Unit/ProviderProbeServiceTests.swift b/native/Tests/Unit/ProviderProbeServiceTests.swift index 3547dfb7..15e7ec88 100644 --- a/native/Tests/Unit/ProviderProbeServiceTests.swift +++ b/native/Tests/Unit/ProviderProbeServiceTests.swift @@ -163,6 +163,62 @@ final class ProviderProbeServiceTests: XCTestCase { XCTAssertEqual(requestCount, 0) } + /// A provider binds up to three addresses, and they fail independently — so the test has to + /// name which one it is testing rather than reporting one verdict for the provider. + func testEachBoundAddressIsTestedOnItsOwnEndpoint() async throws { + var provider = Provider( + name: "DeepSeek", + baseUrl: "https://provider.example", + protocolUrls: [ + "anthropic": "https://provider.example/anthropic", + "openai-chat": "https://provider.example/chat/completions", + ], + authToken: "token", + defaultModel: "m", + protocol: .anthropic + ) + let cases: [(Provider.WireProtocol, String)] = [ + (.anthropic, "/anthropic/v1/messages"), + (.openAIChat, "/chat/completions"), + ] + for (wireProtocol, expectedPath) in cases { + var targets: [String] = [] + ProviderProbeURLProtocol.handler = { request in + targets.append(request.url?.path ?? "") + return wireProtocol == .anthropic + ? (200, #"{"model":"m","type":"message"}"#) + : (200, #"{"model":"m","choices":[]}"#) + } + + let result = await ProviderProbeService(session: makeSession()).test( + provider, + wireProtocol: wireProtocol, + insecureSkipVerify: false + ) + + XCTAssertTrue(result.succeeded, wireProtocol.rawValue) + XCTAssertEqual(targets, [expectedPath], wireProtocol.rawValue) + } + + // Omitting the protocol tests the primary — the address unmatched callers fall to. + var targets: [String] = [] + ProviderProbeURLProtocol.handler = { request in + targets.append(request.url?.path ?? "") + return (200, #"{"model":"m","type":"message"}"#) + } + _ = await ProviderProbeService(session: makeSession()).test( + provider, insecureSkipVerify: false + ) + XCTAssertEqual(targets, ["/anthropic/v1/messages"]) + + // A protocol this provider does not bind has no address to test. + provider.protocolUrls.removeValue(forKey: "openai-chat") + let unbound = await ProviderProbeService(session: makeSession()).test( + provider, wireProtocol: .openAIChat, insecureSkipVerify: false + ) + XCTAssertEqual(unbound.reason, .baseURLEmpty) + } + private func makeSession() -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [ProviderProbeURLProtocol.self] From 1a6d334e728903f6810233167680af3aa7f6155c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:07:04 +0000 Subject: [PATCH 2/3] Invalidate the model catalog when a bound address changes 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 Claude-Session: https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ --- native/Sources/Views/Providers/ProviderEditorView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/native/Sources/Views/Providers/ProviderEditorView.swift b/native/Sources/Views/Providers/ProviderEditorView.swift index 7b53e163..9aa3a691 100644 --- a/native/Sources/Views/Providers/ProviderEditorView.swift +++ b/native/Sources/Views/Providers/ProviderEditorView.swift @@ -93,6 +93,10 @@ struct ProviderEditorView: View { modelCatalog = .unknown rederiveOfferedAddresses(to: newValue) } + // Discovery reads the bound address, not the root, so editing that address is what makes + // the previous answer meaningless — without this an `.unsupported` verdict from one host + // would go on greying out the control against the next one. + .onChange(of: draft.protocolUrls) { _ in modelCatalog = .unknown } .onChange(of: editingProtocol) { _ in modelCatalog = .unknown testMessage = nil From 69a161f1d595c6ed26bce2c2c518bc484ad0b4d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:21:55 +0000 Subject: [PATCH 3/3] Collapse a provider root that names the same API as its bound address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GbEBP29dNMEbw6LcVeucnQ --- .../ProviderModelDiscoveryService.swift | 6 +++++- .../Unit/ProviderModelDiscoveryTests.swift | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/native/Sources/Services/ProviderModelDiscoveryService.swift b/native/Sources/Services/ProviderModelDiscoveryService.swift index cff3447c..c66cf577 100644 --- a/native/Sources/Services/ProviderModelDiscoveryService.swift +++ b/native/Sources/Services/ProviderModelDiscoveryService.swift @@ -178,7 +178,11 @@ struct ProviderModelDiscoveryService: Sendable { provider.upstreamURL(for: wireProtocol).map { GatewayUpstreamURL.upstream(for: wireProtocol, url: $0).baseURL }, - provider.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines), + // Reconciled the same way the bound address is, so a provider whose root and bound + // address name the same API contributes one root instead of two. Compared raw, + // `https://host/v1` and the `https://host` it resolves to read as different roots, + // and the second one's candidates put `…/v1/v1/models` back on the probe list. + GatewayUpstreamURL.bifrostBaseURL(for: provider.baseUrl), ] var seen = Set() return addresses.compactMap { $0 }.filter { !$0.isEmpty && seen.insert($0).inserted } diff --git a/native/Tests/Unit/ProviderModelDiscoveryTests.swift b/native/Tests/Unit/ProviderModelDiscoveryTests.swift index 5e533544..66012de0 100644 --- a/native/Tests/Unit/ProviderModelDiscoveryTests.swift +++ b/native/Tests/Unit/ProviderModelDiscoveryTests.swift @@ -58,12 +58,23 @@ final class ProviderModelDiscoveryTests: XCTestCase { "the protocol address and the provider root collapse to one probe, not two" ) - // A provider saved before per-protocol addresses still has exactly its base URL. + // A provider saved before per-protocol addresses names one API through its base URL, and + // the pair has to collapse: probed as two roots, the second contributes the + // `…/v1/v1/models` spelling the candidate ordering exists to keep off the list. let legacy = Provider(baseUrl: "https://api.example.com/v1", protocol: .anthropic) + let roots = ProviderModelDiscoveryService.listingRoots( + for: legacy, wireProtocol: .anthropic + ) + XCTAssertEqual(roots, ["https://api.example.com"]) + let probes = roots + .flatMap { ProviderModelDiscoveryService.candidateURLs(baseURL: $0) } + .map(\.absoluteString) XCTAssertEqual( - ProviderModelDiscoveryService.listingRoots(for: legacy, wireProtocol: .anthropic), - ["https://api.example.com/v1"] + probes, + ["https://api.example.com/v1/models", "https://api.example.com/models"], + "the address this provider has always used stays the first thing asked" ) + XCTAssertFalse(probes.contains { $0.contains("/v1/v1/") }) XCTAssertTrue( ProviderModelDiscoveryService.listingRoots(for: Provider(), wireProtocol: .anthropic) .isEmpty