diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index adc970bc81..51c049b3e7 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -40,7 +40,7 @@ the admin token (`OPENCODEX_ADMIN_AUTH_TOKEN`, or the auto-generated | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | +| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | @@ -129,7 +129,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | | `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | | `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | -| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, or remove providers. | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, set the default, or remove providers. `PATCH` uses standalone `{ "setDefault": true }` on an enabled provider; `POST` may include `setDefault` when creating/replacing (also enabled-only). Deleting the current default reassigns to the first remaining enabled provider when one exists; otherwise the API returns `409` with `code: "last_provider"` and keeps the current default. | | `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | | `GET /api/selected-models` · `PUT /api/model-visibility` | Read provider allowlists and atomically change the final visibility of one model or provider group. | | `GET /api/key-providers` · `GET /api/oauth/providers` | Read the API-key and OAuth provider catalogs. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 38bd2086bf..82229f0285 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | | **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | -| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | +| **プロバイダー** | プロバイダーを追加、編集、既定に設定(有効なプロバイダーのみ)、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。現在の既定を削除すると、残っている最初の有効なプロバイダーに切り替わります(存在する場合)。なければ削除は拒否され、現在の既定は保持されます。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | @@ -105,7 +105,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | | `GET` / `PUT /api/injection-model` | 委任ガイダンスのモデル/effort、ガイダンストグル、Codex ネイティブサブエージェント既定値の同期トグルを読み取りまたは変更します。 | | `GET` / `PUT /api/v2` | サーフェスモード、Codex 機能フラグ、v2 スレッド上限を読むか変えます。 | -| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、削除。 | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、既定設定、削除。`PATCH` は有効なプロバイダーに対して `{ "setDefault": true }` のみ。`POST` は作成/差替時に `setDefault` を含められ、こちらも有効なプロバイダーのみ。現在の既定を削除すると、残っている最初の有効なプロバイダーに再割当てします(存在する場合)。なければ `409`(`code: "last_provider"`)を返し、現在の既定を保持します。 | | `GET /api/models` · `PUT /api/disabled-models` | ネイティブ/ルーティングモデル行を参照し共有 disabled model 一覧を更新します。 | | `GET /api/selected-models` · `PUT /api/model-visibility` | プロバイダー allowlist を読み取り、モデルまたはプロバイダーグループの最終表示状態を原子的に変更します。 | | `GET /api/key-providers` · `GET /api/oauth/providers` | API キーおよび OAuth プロバイダーカタログを読みます。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 63ef1b0daf..b76815805b 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | -| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | +| **Providers** | 프로바이더를 추가, 편집, 기본으로 설정(활성만), 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. 현재 기본 프로바이더를 제거하면 남아 있는 첫 번째 활성 프로바이더로 전환됩니다(있는 경우); 없으면 삭제가 거부되고 현재 기본이 유지됩니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | @@ -107,7 +107,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | | `GET` / `PUT /api/injection-model` | 위임 가이드의 모델/강도, 가이드 토글, Codex 네이티브 서브에이전트 기본값 동기화 토글을 읽거나 바꿉니다. | | `GET` / `PUT /api/v2` | 서피스 모드, Codex 기능 플래그, v2 thread 상한을 읽거나 바꿉니다. | -| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 제거. | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 기본 설정, 제거. `PATCH`는 활성 프로바이더에 `{ "setDefault": true }`만 보냅니다. `POST`는 생성/교체 시 `setDefault`를 함께 보낼 수 있으며 역시 활성만 허용합니다. 현재 기본을 삭제하면 남아 있는 첫 번째 활성 프로바이더로 재지정됩니다(있는 경우); 없으면 `409`(`code: "last_provider"`)를 반환하고 현재 기본을 유지합니다. | | `GET /api/models` · `PUT /api/disabled-models` | 네이티브/라우팅 모델 행을 조회하고 공용 disabled model 목록을 갱신합니다. | | `GET /api/selected-models` · `PUT /api/model-visibility` | 프로바이더 allowlist를 읽고 개별 모델 또는 프로바이더 그룹의 최종 노출 상태를 원자적으로 변경합니다. | | `GET /api/key-providers` · `GET /api/oauth/providers` | API key 및 OAuth 프로바이더 카탈로그를 읽습니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index d7a4c97d20..28e1c03bb7 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | -| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | +| **Providers** | Добавление, редактирование, назначение провайдера по умолчанию (только включённые), включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. При удалении текущего провайдера по умолчанию выбирается первый оставшийся включённый провайдер, если он есть; иначе удаление отклоняется и текущий default сохраняется. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | @@ -111,7 +111,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `GET` / `PUT /api/sidecar-settings` | Чтение или настройка моделей сайдкаров поиска/vision. | | `GET` / `PUT /api/injection-model` | Чтение или настройка модели/уровня руководства по делегированию, его переключателя и переключателя синхронизации нативных значений подагентов Codex по умолчанию. | | `GET` / `PUT /api/v2` | Чтение или настройка режима поверхности, фиче-флага Codex и лимита потоков v2. | -| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение или удаление провайдеров. | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение, назначение default или удаление. `PATCH` задаёт default отдельным `{ "setDefault": true }` для включённого провайдера; `POST` может включать `setDefault` при создании/замене (тоже только для включённых). Удаление текущего default переназначает на первый оставшийся включённый провайдер, если он есть; иначе API возвращает `409` с `code: "last_provider"` и сохраняет текущий default. | | `GET /api/models` · `PUT /api/disabled-models` | Список строк нативных/маршрутизируемых моделей и обновление общего набора отключённых моделей. | | `GET /api/selected-models` · `PUT /api/model-visibility` | Чтение allowlist провайдера и атомарное изменение итоговой видимости модели или группы провайдера. | | `GET /api/key-providers` · `GET /api/oauth/providers` | Чтение каталогов провайдеров с API-ключом и OAuth. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 85dc44d9b0..672449473b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -31,7 +31,7 @@ bun run dev:gui | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | -| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | +| **Providers** | 添加、编辑、设为默认(仅已启用)、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。删除当前默认时,会切换到剩余的第一个已启用 provider(若存在);否则拒绝删除并保留当前默认。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | @@ -99,7 +99,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | | `GET` / `PUT /api/injection-model` | 读取或设置委派指引模型/强度、指引开关及 Codex 原生子代理默认值同步开关。 | | `GET` / `PUT /api/v2` | 读取或设置界面模式、Codex feature flag 和 v2 thread 上限。 | -| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 列出、添加/替换、启用/禁用或删除 provider。 | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 列出、添加/替换、启用/禁用、设为默认或删除 provider。`PATCH` 用单独的 `{ "setDefault": true }`(仅已启用);`POST` 创建/替换时也可带 `setDefault`(同样仅已启用)。删除当前默认时,会改派到剩余的第一个已启用 provider(若存在);否则返回 `409`(`code: "last_provider"`)并保留当前默认。 | | `GET /api/models` · `PUT /api/disabled-models` | 列出原生/路由模型,并更新共享的 disabled-model 集合。 | | `GET /api/selected-models` · `PUT /api/model-visibility` | 读取 provider allowlist,并原子地更改单个模型或 provider 分组的最终可见状态。 | | `GET /api/key-providers` · `GET /api/oauth/providers` | 读取 API key 和 OAuth provider 目录。 | diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index e825dcee68..d5bde092da 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -52,6 +52,7 @@ export default function ProviderDetails({ isDefault, onRemoveProvider, onSetDisabled, + onSetDefault, }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; @@ -82,6 +83,7 @@ export default function ProviderDetails({ isDefault?: boolean; onRemoveProvider?: (name: string) => void; onSetDisabled?: (name: string, disabled: boolean) => void; + onSetDefault?: (name: string) => void; }) { const t = useT(); const [tab, setTab] = useState("overview"); @@ -155,6 +157,11 @@ export default function ProviderDetails({
+ {!isDefault && !isDisabled && onSetDefault && ( + + )} {onRemoveProvider && ( diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 3cea9d24ba..e9d157b366 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -86,6 +86,8 @@ export interface ProviderAuthHandlers { } export type ProviderUpdatePatch = { + /** A standalone routing change; the management API rejects combinations with edits. */ + setDefault?: true; adapter?: string; baseUrl?: string; defaultModel?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 7ab70985a2..c5a26b1a29 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -301,7 +301,16 @@ export const de: Record = { "prov.logoutOk": "Von {provider} abgemeldet.", "prov.logoutFail": "Abmeldung von {provider} fehlgeschlagen. Der Kontostatus bleibt unverändert.", "prov.removed": "\"{name}\" entfernt.", + "prov.removedDefault": "\"{name}\" entfernt. Standardanbieter ist jetzt \"{defaultProvider}\".", "prov.removeFail": "\"{name}\" konnte nicht entfernt werden.", + "prov.removeLastProvider": "Der Standardanbieter kann nicht entfernt werden, wenn kein anderer aktivierter Anbieter Standard werden kann.", + "prov.removeHasDependentCombos": "Entferne oder aktualisiere zuerst diese abhängigen Combos: {combos}.", + "prov.setDefault": "Als Standard festlegen", + "prov.setDefaultSuccess": "\"{name}\" ist jetzt der Standardanbieter.", + "prov.setDefaultFail": "\"{name}\" konnte nicht als Standardanbieter festgelegt werden.", + "prov.defaultDisabled": "Aktiviere diesen Anbieter, bevor du ihn als Standard festlegst.", + "prov.updateFail": "Dieser Anbieter konnte nicht aktualisiert werden.", + "prov.networkError": "Netzwerkfehler. Prüfe, ob der Proxy läuft, und versuche es erneut.", "prov.added": "\"{name}\" hinzugefügt. Sofort aktiv — führe {cmd} aus (oder starte neu), um seine Modelle in Codex’ Auswahl zu listen.", "prov.removeConfirm": "Anbieter \"{name}\" entfernen? Seine Modelle verschwinden aus Codex’ Auswahl.", "prov.hasApiKey": "API-Schlüssel konfiguriert", @@ -1313,6 +1322,7 @@ export const de: Record = { "pws.healthSummary.staleCredentials": "{provider} {account}: unvollständige Anmeldedaten.", "pws.removeConfirm": "Entfernen", "pws.removeConfirmBody": "Anbieter \"{name}\" entfernen? Dies kann nicht rückgängig gemacht werden.", + "pws.removeDefaultConfirmBody": "Standardanbieter \"{name}\" entfernen? \"{defaultProvider}\" wird zum Standardanbieter. Dies kann nicht rückgängig gemacht werden.", "pws.removeConfirmTitle": "Anbieter entfernen", "pws.saveSettings": "Speichern", "pws.saving": "Wird gespeichert…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 5ada11ac37..370d35e690 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -316,7 +316,16 @@ export const en = { "prov.logoutOk": "Logged out of {provider}.", "prov.logoutFail": "Could not log out of {provider}. Your account state is unchanged.", "prov.removed": "Removed \"{name}\".", + "prov.removedDefault": "Removed \"{name}\". Default provider is now \"{defaultProvider}\".", "prov.removeFail": "Failed to remove \"{name}\".", + "prov.removeLastProvider": "You can't remove this provider when no other enabled provider can become the default.", + "prov.removeHasDependentCombos": "Remove or update these dependent combos first: {combos}.", + "prov.setDefault": "Set as default", + "prov.setDefaultSuccess": "\"{name}\" is now the default provider.", + "prov.setDefaultFail": "Couldn't set \"{name}\" as the default provider.", + "prov.defaultDisabled": "Enable this provider before making it the default.", + "prov.updateFail": "Couldn't update this provider.", + "prov.networkError": "Network error. Check that the proxy is running and try again.", "prov.added": "Added \"{name}\". Live now — run {cmd} (or restart) to list its models in Codex's picker.", "prov.removeConfirm": "Remove provider \"{name}\"? Its models disappear from Codex's picker.", "prov.hasApiKey": "api key configured", @@ -1016,6 +1025,7 @@ export const en = { "pws.healthSummary.staleCredentials": "{provider} {account}: incomplete credentials.", "pws.removeConfirm": "Remove", "pws.removeConfirmBody": "Remove provider \"{name}\"? This cannot be undone.", + "pws.removeDefaultConfirmBody": "Remove default provider \"{name}\"? \"{defaultProvider}\" will become the default provider. This cannot be undone.", "pws.removeConfirmTitle": "Remove provider", "pws.saveSettings": "Save", "pws.saving": "Saving…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6a7f829f0e..2991363436 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -307,7 +307,16 @@ export const ja: Record = { "prov.logoutOk": "{provider} からログアウトしました。", "prov.logoutFail": "{provider} からログアウトできませんでした。アカウント状態は変更されていません。", "prov.removed": "\"{name}\" を削除しました。", + "prov.removedDefault": "\"{name}\" を削除しました。既定のプロバイダーは \"{defaultProvider}\" になりました。", "prov.removeFail": "\"{name}\" の削除に失敗しました。", + "prov.removeLastProvider": "このプロバイダーは、他に有効なプロバイダーを既定にできない場合は削除できません。", + "prov.removeHasDependentCombos": "先に依存するコンボを削除または更新してください: {combos}。", + "prov.setDefault": "既定に設定", + "prov.setDefaultSuccess": "\"{name}\" を既定のプロバイダーに設定しました。", + "prov.setDefaultFail": "\"{name}\" を既定のプロバイダーに設定できませんでした。", + "prov.defaultDisabled": "既定に設定する前に、このプロバイダーを有効にしてください。", + "prov.updateFail": "このプロバイダーを更新できませんでした。", + "prov.networkError": "ネットワークエラーです。プロキシが実行中であることを確認して、もう一度試してください。", "prov.added": "\"{name}\" を追加しました。即時反映 — {cmd} を実行(または再起動)して Codex のピッカーにモデルを一覧表示します。", "prov.removeConfirm": "プロバイダー \"{name}\" を削除しますか? そのモデルは Codex のピッカーから消えます。", "prov.hasApiKey": "API キー設定済み", @@ -966,6 +975,7 @@ export const ja: Record = { "pws.healthSummary.staleCredentials": "{provider} {account}: 資格情報が不完全です。", "pws.removeConfirm": "削除", "pws.removeConfirmBody": "プロバイダー \"{name}\" を削除しますか? これは元に戻せません。", + "pws.removeDefaultConfirmBody": "既定のプロバイダー \"{name}\" を削除しますか? \"{defaultProvider}\" が既定のプロバイダーになります。この操作は元に戻せません。", "pws.removeConfirmTitle": "プロバイダーを削除", "pws.saveSettings": "保存", "pws.saving": "保存中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 00fe513c9b..9ee8989eb4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -310,7 +310,16 @@ export const ko: Record = { "prov.logoutOk": "{provider} 에서 로그아웃했습니다.", "prov.logoutFail": "{provider}에서 로그아웃하지 못했습니다. 계정 상태는 그대로입니다.", "prov.removed": "\"{name}\" 을(를) 삭제했습니다.", + "prov.removedDefault": "\"{name}\"을(를) 삭제했습니다. 이제 기본 프로바이더는 \"{defaultProvider}\"입니다.", "prov.removeFail": "\"{name}\" 삭제에 실패했습니다.", + "prov.removeLastProvider": "활성화된 다른 프로바이더가 기본이 될 수 없으면 이 프로바이더를 삭제할 수 없습니다.", + "prov.removeHasDependentCombos": "먼저 이 프로바이더를 사용하는 콤보를 삭제하거나 수정하세요: {combos}.", + "prov.setDefault": "기본으로 설정", + "prov.setDefaultSuccess": "\"{name}\"이(가) 기본 프로바이더로 설정되었습니다.", + "prov.setDefaultFail": "\"{name}\"을(를) 기본 프로바이더로 설정하지 못했습니다.", + "prov.defaultDisabled": "기본으로 설정하려면 먼저 이 프로바이더를 활성화하세요.", + "prov.updateFail": "이 프로바이더를 업데이트하지 못했습니다.", + "prov.networkError": "네트워크 오류입니다. 프록시가 실행 중인지 확인한 후 다시 시도하세요.", "prov.added": "\"{name}\" 을(를) 추가했습니다. 지금 활성화됨 — Codex 모델 선택기에 표시하려면 {cmd} 를 실행하세요(또는 재시작).", "prov.removeConfirm": "프로바이더 \"{name}\" 을(를) 삭제할까요? 해당 모델이 Codex 선택기에서 사라집니다.", "prov.hasApiKey": "API 키 설정됨", @@ -1340,6 +1349,7 @@ export const ko: Record = { "pws.healthSummary.staleCredentials": "{provider} {account}: 자격 증명이 불완전합니다.", "pws.removeConfirm": "제거", "pws.removeConfirmBody": "프로바이더 \"{name}\"을(를) 제거하시겠습니까? 되돌릴 수 없습니다.", + "pws.removeDefaultConfirmBody": "기본 프로바이더 \"{name}\"을(를) 제거하시겠습니까? \"{defaultProvider}\"이(가) 기본 프로바이더가 됩니다. 이 작업은 되돌릴 수 없습니다.", "pws.removeConfirmTitle": "프로바이더 제거", "pws.saveSettings": "저장", "pws.saving": "저장 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index faaea326b9..36e99c2e40 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -312,7 +312,16 @@ export const ru: Record = { "prov.logoutOk": "Выполнен выход из {provider}.", "prov.logoutFail": "Не удалось выйти из {provider}. Состояние аккаунта не изменилось.", "prov.removed": "Провайдер \"{name}\" удалён.", + "prov.removedDefault": "Провайдер \"{name}\" удалён. Провайдером по умолчанию теперь является \"{defaultProvider}\".", "prov.removeFail": "Не удалось удалить \"{name}\".", + "prov.removeLastProvider": "Нельзя удалить этого провайдера, если ни один другой включённый провайдер не может стать провайдером по умолчанию.", + "prov.removeHasDependentCombos": "Сначала удалите или обновите зависимые комбо: {combos}.", + "prov.setDefault": "Сделать основным", + "prov.setDefaultSuccess": "\"{name}\" теперь провайдер по умолчанию.", + "prov.setDefaultFail": "Не удалось сделать \"{name}\" провайдером по умолчанию.", + "prov.defaultDisabled": "Сначала включите этого провайдера, затем сделайте его основным.", + "prov.updateFail": "Не удалось обновить этого провайдера.", + "prov.networkError": "Ошибка сети. Проверьте, что прокси запущен, и повторите попытку.", "prov.added": "Провайдер \"{name}\" добавлен. Уже активен — выполните {cmd} (или перезапустите), чтобы его модели появились в селекторе моделей Codex.", "prov.removeConfirm": "Удалить провайдера \"{name}\"? Его модели исчезнут из селектора моделей Codex.", "prov.hasApiKey": "API-ключ настроен", @@ -1008,6 +1017,7 @@ export const ru: Record = { "pws.healthSummary.staleCredentials": "{provider} {account}: неполные учётные данные.", "pws.removeConfirm": "Удалить", "pws.removeConfirmBody": "Удалить провайдера \"{name}\"? Это действие нельзя отменить.", + "pws.removeDefaultConfirmBody": "Удалить провайдера по умолчанию \"{name}\"? \"{defaultProvider}\" станет провайдером по умолчанию. Это действие нельзя отменить.", "pws.removeConfirmTitle": "Удалить провайдера", "pws.saveSettings": "Сохранить", "pws.saving": "Сохранение…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2d6fa8a35a..ec524e82d0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -307,7 +307,16 @@ export const zh: Record = { "prov.logoutOk": "已退出 {provider}。", "prov.logoutFail": "无法退出 {provider}。账户状态保持不变。", "prov.removed": "已移除 \"{name}\"。", + "prov.removedDefault": "已移除 \"{name}\"。默认提供方现为 \"{defaultProvider}\"。", "prov.removeFail": "移除 \"{name}\" 失败。", + "prov.removeLastProvider": "如果没有其他已启用的提供方可以成为默认,则无法移除此提供方。", + "prov.removeHasDependentCombos": "请先移除或更新依赖它的组合:{combos}。", + "prov.setDefault": "设为默认", + "prov.setDefaultSuccess": "\"{name}\" 已设为默认提供方。", + "prov.setDefaultFail": "无法将 \"{name}\" 设为默认提供方。", + "prov.defaultDisabled": "请先启用此提供方,再将其设为默认。", + "prov.updateFail": "无法更新此提供方。", + "prov.networkError": "网络错误。请确认代理正在运行后重试。", "prov.added": "已添加 \"{name}\"。现已生效 — 运行 {cmd}(或重启)以在 Codex 选择器中列出其模型。", "prov.removeConfirm": "移除提供方 \"{name}\"?其模型将从 Codex 选择器中消失。", "prov.hasApiKey": "已配置 API 密钥", @@ -1333,6 +1342,7 @@ export const zh: Record = { "pws.healthSummary.staleCredentials": "{provider} {account}:凭证不完整。", "pws.removeConfirm": "移除", "pws.removeConfirmBody": "移除提供商「{name}」?此操作无法撤消。", + "pws.removeDefaultConfirmBody": "移除默认提供方「{name}」?「{defaultProvider}」将成为默认提供方。此操作无法撤消。", "pws.removeConfirmTitle": "移除提供商", "pws.saveSettings": "保存", "pws.saving": "保存中…", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 84d6c9867e..d9784b38de 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -179,7 +179,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, }); - const { removeProvider, confirmRemoveProvider, setProviderDisabled, updateProvider } = useProvidersCrud({ + const { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider } = useProvidersCrud({ apiBase, t, removeBusyRef, workspaceSelected, setWorkspaceSelected, setRemoveConfirmName, notify, fetchConfig, fetchOauth, fetchProviderQuotas, }); @@ -328,6 +328,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { isDefault={item.name === config.defaultProvider} onRemoveProvider={removeProvider} onSetDisabled={setProviderDisabled} + onSetDefault={name => { void setDefaultProvider(name); }} onUpdateProvider={updateProvider} codexController={codexPool} /> @@ -343,6 +344,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { addModalAccountRows={addModalAccountRows} accountLoginStatus={accountLoginStatus} removeConfirmName={removeConfirmName} + removeDefaultProvider={removeConfirmName === config.defaultProvider + ? Object.entries(config.providers).find(([name, provider]) => name !== removeConfirmName && provider.disabled !== true)?.[0] ?? null + : null} codexLoginOpen={codexLoginOpen} jsonLeaveOpen={jsonLeaveOpen} jsonSaving={jsonSaving} diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index 69099befa2..58ab9fa2c7 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -16,6 +16,7 @@ export function ProvidersPageModals({ addModalAccountRows, accountLoginStatus, removeConfirmName, + removeDefaultProvider, codexLoginOpen, jsonLeaveOpen, jsonSaving, @@ -44,6 +45,7 @@ export function ProvidersPageModals({ addModalAccountRows: AccountLoginRow[]; accountLoginStatus: Record; removeConfirmName: string | null; + removeDefaultProvider: string | null; codexLoginOpen: boolean; jsonLeaveOpen?: boolean; jsonSaving?: boolean; @@ -93,6 +95,7 @@ export function ProvidersPageModals({ {removeConfirmName && ( diff --git a/gui/src/pages/use-providers-crud.ts b/gui/src/pages/use-providers-crud.ts index 325fdada6a..824138c4fe 100644 --- a/gui/src/pages/use-providers-crud.ts +++ b/gui/src/pages/use-providers-crud.ts @@ -3,6 +3,21 @@ import type { TFn } from "../i18n/shared"; import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; import { apiErrorMessage } from "../api-error"; +type ProviderError = { code?: unknown; combos?: unknown; error?: unknown }; + +function providerErrorMessage(data: ProviderError, t: TFn, fallback: string): string { + switch (data.code) { + case "last_provider": return t("prov.removeLastProvider"); + case "provider_has_dependent_combos": { + const combos = Array.isArray(data.combos) ? data.combos.filter((id): id is string => typeof id === "string").join(", ") : ""; + return t("prov.removeHasDependentCombos", { combos: combos || "—" }); + } + case "default_provider_disabled": return t("prov.defaultDisabled"); + default: + return typeof data.error === "string" && data.error.trim() ? data.error.trim() : fallback; + } +} + export function useProvidersCrud({ apiBase, t, @@ -39,13 +54,18 @@ export function useProvidersCrud({ try { const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { method: "DELETE" }); if (res.ok) { - notify(t("prov.removed", { name }), true); + const data = await res.json().catch(() => ({})) as { defaultProvider?: unknown }; + const defaultProvider = typeof data.defaultProvider === "string" ? data.defaultProvider : null; + notify(defaultProvider + ? t("prov.removedDefault", { name, defaultProvider }) + : t("prov.removed", { name }), true); if (workspaceSelected === name) setWorkspaceSelected(null); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); } else { - notify(await apiErrorMessage(res, fallback), false); + const data = await res.json().catch(() => ({})) as ProviderError; + notify(providerErrorMessage(data, t, fallback), false); } } catch { notify(fallback, false); @@ -61,8 +81,7 @@ export function useProvidersCrud({ body: JSON.stringify({ disabled }), }); if (!res.ok) { - const data = await res.json().catch(() => ({})) as { error?: string }; - notify(data.error || (disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); + notify(await apiErrorMessage(res, disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); return; } notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), true); @@ -79,17 +98,37 @@ export function useProvidersCrud({ body: JSON.stringify(patch), }); if (!res.ok) { - const data = await res.json().catch(() => ({})) as { error?: string }; - return { ok: false, error: data.error || "Update failed" }; + return { ok: false, error: await apiErrorMessage(res, t("prov.updateFail")) }; } // Await refresh so callers (e.g. notes editor) only leave edit mode once // item.note reflects the saved value. await fetchConfig(); return { ok: true }; } catch { - return { ok: false, error: "Network error" }; + return { ok: false, error: t("prov.networkError") }; + } + }, [apiBase, fetchConfig, t]); + + const setDefaultProvider = useCallback(async (name: string): Promise => { + try { + const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ setDefault: true }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})) as ProviderError; + notify(providerErrorMessage(data, t, t("prov.setDefaultFail", { name })), false); + return false; + } + notify(t("prov.setDefaultSuccess", { name }), true); + await fetchConfig(); + return true; + } catch { + notify(t("prov.setDefaultFail", { name }), false); + return false; } - }, [apiBase, fetchConfig]); + }, [apiBase, fetchConfig, notify, t]); - return { removeProvider, confirmRemoveProvider, setProviderDisabled, updateProvider }; + return { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider }; } diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 783c3edfe3..b847a68e70 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -278,6 +278,16 @@ background: var(--raised); } +/* The absolute delete button occupies the rail's trailing status area. Hide the + star/status while it is visible instead of letting the button crop either icon. */ +.pws-rail-row-wrap:hover .providers-workspace-rail-trail { + opacity: 0; +} + +.providers-workspace-rail-trail { + transition: opacity var(--motion-fast); +} + .pws-rail-row-remove:hover { background: var(--raised); } diff --git a/gui/tests/rail-hover-delete.test.ts b/gui/tests/rail-hover-delete.test.ts index dde87089e7..564ddb9adb 100644 --- a/gui/tests/rail-hover-delete.test.ts +++ b/gui/tests/rail-hover-delete.test.ts @@ -77,6 +77,10 @@ test("the accelerator is hidden where hover does not exist", async () => { expect(rule(".pws-rail-row-remove {")).toContain("opacity: 0;"); expect(rule(".pws-rail-row-remove {")).toContain("pointer-events: none;"); expect(rule(".pws-rail-row-wrap:hover .pws-rail-row-remove")).toContain("opacity: 1;"); + // The floating control overlays the trailing star/status area. Hide those markers + // while the control is visible so the default star cannot be partially cropped. + expect(css).toContain(".pws-rail-row-wrap:hover .providers-workspace-rail-trail"); + expect(rule(".pws-rail-row-wrap:hover .providers-workspace-rail-trail")).toContain("opacity: 0;"); // Touch devices have no hover, so it would otherwise be permanently visible. expect(css).toContain("@media (hover: none)"); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b792505cd2..ffa1724d66 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -118,6 +118,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise provider !== name && providerConfig.disabled !== true) + ?.[0] + : undefined; + if (name === config.defaultProvider && !fallbackDefault) { + return jsonResponse({ + error: "cannot delete the default provider when no enabled replacement remains", + code: "last_provider", + }, 409); + } const dependentCombos = Object.entries(config.combos ?? {}) .filter(([, combo]) => combo.targets.some(target => target.provider === name)) .map(([id]) => id) @@ -425,17 +463,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0) { return jsonResponse({ error: `cannot delete provider "${name}" while combos depend on it`, + code: "provider_has_dependent_combos", combos: dependentCombos, }, 409); } const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + if (fallbackDefault) config.defaultProvider = fallbackDefault; delete config.providers[name]; setProviderContextCap(config, name, false); save(config); const { clearModelCache: clearCache } = await import("../../codex/model-cache"); clearCache(name); await refreshCodexCatalogBestEffort(); - return jsonResponse({ success: true }); + return jsonResponse({ success: true, ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}) }); } if (url.pathname === "/api/provider-context-caps" && req.method === "GET") { diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index cf52454df3..20b9d42cf5 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -1,14 +1,19 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); const isolatedCodexHome = mkdtempSync(join(tmpdir(), "ocx-prov-codex-home-")); +// Every case below spawns the real CLI. Cold Bun starts on a loaded windows-latest runner +// routinely blow the 5s default before --help returns; the spawn IS the assertion. +setDefaultTimeout(SPAWN_BUDGET_MS); + function runCli(args: string[], env: Record = {}) { return spawnSync(process.execPath, [cliPath, ...args], { cwd: repoRoot, @@ -17,6 +22,9 @@ function runCli(args: string[], env: Record = {}) { // a test run would WIPE the user's routed catalog entries (live-catalog pollution). env: { ...process.env, CODEX_HOME: isolatedCodexHome, ...env }, encoding: "utf8", + // Contended windows-latest cold starts regularly exceed Bun's 5s default before --help + // even prints; keep the child deadline under the test budget so status is not null. + timeout: SPAWN_BUDGET_MS - 5_000, }); } diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index f5696c248b..6cf06b16b9 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -937,6 +937,134 @@ describe("provider management validation", () => { } }); + test("provider management switches the default and reassigns it when removed", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + defaultProvider: "alpha", + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", liveModels: false }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", liveModels: false }, + }, + }); + + const server = startServer(0); + try { + const setDefault = await fetch(new URL("/api/providers?name=beta", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ setDefault: true }), + }); + expect(setDefault.status).toBe(200); + expect(await setDefault.json()).toMatchObject({ success: true, defaultProvider: "beta" }); + + const deleteDefault = await fetch(new URL("/api/providers?name=beta", server.url), { method: "DELETE" }); + expect(deleteDefault.status).toBe(200); + expect(await deleteDefault.json()).toMatchObject({ success: true, defaultProvider: "alpha" }); + + const saved = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { + defaultProvider: string; + providers: Record; + }; + expect(saved.defaultProvider).toBe("alpha"); + expect(saved.providers).toEqual(expect.objectContaining({ alpha: expect.any(Object) })); + expect(saved.providers.beta).toBeUndefined(); + + const deleteLast = await fetch(new URL("/api/providers?name=alpha", server.url), { method: "DELETE" }); + expect(deleteLast.status).toBe(409); + expect(await deleteLast.json()).toMatchObject({ code: "last_provider" }); + } finally { + await server.stop(true); + } + }); + + test("provider management rejects POST setDefault for a disabled provider", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + defaultProvider: "alpha", + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", liveModels: false }, + }, + }); + + const server = startServer(0); + try { + const createDisabledDefault = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "beta", + setDefault: true, + provider: { + adapter: "openai-chat", + baseUrl: "https://beta.example.test/v1", + liveModels: false, + disabled: true, + }, + }), + }); + expect(createDisabledDefault.status).toBe(400); + expect(await createDisabledDefault.json()).toMatchObject({ code: "default_provider_disabled" }); + + const saved = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { + defaultProvider: string; + providers: Record; + }; + expect(saved.defaultProvider).toBe("alpha"); + expect(saved.providers.beta).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + + test("provider management refuses to delete the default when only a disabled replacement remains", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + defaultProvider: "alpha", + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", liveModels: false }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", liveModels: false, disabled: true }, + gamma: { adapter: "openai-chat", baseUrl: "https://gamma.example.test/v1", liveModels: false }, + }, + }); + + const server = startServer(0); + try { + const deleteWithDisabledFirst = await fetch(new URL("/api/providers?name=alpha", server.url), { method: "DELETE" }); + expect(deleteWithDisabledFirst.status).toBe(200); + expect(await deleteWithDisabledFirst.json()).toMatchObject({ success: true, defaultProvider: "gamma" }); + + const saved = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { + defaultProvider: string; + providers: Record; + }; + expect(saved.defaultProvider).toBe("gamma"); + expect(saved.providers.beta?.disabled).toBe(true); + expect(saved.providers.alpha).toBeUndefined(); + + const deleteOnlyEnabled = await fetch(new URL("/api/providers?name=gamma", server.url), { method: "DELETE" }); + expect(deleteOnlyEnabled.status).toBe(409); + expect(await deleteOnlyEnabled.json()).toMatchObject({ code: "last_provider" }); + const stillThere = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { + defaultProvider: string; + providers: Record; + }; + expect(stillThere.defaultProvider).toBe("gamma"); + expect(stillThere.providers.gamma).toEqual(expect.any(Object)); + expect(stillThere.providers.beta).toEqual(expect.any(Object)); + } finally { + await server.stop(true); + } + }); + test("provider management can disable and re-enable non-default providers", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });