Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 12 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ export const ko: Record<TKey, string> = {
"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분 모델 캐시를 무효화하므로 재시작이 필요 없습니다.",
Expand Down
12 changes: 12 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ export const zh: Record<TKey, string> = {
"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 分钟模型缓存失效,因此无需重启。",
Expand Down
68 changes: 51 additions & 17 deletions gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { adapter: string; baseUrl: string; hasApiKey?: boolean; hasHeaders?: boolean; defaultModel?: string; authMode?: string }>;
providers: Record<string, { adapter: string; baseUrl: string; hasApiKey?: boolean; hasHeaders?: boolean; defaultModel?: string; authMode?: string; disabled?: boolean }>;
}

interface OAuthStatus { loggedIn: boolean; email?: string; error?: string }
Expand Down Expand Up @@ -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 <div className="muted">{t("prov.loadingConfig")}</div>;

return (
Expand Down Expand Up @@ -211,24 +227,42 @@ export default function Providers({ apiBase }: { apiBase: string }) {
<div className="muted" style={{ fontSize: 13, marginBottom: 4 }}>
{t("prov.port")}: <code className="chip">{config.port}</code> · {t("prov.default")}: <code className="chip">{config.defaultProvider}</code>
</div>
{Object.entries(config.providers).map(([name, prov]) => (
<div key={name} className="card prov-card">
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 5 }}>
<span style={{ fontWeight: 600 }}>{name}</span>
{prov.authMode === "oauth" && <span className="badge badge-accent">oauth</span>}
{prov.authMode === "forward" && <span className="badge badge-amber">passthrough</span>}
{Object.entries(config.providers).map(([name, prov]) => {
const isDefault = name === config.defaultProvider;
const isDisabled = prov.disabled === true;
return (
<div key={name} className={`card prov-card${isDisabled ? " prov-card-disabled" : ""}`}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 5 }}>
<span style={{ fontWeight: 600 }}>{name}</span>
{isDefault && <span className="badge badge-primary">{t("prov.defaultBadge")}</span>}
{isDisabled ? <span className="badge badge-muted">{t("prov.disabledBadge")}</span> : <span className="badge badge-green">{t("prov.activeBadge")}</span>}
{prov.authMode === "oauth" && <span className="badge badge-accent">oauth</span>}
{prov.authMode === "forward" && <span className="badge badge-amber">passthrough</span>}
</div>
<div className="muted" style={{ fontSize: 13 }}>
<code className="chip">{prov.adapter}</code> · {prov.baseUrl}
{prov.defaultModel && <> · {prov.defaultModel}</>}
{prov.hasApiKey && <> · {t("prov.hasApiKey")}</>}
{prov.hasHeaders && <> · {t("prov.hasHeaders")}</>}
</div>
</div>
<div className="muted" style={{ fontSize: 13 }}>
<code className="chip">{prov.adapter}</code> · {prov.baseUrl}
{prov.defaultModel && <> · {prov.defaultModel}</>}
{prov.hasApiKey && <> · {t("prov.hasApiKey")}</>}
{prov.hasHeaders && <> · {t("prov.hasHeaders")}</>}
<div className="provider-actions">
<button
className={`btn ${isDisabled ? "btn-primary" : "btn-ghost"} btn-sm`}
onClick={() => setProviderDisabled(name, !isDisabled)}
disabled={isDefault}
title={isDefault ? t("prov.defaultCannotDisable") : undefined}
aria-label={isDisabled ? t("prov.enableAria", { name }) : t("prov.disableAria", { name })}
>
{isDefault ? <IconLock /> : <IconPower />}
{isDisabled ? t("prov.enable") : t("prov.disable")}
</button>
<button className="btn btn-danger btn-sm" onClick={() => removeProvider(name)} aria-label={t("sub.removeAria", { m: name })}><IconTrash />{t("common.remove")}</button>
</div>
</div>
<button className="btn btn-danger btn-sm" onClick={() => removeProvider(name)} aria-label={t("sub.removeAria", { m: name })} style={{ flexShrink: 0 }}><IconTrash />{t("common.remove")}</button>
</div>
))}
);
})}
</div>
)}
{adding && (
Expand Down
4 changes: 3 additions & 1 deletion gui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
5 changes: 3 additions & 2 deletions src/codex-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,10 +688,11 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
*/
export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
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));
Expand Down
12 changes: 9 additions & 3 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4-pro").
// Only triggers when the prefix matches a CONFIGURED provider, so genuine
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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),
Expand Down
20 changes: 19 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1468,7 +1468,7 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
return {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
"Vary": "Origin",
};
Expand Down Expand Up @@ -1581,6 +1581,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
};
for (const key of [
"defaultModel",
"disabled",
"authMode",
"liveModels",
"models",
Expand Down Expand Up @@ -1721,6 +1722,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
hasApiKey: !!p.apiKey,
disabled: p.disabled === true,
})));
}

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
1 change: 1 addition & 0 deletions src/vision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/web-search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions tests/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
26 changes: 26 additions & 0 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading
Loading