From 1cf32f5689255071f143112729e1bbd93900365c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?= Date: Fri, 31 Jul 2026 17:15:02 +0800 Subject: [PATCH 1/6] fix(providers): manage default provider safely --- .../provider-workspace/ProviderDetails.tsx | 7 +++ .../provider-workspace/ProviderDialogs.tsx | 8 ++- .../components/provider-workspace/types.ts | 2 + gui/src/i18n/de.ts | 10 ++++ gui/src/i18n/en.ts | 10 ++++ gui/src/i18n/ja.ts | 10 ++++ gui/src/i18n/ko.ts | 10 ++++ gui/src/i18n/ru.ts | 10 ++++ gui/src/i18n/zh.ts | 10 ++++ gui/src/pages/Providers.tsx | 4 +- gui/src/pages/providers-page-modals.tsx | 3 + gui/src/pages/use-providers-crud.ts | 57 +++++++++++++++---- gui/src/styles/provider-workspace-shell.css | 10 ++++ gui/tests/rail-hover-delete.test.ts | 4 ++ src/server/management/provider-routes.ts | 32 ++++++++++- tests/management-provider-validation.test.ts | 43 ++++++++++++++ 16 files changed, 215 insertions(+), 15 deletions(-) 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 aeb8f7a39c..a31a819866 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 einzige konfigurierte Anbieter kann nicht entfernt werden.", + "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", @@ -1290,6 +1299,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 a710368fe8..149c016d7d 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 the only configured provider.", + "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 86cb313178..638cb69e79 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 f880055b06..3da8584ec0 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 키 설정됨", @@ -1317,6 +1326,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 cf49ced34f..39799efb4a 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 aa416bcb84..e3e52f6676 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 密钥", @@ -1310,6 +1319,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..51b9cc84fd 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,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { addModalAccountRows={addModalAccountRows} accountLoginStatus={accountLoginStatus} removeConfirmName={removeConfirmName} + removeDefaultProvider={removeConfirmName === config.defaultProvider ? Object.keys(config.providers).find(name => name !== removeConfirmName) ?? 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..2c3120178e 100644 --- a/gui/src/pages/use-providers-crud.ts +++ b/gui/src/pages/use-providers-crud.ts @@ -1,7 +1,20 @@ import { useCallback } from "react"; 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 }; + +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 fallback; + } +} export function useProvidersCrud({ apiBase, @@ -39,13 +52,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 +79,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(disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name }), false); return; } notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), true); @@ -79,17 +96,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: 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..62149d45e0 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(css).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..aaa6c5a70e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -147,6 +147,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise provider !== name) + : undefined; + if (name === config.defaultProvider && !fallbackDefault) { + return jsonResponse({ error: "cannot delete the only configured provider", code: "last_provider" }, 409); + } const dependentCombos = Object.entries(config.combos ?? {}) .filter(([, combo]) => combo.targets.some(target => target.provider === name)) .map(([id]) => id) @@ -425,17 +451,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/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index f5696c248b..3b0011cdd9 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -937,6 +937,49 @@ 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 can disable and re-enable non-default providers", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From dc6e92b4bc45c38be83b7663f16916439e6e4f85 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:54:03 +0200 Subject: [PATCH 2/6] fix(providers): skip disabled providers when reassigning default DELETE of the current default now picks the first remaining enabled provider (or 409), matching setDefault/disable guards. Restore actionable API errors in the GUI and document the dashboard set-default flow. --- .../src/content/docs/guides/web-dashboard.md | 4 +- .../content/docs/ja/guides/web-dashboard.md | 4 +- .../content/docs/ko/guides/web-dashboard.md | 4 +- .../content/docs/ru/guides/web-dashboard.md | 4 +- .../docs/zh-cn/guides/web-dashboard.md | 4 +- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 16 +++---- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/src/pages/Providers.tsx | 4 +- gui/src/pages/use-providers-crud.ts | 10 +++-- gui/tests/rail-hover-delete.test.ts | 2 +- src/server/management/provider-routes.ts | 14 ++++-- tests/management-provider-validation.test.ts | 43 +++++++++++++++++++ 16 files changed, 86 insertions(+), 33 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index adc970bc81..a19571c0a8 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, 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. 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 (`{ "setDefault": true }` alone), or remove providers. Deleting the current default reassigns to the first remaining enabled provider when one exists. | | `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..61ccc5ac8d 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=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、既定設定(`{ "setDefault": true }` のみ)、削除。現在の既定を削除すると、残っている最初の有効なプロバイダーに再割当てします(存在する場合)。 | | `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..9c2ea004d5 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=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 기본 설정(`{ "setDefault": true }`만), 제거. 현재 기본을 삭제하면 남아 있는 첫 번째 활성 프로바이더로 재지정됩니다(있는 경우). | | `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..8cdc31b7e7 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-ключей там, где они поддерживаются. При удалении текущего провайдера по умолчанию выбирается первый оставшийся включённый провайдер, если он есть. Для пулов 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=...` | Список, добавление/замена, включение/отключение, назначение провайдера по умолчанию (`{ "setDefault": true }` отдельно) или удаление провайдеров. Удаление текущего 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..b370827dd0 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=...` | 列出、添加/替换、启用/禁用、设为默认(单独 `{ "setDefault": true }`)或删除 provider。删除当前默认时,会改派到剩余的第一个已启用 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/i18n/de.ts b/gui/src/i18n/de.ts index a31a819866..90de508480 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -303,7 +303,7 @@ export const de: Record = { "prov.removed": "\"{name}\" entfernt.", "prov.removedDefault": "\"{name}\" entfernt. Standardanbieter ist jetzt \"{defaultProvider}\".", "prov.removeFail": "\"{name}\" konnte nicht entfernt werden.", - "prov.removeLastProvider": "Der einzige konfigurierte Anbieter kann 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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 149c016d7d..60ab857be1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -318,7 +318,7 @@ export const en = { "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 the only configured provider.", + "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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 638cb69e79..082566d6b5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -309,7 +309,7 @@ export const ja: Record = { "prov.removed": "\"{name}\" を削除しました。", "prov.removedDefault": "\"{name}\" を削除しました。既定のプロバイダーは \"{defaultProvider}\" になりました。", "prov.removeFail": "\"{name}\" の削除に失敗しました。", - "prov.removeLastProvider": "設定済みの唯一のプロバイダーは削除できません。", + "prov.removeLastProvider": "このプロバイダーは、他に有効なプロバイダーを既定にできない場合は削除できません。", "prov.removeHasDependentCombos": "先に依存するコンボを削除または更新してください: {combos}。", "prov.setDefault": "既定に設定", "prov.setDefaultSuccess": "\"{name}\" を既定のプロバイダーに設定しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3da8584ec0..fb3e7d979d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -310,15 +310,15 @@ export const ko: Record = { "prov.logoutOk": "{provider} 에서 로그아웃했습니다.", "prov.logoutFail": "{provider}에서 로그아웃하지 못했습니다. 계정 상태는 그대로입니다.", "prov.removed": "\"{name}\" 을(를) 삭제했습니다.", - "prov.removedDefault": "\"{name}\"을(를) 삭제했습니다. 이제 기본 제공자는 \"{defaultProvider}\"입니다.", + "prov.removedDefault": "\"{name}\"을(를) 삭제했습니다. 이제 기본 프로바이더는 \"{defaultProvider}\"입니다.", "prov.removeFail": "\"{name}\" 삭제에 실패했습니다.", - "prov.removeLastProvider": "유일하게 구성된 제공자는 삭제할 수 없습니다.", - "prov.removeHasDependentCombos": "먼저 이 제공자를 사용하는 콤보를 삭제하거나 수정하세요: {combos}.", + "prov.removeLastProvider": "활성화된 다른 프로바이더가 기본이 될 수 없으면 이 프로바이더를 삭제할 수 없습니다.", + "prov.removeHasDependentCombos": "먼저 이 프로바이더를 사용하는 콤보를 삭제하거나 수정하세요: {combos}.", "prov.setDefault": "기본으로 설정", - "prov.setDefaultSuccess": "\"{name}\"이(가) 기본 제공자로 설정되었습니다.", - "prov.setDefaultFail": "\"{name}\"을(를) 기본 제공자로 설정하지 못했습니다.", - "prov.defaultDisabled": "기본으로 설정하려면 먼저 이 제공자를 활성화하세요.", - "prov.updateFail": "이 제공자를 업데이트하지 못했습니다.", + "prov.setDefaultSuccess": "\"{name}\"이(가) 기본 프로바이더로 설정되었습니다.", + "prov.setDefaultFail": "\"{name}\"을(를) 기본 프로바이더로 설정하지 못했습니다.", + "prov.defaultDisabled": "기본으로 설정하려면 먼저 이 프로바이더를 활성화하세요.", + "prov.updateFail": "이 프로바이더를 업데이트하지 못했습니다.", "prov.networkError": "네트워크 오류입니다. 프록시가 실행 중인지 확인한 후 다시 시도하세요.", "prov.added": "\"{name}\" 을(를) 추가했습니다. 지금 활성화됨 — Codex 모델 선택기에 표시하려면 {cmd} 를 실행하세요(또는 재시작).", "prov.removeConfirm": "프로바이더 \"{name}\" 을(를) 삭제할까요? 해당 모델이 Codex 선택기에서 사라집니다.", @@ -1326,7 +1326,7 @@ export const ko: Record = { "pws.healthSummary.staleCredentials": "{provider} {account}: 자격 증명이 불완전합니다.", "pws.removeConfirm": "제거", "pws.removeConfirmBody": "프로바이더 \"{name}\"을(를) 제거하시겠습니까? 되돌릴 수 없습니다.", - "pws.removeDefaultConfirmBody": "기본 제공자 \"{name}\"을(를) 제거하시겠습니까? \"{defaultProvider}\"이(가) 기본 제공자가 됩니다. 이 작업은 되돌릴 수 없습니다.", + "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 39799efb4a..56aed1a976 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -314,7 +314,7 @@ export const ru: Record = { "prov.removed": "Провайдер \"{name}\" удалён.", "prov.removedDefault": "Провайдер \"{name}\" удалён. Провайдером по умолчанию теперь является \"{defaultProvider}\".", "prov.removeFail": "Не удалось удалить \"{name}\".", - "prov.removeLastProvider": "Нельзя удалить единственного настроенного провайдера.", + "prov.removeLastProvider": "Нельзя удалить этого провайдера, если ни один другой включённый провайдер не может стать провайдером по умолчанию.", "prov.removeHasDependentCombos": "Сначала удалите или обновите зависимые комбо: {combos}.", "prov.setDefault": "Сделать основным", "prov.setDefaultSuccess": "\"{name}\" теперь провайдер по умолчанию.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e3e52f6676..04e57a6efb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -309,7 +309,7 @@ export const zh: Record = { "prov.removed": "已移除 \"{name}\"。", "prov.removedDefault": "已移除 \"{name}\"。默认提供方现为 \"{defaultProvider}\"。", "prov.removeFail": "移除 \"{name}\" 失败。", - "prov.removeLastProvider": "不能移除唯一已配置的提供方。", + "prov.removeLastProvider": "如果没有其他已启用的提供方可以成为默认,则无法移除此提供方。", "prov.removeHasDependentCombos": "请先移除或更新依赖它的组合:{combos}。", "prov.setDefault": "设为默认", "prov.setDefaultSuccess": "\"{name}\" 已设为默认提供方。", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 51b9cc84fd..d9784b38de 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -344,7 +344,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { addModalAccountRows={addModalAccountRows} accountLoginStatus={accountLoginStatus} removeConfirmName={removeConfirmName} - removeDefaultProvider={removeConfirmName === config.defaultProvider ? Object.keys(config.providers).find(name => name !== removeConfirmName) ?? null : null} + 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/use-providers-crud.ts b/gui/src/pages/use-providers-crud.ts index 2c3120178e..824138c4fe 100644 --- a/gui/src/pages/use-providers-crud.ts +++ b/gui/src/pages/use-providers-crud.ts @@ -1,8 +1,9 @@ import { useCallback } from "react"; 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 }; +type ProviderError = { code?: unknown; combos?: unknown; error?: unknown }; function providerErrorMessage(data: ProviderError, t: TFn, fallback: string): string { switch (data.code) { @@ -12,7 +13,8 @@ function providerErrorMessage(data: ProviderError, t: TFn, fallback: string): st return t("prov.removeHasDependentCombos", { combos: combos || "—" }); } case "default_provider_disabled": return t("prov.defaultDisabled"); - default: return fallback; + default: + return typeof data.error === "string" && data.error.trim() ? data.error.trim() : fallback; } } @@ -79,7 +81,7 @@ export function useProvidersCrud({ body: JSON.stringify({ disabled }), }); if (!res.ok) { - notify(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); @@ -96,7 +98,7 @@ export function useProvidersCrud({ body: JSON.stringify(patch), }); if (!res.ok) { - return { ok: false, error: t("prov.updateFail") }; + 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. diff --git a/gui/tests/rail-hover-delete.test.ts b/gui/tests/rail-hover-delete.test.ts index 62149d45e0..564ddb9adb 100644 --- a/gui/tests/rail-hover-delete.test.ts +++ b/gui/tests/rail-hover-delete.test.ts @@ -80,7 +80,7 @@ test("the accelerator is hidden where hover does not exist", async () => { // 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(css).toContain("opacity: 0;"); + 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 aaa6c5a70e..78e91f40f7 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -436,13 +436,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise provider !== name) + ? Object.entries(config.providers) + .find(([provider, providerConfig]) => provider !== name && providerConfig.disabled !== true) + ?.[0] : undefined; if (name === config.defaultProvider && !fallbackDefault) { - return jsonResponse({ error: "cannot delete the only configured provider", code: "last_provider" }, 409); + 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)) diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 3b0011cdd9..8ef6de4c7a 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -980,6 +980,49 @@ describe("provider management validation", () => { } }); + 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 }); From 59a4038e7f5f9f588e2382d3146a68ca064ecdf8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:10:13 +0200 Subject: [PATCH 3/6] docs(providers): spell out enabled-only default delete guards Clarify that set-default requires an enabled provider and that deleting the default is refused when no enabled replacement remains. --- docs-site/src/content/docs/guides/web-dashboard.md | 4 ++-- docs-site/src/content/docs/ja/guides/web-dashboard.md | 4 ++-- docs-site/src/content/docs/ko/guides/web-dashboard.md | 4 ++-- docs-site/src/content/docs/ru/guides/web-dashboard.md | 4 ++-- docs-site/src/content/docs/zh-cn/guides/web-dashboard.md | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index a19571c0a8..511f6f89c2 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, set the default, 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. 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, set the default (`{ "setDefault": true }` alone), or remove providers. Deleting the current default reassigns to the first remaining enabled provider when one exists. | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, set the default (`{ "setDefault": true }` alone on an enabled provider), or remove providers. 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 61ccc5ac8d..2a1c8f709f 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=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、既定設定(`{ "setDefault": true }` のみ)、削除。現在の既定を削除すると、残っている最初の有効なプロバイダーに再割当てします(存在する場合)。 | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、既定設定(`{ "setDefault": true }` のみ、有効なプロバイダー)、削除。現在の既定を削除すると、残っている最初の有効なプロバイダーに再割当てします(存在する場合)。なければ `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 9c2ea004d5..d1818fd0ec 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=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 기본 설정(`{ "setDefault": true }`만), 제거. 현재 기본을 삭제하면 남아 있는 첫 번째 활성 프로바이더로 재지정됩니다(있는 경우). | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 기본 설정(`{ "setDefault": true }`만, 활성 프로바이더), 제거. 현재 기본을 삭제하면 남아 있는 첫 번째 활성 프로바이더로 재지정됩니다(있는 경우); 없으면 `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 8cdc31b7e7..f86fea5194 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=...` | Список, добавление/замена, включение/отключение, назначение провайдера по умолчанию (`{ "setDefault": true }` отдельно) или удаление провайдеров. Удаление текущего default переназначает на первый оставшийся включённый провайдер, если он есть. | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение, назначение провайдера по умолчанию (`{ "setDefault": true }` отдельно для включённого провайдера) или удаление провайдеров. Удаление текущего 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 b370827dd0..e3e61cb450 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 池。删除当前默认时,会切换到剩余的第一个已启用 provider(若存在)。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=...` | 列出、添加/替换、启用/禁用、设为默认(单独 `{ "setDefault": true }`)或删除 provider。删除当前默认时,会改派到剩余的第一个已启用 provider(若存在)。 | +| `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 列出、添加/替换、启用/禁用、设为默认(单独 `{ "setDefault": true }`,且仅已启用)或删除 provider。删除当前默认时,会改派到剩余的第一个已启用 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 目录。 | From 9b7366787d8f4ef2f327f42e51ff0f94962454de Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:25:15 +0200 Subject: [PATCH 4/6] fix(providers): reject disabled setDefault on POST create Match PATCH: refuse creating/replacing a disabled provider as default before mutating config, and document POST vs PATCH setDefault in the dashboard API table. --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/ja/guides/web-dashboard.md | 2 +- .../content/docs/ko/guides/web-dashboard.md | 2 +- .../content/docs/ru/guides/web-dashboard.md | 2 +- .../docs/zh-cn/guides/web-dashboard.md | 2 +- src/server/management/provider-routes.ts | 3 ++ tests/management-provider-validation.test.ts | 42 +++++++++++++++++++ 7 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 511f6f89c2..51c049b3e7 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -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, set the default (`{ "setDefault": true }` alone on an enabled provider), or remove providers. 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/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 2a1c8f709f..82229f0285 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -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=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、既定設定(`{ "setDefault": true }` のみ、有効なプロバイダー)、削除。現在の既定を削除すると、残っている最初の有効なプロバイダーに再割当てします(存在する場合)。なければ `409`(`code: "last_provider"`)を返し、現在の既定を保持します。 | +| `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 d1818fd0ec..b76815805b 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -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=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 기본 설정(`{ "setDefault": true }`만, 활성 프로바이더), 제거. 현재 기본을 삭제하면 남아 있는 첫 번째 활성 프로바이더로 재지정됩니다(있는 경우); 없으면 `409`(`code: "last_provider"`)를 반환하고 현재 기본을 유지합니다. | +| `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 f86fea5194..28e1c03bb7 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -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=...` | Список, добавление/замена, включение/отключение, назначение провайдера по умолчанию (`{ "setDefault": true }` отдельно для включённого провайдера) или удаление провайдеров. Удаление текущего default переназначает на первый оставшийся включённый провайдер, если он есть; иначе API возвращает `409` с `code: "last_provider"` и сохраняет текущий default. | +| `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 e3e61cb450..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 @@ -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=...` | 列出、添加/替换、启用/禁用、设为默认(单独 `{ "setDefault": true }`,且仅已启用)或删除 provider。删除当前默认时,会改派到剩余的第一个已启用 provider(若存在);否则返回 `409`(`code: "last_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/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 78e91f40f7..3d5d35f94e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -118,6 +118,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + 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 }); From 6d3990e038a25261481d9178ba6d1fdf39d5f977 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:38:20 +0200 Subject: [PATCH 5/6] fix(providers): require boolean setDefault on POST Reject non-boolean setDefault values and only promote when the flag is exactly true, matching the PATCH contract. --- src/server/management/provider-routes.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 3d5d35f94e..ffa1724d66 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -118,7 +118,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise Date: Fri, 31 Jul 2026 20:19:17 +0200 Subject: [PATCH 6/6] fix(test): budget ocx provider CLI spawns for Windows CI Cold Bun starts of `provider --help` exceeded the 5s default on contended windows-latest (status null). Use SPAWN_BUDGET_MS for the suite and give spawnSync a matching deadline. --- tests/cli-provider.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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, }); }