From 608b575ce02ce0120c7925620855f85c4fbae7cf Mon Sep 17 00:00:00 2001 From: 0disoft Date: Mon, 29 Jun 2026 17:32:31 +0900 Subject: [PATCH] feat(providers): allow disabling providers --- gui/src/i18n/en.ts | 12 ++++++ gui/src/i18n/ko.ts | 12 ++++++ gui/src/i18n/zh.ts | 12 ++++++ gui/src/pages/Providers.tsx | 68 ++++++++++++++++++++++++-------- gui/src/styles.css | 4 +- src/codex-catalog.ts | 5 ++- src/router.ts | 12 ++++-- src/server.ts | 20 +++++++++- src/types.ts | 2 + src/vision/index.ts | 1 + src/web-search/index.ts | 1 + tests/codex-catalog.test.ts | 24 ++++++++++++ tests/router.test.ts | 26 +++++++++++++ tests/server-auth.test.ts | 78 +++++++++++++++++++++++++++++++++++++ 14 files changed, 253 insertions(+), 24 deletions(-) diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 62a0ed0e92..ec0224f062 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -82,6 +82,18 @@ export const en = { "prov.removeConfirm": "Remove provider \"{name}\"? Its models disappear from Codex's picker.", "prov.hasApiKey": "api key configured", "prov.hasHeaders": "custom headers configured", + "prov.activeBadge": "Active", + "prov.disabledBadge": "Disabled", + "prov.defaultBadge": "Default", + "prov.enable": "Enable", + "prov.disable": "Disable", + "prov.enabled": "Enabled \"{name}\". Its models can appear in Codex again.", + "prov.disabled": "Disabled \"{name}\". Settings are kept, but its models are hidden.", + "prov.enableFail": "Failed to enable \"{name}\".", + "prov.disableFail": "Failed to disable \"{name}\".", + "prov.enableAria": "Enable provider {name}", + "prov.disableAria": "Disable provider {name}", + "prov.defaultCannotDisable": "Default provider can't be disabled", // models "models.subtitle": "Toggle which routed models Codex sees, grouped by provider (click a header to collapse). Disabled models are hidden from the catalog + model picker. Changes apply on the next Codex turn — opencodex invalidates Codex's 5-min model cache so no restart is needed.", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 233428caae..5f31431c53 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -82,6 +82,18 @@ export const ko: Record = { "prov.removeConfirm": "프로바이더 \"{name}\" 을(를) 삭제할까요? 해당 모델이 Codex 선택기에서 사라집니다.", "prov.hasApiKey": "API 키 설정됨", "prov.hasHeaders": "커스텀 헤더 설정됨", + "prov.activeBadge": "활성", + "prov.disabledBadge": "비활성", + "prov.defaultBadge": "기본", + "prov.enable": "활성화", + "prov.disable": "비활성화", + "prov.enabled": "\"{name}\" 을(를) 활성화했습니다. 해당 모델을 다시 Codex에서 사용할 수 있습니다.", + "prov.disabled": "\"{name}\" 을(를) 비활성화했습니다. 설정은 유지되고 모델은 숨겨집니다.", + "prov.enableFail": "\"{name}\" 활성화에 실패했습니다.", + "prov.disableFail": "\"{name}\" 비활성화에 실패했습니다.", + "prov.enableAria": "{name} 프로바이더 활성화", + "prov.disableAria": "{name} 프로바이더 비활성화", + "prov.defaultCannotDisable": "기본 프로바이더는 비활성화할 수 없습니다", // models "models.subtitle": "Codex가 보는 라우팅된 모델을 프로바이더별로 묶어 켜고 끕니다(헤더를 클릭하면 접힘). 비활성화된 모델은 카탈로그와 모델 선택기에서 숨겨집니다. 변경 사항은 다음 Codex 턴에 적용됩니다 — opencodex가 Codex의 5분 모델 캐시를 무효화하므로 재시작이 필요 없습니다.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 91944b3b78..676f134c8d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -82,6 +82,18 @@ export const zh: Record = { "prov.removeConfirm": "移除提供方 \"{name}\"?其模型将从 Codex 选择器中消失。", "prov.hasApiKey": "已配置 API 密钥", "prov.hasHeaders": "已配置自定义请求头", + "prov.activeBadge": "已启用", + "prov.disabledBadge": "已禁用", + "prov.defaultBadge": "默认", + "prov.enable": "启用", + "prov.disable": "禁用", + "prov.enabled": "已启用 \"{name}\"。其模型可再次出现在 Codex 中。", + "prov.disabled": "已禁用 \"{name}\"。设置会保留,但模型会被隐藏。", + "prov.enableFail": "启用 \"{name}\" 失败。", + "prov.disableFail": "禁用 \"{name}\" 失败。", + "prov.enableAria": "启用提供方 {name}", + "prov.disableAria": "禁用提供方 {name}", + "prov.defaultCannotDisable": "默认提供方不能被禁用", // models "models.subtitle": "按提供方分组开关 Codex 可见的已路由模型(点击标题可折叠)。已禁用的模型会从目录和模型选择器中隐藏。更改在下一个 Codex 回合生效 — opencodex 会使 Codex 的 5 分钟模型缓存失效,因此无需重启。", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index f2d12c140b..68e9075cab 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from "react"; import AddProviderModal from "../components/AddProviderModal"; import { Notice } from "../ui"; -import { IconPlus, IconTrash, IconLock, IconExternal } from "../icons"; +import { IconPlus, IconTrash, IconLock, IconExternal, IconPower } from "../icons"; import { useT } from "../i18n"; interface Config { port: number; defaultProvider: string; - providers: Record; + providers: Record; } interface OAuthStatus { loggedIn: boolean; email?: string; error?: string } @@ -133,6 +133,22 @@ export default function Providers({ apiBase }: { apiBase: string }) { else notify(t("prov.removeFail", { name }), false); }; + const setProviderDisabled = async (name: string, disabled: boolean) => { + const res = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ disabled }), + }); + if (res.ok) { + notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), true); + fetchConfig(); + fetchOauth(); + return; + } + const data = await res.json().catch(() => ({})); + notify(data.error || (disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); + }; + if (!config) return
{t("prov.loadingConfig")}
; return ( @@ -211,24 +227,42 @@ export default function Providers({ apiBase }: { apiBase: string }) {
{t("prov.port")}: {config.port} · {t("prov.default")}: {config.defaultProvider}
- {Object.entries(config.providers).map(([name, prov]) => ( -
-
-
- {name} - {prov.authMode === "oauth" && oauth} - {prov.authMode === "forward" && passthrough} + {Object.entries(config.providers).map(([name, prov]) => { + const isDefault = name === config.defaultProvider; + const isDisabled = prov.disabled === true; + return ( +
+
+
+ {name} + {isDefault && {t("prov.defaultBadge")}} + {isDisabled ? {t("prov.disabledBadge")} : {t("prov.activeBadge")}} + {prov.authMode === "oauth" && oauth} + {prov.authMode === "forward" && passthrough} +
+
+ {prov.adapter} · {prov.baseUrl} + {prov.defaultModel && <> · {prov.defaultModel}} + {prov.hasApiKey && <> · {t("prov.hasApiKey")}} + {prov.hasHeaders && <> · {t("prov.hasHeaders")}} +
-
- {prov.adapter} · {prov.baseUrl} - {prov.defaultModel && <> · {prov.defaultModel}} - {prov.hasApiKey && <> · {t("prov.hasApiKey")}} - {prov.hasHeaders && <> · {t("prov.hasHeaders")}} +
+ +
- -
- ))} + ); + })}
)} {adding && ( diff --git a/gui/src/styles.css b/gui/src/styles.css index c714e5284a..8eb3627cba 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -327,7 +327,9 @@ select.input { appearance: none; } .list-row .sub { font-size: 12px; color: var(--muted); margin-top: 2px; } /* provider config card (Providers page) */ -.prov-card { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 15px 16px; } +.prov-card { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 15px 16px; flex-wrap: wrap; } +.prov-card-disabled { opacity: 0.62; } +.provider-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .link-btn { background: none; border: none; color: var(--accent-hover); font: inherit; font-size: 13px; cursor: pointer; padding: 6px 2px; text-decoration: underline; } /* responsive: sticky top bar (brand + theme) over a horizontal tab strip on narrow screens */ diff --git a/src/codex-catalog.ts b/src/codex-catalog.ts index 1aad95b8f0..4ba2ea242b 100644 --- a/src/codex-catalog.ts +++ b/src/codex-catalog.ts @@ -688,10 +688,11 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: */ export async function gatherRoutedModels(config: OcxConfig): Promise { const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; + const activeProviders = Object.entries(config.providers).filter(([, prov]) => prov.disabled !== true); const lists = await Promise.all( - Object.entries(config.providers).map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)), + activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)), ); - const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers), config.providers) + const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), activeProviders.map(([name]) => name), config.providers) // Drop image/video generation models (e.g. Grok image/video) — they are not usable by Codex and // must not surface in the dashboard, /v1/models, or the routed catalog. Single choke point. .filter(m => !isMediaGenerationModelId(m.id)); diff --git a/src/router.ts b/src/router.ts index cebadfa85c..7d82960e44 100644 --- a/src/router.ts +++ b/src/router.ts @@ -112,6 +112,10 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) }; } +function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][] { + return Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true); +} + export function routeModel(config: OcxConfig, modelId: string): RouteResult { // 0. Explicit "/" namespace (e.g. "opencode-go/deepseek-v4-pro"). // Only triggers when the prefix matches a CONFIGURED provider, so genuine @@ -122,6 +126,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult { const provName = modelId.slice(0, slash); if (hasOwnProvider(config.providers, provName)) { const prov = config.providers[provName]; + if (prov.disabled === true) throw new Error(`Provider is disabled: ${provName}`); return { providerName: provName, provider: routedProviderConfig(provName, prov), @@ -130,7 +135,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult { } } - for (const [provName, prov] of Object.entries(config.providers)) { + for (const [provName, prov] of activeProviderEntries(config)) { if (prov.defaultModel === modelId) { return { providerName: provName, @@ -140,7 +145,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult { } } - for (const [provName, prov] of Object.entries(config.providers)) { + for (const [provName, prov] of activeProviderEntries(config)) { if (prov.models && Array.isArray(prov.models) && (prov.models as string[]).includes(modelId)) { return { providerName: provName, @@ -152,7 +157,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult { for (const [patternKey, prefixes] of Object.entries(MODEL_PROVIDER_PATTERNS)) { if (prefixes.some(prefix => modelId.startsWith(prefix))) { - const matchingProvider = Object.entries(config.providers).find( + const matchingProvider = activeProviderEntries(config).find( ([name]) => name === patternKey || name.startsWith(patternKey) ); if (matchingProvider) { @@ -168,6 +173,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult { if (hasOwnProvider(config.providers, config.defaultProvider)) { const defaultProv = config.providers[config.defaultProvider]; + if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`); return { providerName: config.defaultProvider, provider: routedProviderConfig(config.defaultProvider, defaultProv), diff --git a/src/server.ts b/src/server.ts index 04a89e7cc0..f9bbb19714 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1468,7 +1468,7 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record ({ name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, hasApiKey: !!p.apiKey, + disabled: p.disabled === true, }))); } @@ -1753,6 +1755,22 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P return jsonResponse({ success: true, name }); } + if (url.pathname === "/api/providers" && req.method === "PATCH") { + const name = url.searchParams.get("name")?.trim(); + if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404); + let body: { disabled?: unknown }; + try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } + if (typeof body.disabled !== "boolean") return jsonResponse({ error: "disabled boolean is required" }, 400); + if (body.disabled && name === config.defaultProvider) { + return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400); + } + const { saveConfig: save } = await import("./config"); + config.providers[name] = { ...config.providers[name], disabled: body.disabled }; + save(config); + await refreshCodexCatalogBestEffort(); + return jsonResponse({ success: true, name, disabled: body.disabled }); + } + if (url.pathname === "/api/providers" && req.method === "DELETE") { const name = url.searchParams.get("name")?.trim(); if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404); diff --git a/src/types.ts b/src/types.ts index 340f6bbe29..cf4e4b3ffa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -265,6 +265,8 @@ export interface OcxWebSearchSidecarConfig { export interface OcxProviderConfig { adapter: string; baseUrl: string; + /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ + disabled?: boolean; apiKey?: string; defaultModel?: string; models?: string[]; diff --git a/src/vision/index.ts b/src/vision/index.ts index 59a830ed68..8047b7655e 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -36,6 +36,7 @@ function clamp(s: string, max: number): string { /** First configured forward (ChatGPT passthrough) provider — the path with native image input. */ function findForwardProvider(config: OcxConfig): OcxProviderConfig | undefined { for (const prov of Object.values(config.providers)) { + if (prov.disabled === true) continue; if (prov.authMode === "forward") return prov; } return undefined; diff --git a/src/web-search/index.ts b/src/web-search/index.ts index cc3f50ebcb..f2218b0f99 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -16,6 +16,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; /** First configured forward (ChatGPT passthrough) provider — the only path with server-side web_search. */ export function findForwardProvider(config: OcxConfig): OcxProviderConfig | undefined { for (const prov of Object.values(config.providers)) { + if (prov.disabled === true) continue; if (prov.authMode === "forward") return prov; } return undefined; diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 58856d785b..cf5fce859e 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -232,6 +232,30 @@ describe("Codex catalog routed normalization", () => { } }); + test("disabled providers are excluded from routed model gathering", async () => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: "active", + providers: { + active: { + adapter: "openai-chat", + baseUrl: "https://active.example.test/v1", + liveModels: false, + models: ["active-model"], + }, + disabled: { + adapter: "openai-chat", + baseUrl: "https://disabled.example.test/v1", + liveModels: false, + models: ["disabled-model"], + disabled: true, + }, + }, + }); + + expect(models.map(m => `${m.provider}/${m.id}`)).toEqual(["active/active-model"]); + }); + test("liveModels false ignores a fresh live-model cache", async () => { setCached("static-cache", [ { provider: "static-cache", id: "cached-live-model" }, diff --git a/tests/router.test.ts b/tests/router.test.ts index 0596d66470..40d885602a 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -163,4 +163,30 @@ describe("routeModel registry effort defaults", () => { expect(() => routeModel(config, "constructor/model")).toThrow("No provider configured"); }); + + test("skips disabled providers during routing", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "active", + providers: { + disabled: { + adapter: "openai-chat", + baseUrl: "https://disabled.example.test/v1", + defaultModel: "shared-model", + models: ["disabled-only"], + disabled: true, + }, + active: { + adapter: "openai-chat", + baseUrl: "https://active.example.test/v1", + defaultModel: "shared-model", + models: ["active-only"], + }, + }, + }; + + expect(routeModel(config, "shared-model").providerName).toBe("active"); + expect(routeModel(config, "active-only").providerName).toBe("active"); + expect(() => routeModel(config, "disabled/disabled-only")).toThrow("Provider is disabled"); + }); }); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 2b4b8da735..6587ca90f9 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -138,6 +138,7 @@ describe("server local API auth", () => { }); expect(dto.providers.openai).not.toHaveProperty("apiKey"); expect(dto.providers.openai).not.toHaveProperty("headers"); + expect(dto.providers.openai.disabled).toBeUndefined(); }); test("safeConfigDTO strips URL-embedded provider secrets", () => { @@ -416,6 +417,83 @@ describe("server local API auth", () => { } }); + 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 }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }); + + const server = startServer(0); + try { + const disable = await fetch(new URL("/api/providers?name=extra", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: true }), + }); + expect(disable.status).toBe(200); + expect(await disable.json()).toMatchObject({ success: true, name: "extra", disabled: true }); + + const disabledConfig = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { + providers: Record; + }; + expect(disabledConfig.providers.extra.disabled).toBe(true); + + const enable = await fetch(new URL("/api/providers?name=extra", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(enable.status).toBe(200); + expect(await enable.json()).toMatchObject({ success: true, name: "extra", disabled: false }); + + const enabledConfig = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { + providers: Record; + }; + expect(enabledConfig.providers.extra.disabled).toBe(false); + } finally { + await server.stop(true); + } + }); + + test("provider management rejects disabling the default provider", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const response = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: true }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: expect.stringContaining("cannot disable the default provider"), + }); + } finally { + await server.stop(true); + } + }); + test("provider management allows restoring the built-in ChatGPT forward provider preset", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true });