diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 3d7675904..a96c95707 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -45,6 +45,10 @@ Cost values in **Logs** and **Usage** are API list-price equivalents calculated They are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. +## Model visibility + +The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. + ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, @@ -98,6 +102,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `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/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. | | `POST /api/oauth/login` · `GET /api/oauth/status` | Start a provider OAuth flow and poll for completion. | | `GET /api/codex-auth/accounts?refresh=1` | List main and pool accounts, force quota refresh, and report main-account `hasCredential` / terminal `needsReauth` state. | 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 8fabb8d99..b0e8a0fa2 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -44,6 +44,10 @@ bun run dev:gui **ログ**と**使用量**のコスト値は報告されたトークンで計算した API 定価換算値です。請求明細や 実際の請求証拠ではなく、サブスクリプション使用量またはプロバイダークレジットが代わりに適用される場合があります。 +## モデルの表示 + +**モデル** スイッチは Codex での最終的な表示状態を示します。ルーティングモデルはプロバイダーの allowlist に含まれる(または allowlist がない)うえで、無効化されていない場合だけオンになります。オン操作は両方のフィルターを原子的に調整し、**すべてオン** は allowlist を解除して新しいモデルも含めます。 + ## 委任セレクターとスポーンルーティングの違い ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を @@ -92,6 +96,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `GET` / `PUT /api/v2` | サーフェスモード、Codex 機能フラグ、v2 スレッド上限を読むか変えます。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、削除。 | | `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 プロバイダーカタログを読みます。 | | `POST /api/oauth/login` · `GET /api/oauth/status` | プロバイダー OAuth ログインを開始し完了可否を確認します。 | | `GET /api/codex-auth/accounts?refresh=1` | メインおよびプールアカウントを参照しクォータを強制更新し、メインの `hasCredential` / terminal `needsReauth` 状態を返します。 | 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 2ce065e9d..d257dc33c 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -44,6 +44,10 @@ bun run dev:gui **Logs**와 **Usage**의 비용 값은 보고된 토큰으로 계산한 API 정가 환산치입니다. 결제 영수증이나 실제 청구 증거가 아니며, 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다. +## 모델 노출 + +**Models** 스위치는 Codex의 최종 노출 상태를 나타냅니다. 라우팅 모델은 프로바이더 allowlist에 포함되거나 allowlist가 없고, 동시에 비활성화되지 않았을 때만 켜집니다. 모델을 켜면 두 필터를 원자적으로 조정하며, **모두 활성화**는 allowlist를 해제해 새로 발견되는 모델도 켭니다. + ## 위임 선택기와 스폰 라우팅의 차이 Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`를 @@ -94,6 +98,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `GET` / `PUT /api/v2` | 서피스 모드, Codex 기능 플래그, v2 thread 상한을 읽거나 바꿉니다. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 제거. | | `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 프로바이더 카탈로그를 읽습니다. | | `POST /api/oauth/login` · `GET /api/oauth/status` | 프로바이더 OAuth 로그인을 시작하고 완료 여부를 확인합니다. | | `GET /api/codex-auth/accounts?refresh=1` | main 및 pool 계정을 조회하고 할당량을 강제로 갱신하며 main 계정의 `hasCredential` / terminal `needsReauth` 상태를 표시합니다. | 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 f42a34e28..7e75d21c7 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -45,6 +45,10 @@ bun run dev:gui рассчитанные на основе сообщённых токенов. Они не являются счётом и не подтверждают фактическое списание; расходы могут покрываться подпиской или кредитами провайдера. +## Видимость моделей + +Переключатели **Models** показывают итоговую видимость в Codex. Маршрутизируемая модель включена, только если она входит в allowlist провайдера (или allowlist отсутствует) и не отключена. Включение атомарно согласует оба фильтра, а **Включить все** удаляет allowlist и включает новые модели. + ## Селектор делегирования и маршрутизация порождений Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, @@ -97,6 +101,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `GET` / `PUT /api/v2` | Чтение или настройка режима поверхности, фиче-флага Codex и лимита потоков v2. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение или удаление провайдеров. | | `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. | | `POST /api/oauth/login` · `GET /api/oauth/status` | Запуск OAuth-процесса провайдера и опрос его завершения. | | `GET /api/codex-auth/accounts?refresh=1` | Список основного и пуловых аккаунтов с принудительным обновлением квот и состояниями `hasCredential` / terminal `needsReauth` основного аккаунта. | 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 200f7af81..e91be08b5 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 @@ -43,6 +43,10 @@ bun run dev:gui **Logs** 和 **Usage** 中的费用是根据已报告 token 计算的 API 标价折算值,不是账单,也不能证明 实际发生了扣费;实际可能计入订阅用量或消耗服务商额度。 +## 模型可见性 + +**Models** 开关表示 Codex 中的最终可见状态。路由模型只有在 provider allowlist 中(或未设置 allowlist)且未被禁用时才会开启。开启模型会原子地协调两个过滤条件;**全部开启** 会清除 allowlist,因此以后新发现的模型也会开启。 + ## 委派选择器与生成路由的区别 Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的 @@ -89,6 +93,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `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/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 目录。 | | `POST /api/oauth/login` · `GET /api/oauth/status` | 启动 provider OAuth 流程并轮询完成状态。 | | `GET /api/codex-auth/accounts?refresh=1` | 列出主账号与池账号、强制刷新配额,并返回主账号的 `hasCredential` / terminal `needsReauth` 状态。 | diff --git a/gui/src/model-visibility.ts b/gui/src/model-visibility.ts new file mode 100644 index 000000000..e422edc40 --- /dev/null +++ b/gui/src/model-visibility.ts @@ -0,0 +1,74 @@ +export type ProviderModelMap = Record; + +export interface ModelVisibilityTarget { id: string; native?: boolean; } + +export type ModelVisibilityScope = "models" | "provider"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function uniqueStrings(value: unknown): string[] { + if (!Array.isArray(value) || value.some(item => typeof item !== "string")) { + throw new Error("invalid model list"); + } + return [...new Set(value)]; +} + +export function parseSelectedModels(value: unknown): ProviderModelMap { + if (!isRecord(value)) throw new Error("invalid selected models response"); + const selected = value.selected; + if (!isRecord(selected)) throw new Error("invalid selected models response"); + return Object.fromEntries( + Object.entries(selected).map(([provider, ids]) => [provider, uniqueStrings(ids)]), + ); +} + +export async function fetchSelectedModels( + apiBase: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl(`${apiBase}/api/selected-models`); + if (!response.ok) throw new Error(`selected models HTTP ${response.status}`); + return parseSelectedModels(await response.json()); +} + +export function modelIncluded( + selected: ProviderModelMap, + provider: string, + modelId: string, + native = false, +): boolean { + if (native) return true; + const allowlist = selected[provider]; + return !allowlist || allowlist.length === 0 || allowlist.includes(modelId); +} + +export function modelVisible( + selected: ProviderModelMap, + provider: string, + modelId: string, + native: boolean, + blocked: boolean, +): boolean { + return modelIncluded(selected, provider, modelId, native) && !blocked; +} + +export async function putModelVisibility( + apiBase: string, + scope: ModelVisibilityScope, + provider: string, + targets: ModelVisibilityTarget[], + enabled: boolean, + fetchImpl: typeof fetch = fetch, +): Promise { + return fetchImpl(`${apiBase}/api/model-visibility`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scope, provider, targets, enabled }), + }); +} + +export function shouldApplyLoadGeneration(request: number, current: number): boolean { + return request === current; +} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index a09dae303..94b66fafc 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -10,6 +10,15 @@ import { type ConfiguredProviderSummary, type ProviderDiscoverySummary, } from "../models-groups"; +import { + fetchSelectedModels, + modelVisible, + putModelVisibility, + shouldApplyLoadGeneration, + type ProviderModelMap, + type ModelVisibilityScope, + type ModelVisibilityTarget, +} from "../model-visibility"; interface ModelRow { provider: string; @@ -65,10 +74,15 @@ function collectDisabledNamespaced(rows: ModelRow[]): Set { return next; } -function activeModelOptions(models: ModelRow[], disabled: Set): { value: string; label: string }[] { +function activeModelOptions( + models: ModelRow[], + disabled: Set, + selected: ProviderModelMap, +): { value: string; label: string }[] { const options: { value: string; label: string }[] = []; for (const m of models) { - if (!disabled.has(m.id) && !disabled.has(m.namespaced)) { + const blocked = disabled.has(m.id) || disabled.has(m.namespaced); + if (modelVisible(selected, m.provider, m.id, m.native === true, blocked)) { options.push({ value: m.namespaced, label: m.namespaced }); } } @@ -80,6 +94,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const [models, setModels] = useState([]); const [providers, setProviders] = useState([]); const [disabled, setDisabled] = useState>(new Set()); + const [selectedModels, setSelectedModels] = useState(null); const [search, setSearch] = useState>({}); const [limit, setLimit] = useState>({}); const [contextCaps, setContextCaps] = useState>({}); @@ -97,6 +112,8 @@ export default function Models({ apiBase }: { apiBase: string }) { const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const busyRef = useRef(false); + const loadGenerationRef = useRef(0); + const loadPendingRef = useRef(false); // multi_agent_v2 / ultra gate. null = endpoint unavailable (older proxy build) -> section hidden. const [v2, setV2] = useState(null); const [v2Busy, setV2Busy] = useState(false); @@ -150,8 +167,8 @@ export default function Models({ apiBase }: { apiBase: string }) { }, []); const shadowModelOptions = useMemo( - () => activeModelOptions(models, disabled), - [models, disabled], + () => activeModelOptions(models, disabled, selectedModels ?? {}), + [models, disabled, selectedModels], ); const loadShadowCall = useCallback(async () => { @@ -181,28 +198,42 @@ export default function Models({ apiBase }: { apiBase: string }) { } }, [apiBase]); - const load = useCallback(async () => { + const load = useCallback(async (force = false): Promise => { + if (loadPendingRef.current && !force) return false; + loadPendingRef.current = true; + const generation = ++loadGenerationRef.current; try { const [data, capsData] = await Promise.all([ - fetch(`${apiBase}/api/models`).then(r => r.json()) as Promise, - fetch(`${apiBase}/api/provider-context-caps`).then(r => r.json()) as Promise, + fetch(`${apiBase}/api/models`).then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) as Promise, + fetch(`${apiBase}/api/provider-context-caps`).then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) as Promise, + ]); + const [providerData, selectionData] = await Promise.all([ + fetch(`${apiBase}/api/providers`).then(r => r.ok ? r.json() : Promise.reject(new Error(String(r.status)))) as Promise, + fetchSelectedModels(apiBase), ]); - const providerData = await fetch(`${apiBase}/api/providers`) - .then(r => r.json()) as ConfiguredProviderSummary[]; + if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) return false; void loadV2(); // best-effort, independent of the models fetch void loadShadowCall(); setModels(data); setProviders(providerData); setDisabled(collectDisabledNamespaced(data)); + setSelectedModels(selectionData); const value = typeof capsData.value === "number" && Number.isFinite(capsData.value) && capsData.value > 0 ? capsData.value : (typeof capsData.cap === "number" && Number.isFinite(capsData.cap) && capsData.cap > 0 ? capsData.cap : undefined); if (value !== undefined) setContextCapValue(value); setContextCaps(capsData.caps ?? {}); + return true; } catch { - setOk(false); setStatus(t("models.loadFail")); + if (shouldApplyLoadGeneration(generation, loadGenerationRef.current)) { + setOk(false); setStatus(t("models.loadFail")); + } + return false; } finally { - setLoading(false); + if (shouldApplyLoadGeneration(generation, loadGenerationRef.current)) { + loadPendingRef.current = false; + setLoading(false); + } } }, [apiBase, loadShadowCall, loadV2, t]); useEffect(() => { @@ -228,32 +259,47 @@ export default function Models({ apiBase }: { apiBase: string }) { [models, providers], ); - const apply = async (next: Set) => { + const effectiveVisibleCount = useMemo(() => { + if (!selectedModels) return 0; + return models.filter(model => modelVisible( + selectedModels, + model.provider, + model.id, + model.native === true, + disabled.has(model.namespaced), + )).length; + }, [disabled, models, selectedModels]); + + const applyVisibility = async ( + scope: ModelVisibilityScope, + provider: string, + targets: ModelVisibilityTarget[], + enabled: boolean, + ) => { + ++loadGenerationRef.current; setBusy(true); busyRef.current = true; setStatus(""); + let errorKey: "models.saveFailed" | "models.networkError" | null = null; try { - const r = await fetch(`${apiBase}/api/disabled-models`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ models: [...next] }), - }); - if (r.ok) { setDisabled(next); setOk(true); setStatus(t("models.applied")); } - else { setOk(false); setStatus(t("models.saveFailed")); } + const response = await putModelVisibility(apiBase, scope, provider, targets, enabled); + if (!response.ok) errorKey = "models.saveFailed"; } catch { - setOk(false); setStatus(t("models.networkError")); + errorKey = "models.networkError"; } finally { + const refreshed = await load(true); + if (errorKey) { + setOk(false); + setStatus(t(errorKey)); + } else if (refreshed) { + setOk(true); + setStatus(t("models.applied")); + } setBusy(false); busyRef.current = false; } }; - const toggle = (ns: string) => { - const next = new Set(disabled); - if (next.has(ns)) next.delete(ns); else next.add(ns); - apply(next); - }; - const toggleProviderCap = async (provider: string) => { setBusy(true); busyRef.current = true; @@ -270,7 +316,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setContextCaps(data.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); - await load(); + await load(true); } else { setOk(false); setStatus(t("models.capSaveFailed")); @@ -307,7 +353,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setContextCaps(data.caps ?? {}); setOk(true); setStatus(t("models.capApplied")); - await load(); + await load(true); } else { setOk(false); setStatus(t("models.capSaveFailed")); @@ -481,7 +527,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomModalOpen(false); setOk(true); setStatus(t("models.customAdded")); - await load(); + await load(true); } else { const data = await r.json().catch(() => null) as { error?: string } | null; setCustomError(data?.error ?? t("models.customSaveFailed")); @@ -506,7 +552,7 @@ export default function Models({ apiBase }: { apiBase: string }) { setCustomModalOpen(false); setOk(true); setStatus(t("models.customUpdated")); - await load(); + await load(true); } else { const data = await r.json().catch(() => null) as { error?: string } | null; setCustomError(data?.error ?? t("models.customSaveFailed")); @@ -524,7 +570,7 @@ export default function Models({ apiBase }: { apiBase: string }) { if (r.ok) { setOk(true); setStatus(t("models.customDeleted")); - await load(); + await load(true); } else { setOk(false); setStatus(t("models.customSaveFailed")); @@ -536,13 +582,16 @@ export default function Models({ apiBase }: { apiBase: string }) { }; if (loading) return
{t("models.loading")}
; + if (!selectedModels) { + return {t("models.loadFail")}; + } return ( <>

{t("nav.models")}

- {t("models.active", { active: models.length - disabled.size, total: models.length })} + {t("models.active", { active: effectiveVisibleCount, total: models.length })}

{t("models.subtitle")}

{status && {status}} @@ -733,26 +782,30 @@ export default function Models({ apiBase }: { apiBase: string }) { // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. groups.map(({ provider, rows, native: isNative, liveModels, discovery }) => { const isCollapsed = collapsed.has(provider); - const activeCount = rows.filter(m => !disabled.has(m.namespaced)).length; + const isVisible = (model: ModelRow) => modelVisible( + selectedModels, + provider, + model.id, + model.native === true, + disabled.has(model.namespaced), + ); + const activeCount = rows.filter(isVisible).length; const capOn = contextCaps[provider] === contextCapValue; const q = (search[provider] ?? "").trim().toLowerCase(); - const filtered = q ? rows.filter(m => m.id.toLowerCase().includes(q)) : rows; - // Display-only: enabled models float to the top of each provider group so they - // stay findable in long lists. The sort is stable, so the server order is kept - // inside each partition, and this does not affect the picker order above - // (visibility toggles still only filter). - const sorted = [...filtered].sort((a, b) => Number(disabled.has(a.namespaced)) - Number(disabled.has(b.namespaced))); + const filtered = q ? rows.filter(model => model.id.toLowerCase().includes(q)) : rows; + // Display-only: models visible to Codex float to the top. The stable sort does not + // change catalog or picker ordering. + const sorted = [...filtered].sort((a, b) => Number(!isVisible(a)) - Number(!isVisible(b))); const shown = limit[provider] ?? PAGE; const visible = sorted.slice(0, shown); const remaining = filtered.length - visible.length; - const discoveryFailure = liveModels && discovery?.status === "failed" ? discovery : undefined; - const allOn = rows.length > 0 && rows.every(m => !disabled.has(m.namespaced)); - const allOff = rows.length > 0 && rows.every(m => disabled.has(m.namespaced)); - const bulkToggle = (enable: boolean) => { - const next = new Set(disabled); - for (const m of rows) { if (enable) next.delete(m.namespaced); else next.add(m.namespaced); } - apply(next); - }; + const discoveryFailure = liveModels && discovery?.status === "failed" ? discovery : undefined; + const allOn = rows.length > 0 && rows.every(isVisible); + const allOff = rows.length > 0 && rows.every(model => !isVisible(model)); + const targets = rows.map(model => ({ id: model.id, ...(model.native ? { native: true } : {}) })); + const bulkToggle = (enable: boolean) => { + void applyVisibility("provider", provider, targets, enable); + }; return (
toggleCollapse(provider)} @@ -819,9 +872,9 @@ export default function Models({ apiBase }: { apiBase: string }) { onChange={e => setSearch(prev => ({ ...prev, [provider]: e.target.value }))} aria-label={t("models.search")} /> - )} + )} {visible.map(m => { - const off = disabled.has(m.namespaced); + const on = isVisible(m); return (
- toggle(m.namespaced)} disabled={busy} label={m.native ? m.id : m.namespaced} /> - {m.native ? modelLabel(m.id) : m.namespaced} + void applyVisibility("models", provider, [{ id: m.id, ...(m.native ? { native: true } : {}) }], !on)} + disabled={busy} + label={m.native ? m.id : m.namespaced} + /> + {m.native ? modelLabel(m.id) : m.namespaced} {m.custom && ( {t("models.customBadge")} @@ -884,7 +942,7 @@ export default function Models({ apiBase }: { apiBase: string }) { )} {t("models.tipStatus")} - {off ? t("models.tipDisabled") : t("models.tipActive")} + {on ? t("models.tipActive") : t("models.tipDisabled")}
{m.custom && m.customId && (
diff --git a/gui/tests/model-visibility.test.tsx b/gui/tests/model-visibility.test.tsx new file mode 100644 index 000000000..b2cc62a2b --- /dev/null +++ b/gui/tests/model-visibility.test.tsx @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; +import { modelVisible, parseSelectedModels, shouldApplyLoadGeneration } from "../src/model-visibility"; + +test("final visibility helpers normalize selections and stale generations", () => { + expect([modelVisible({ proxy: ["a"] }, "proxy", "a", false, false), modelVisible({ proxy: ["a"] }, "proxy", "b", false, false), modelVisible({ openai: ["other"] }, "openai", "gpt-5.6-sol", true, true)]).toEqual([true, false, false]); + expect(parseSelectedModels({ selected: { proxy: ["a", "a", "b"] } })).toEqual({ proxy: ["a", "b"] }); + expect(() => parseSelectedModels({ selected: { proxy: "a" } })).toThrow(); + expect(shouldApplyLoadGeneration(4, 5)).toBe(false); +}); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 3d41ee554..f127bfb73 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -68,7 +68,7 @@ async function providerDto( return providers[0] ?? {}; } -test("Models page keeps the discovery-failure badge visible with fallback rows", async () => { +test("Models page combines final visibility, atomic actions, discovery status, and serialized polling", async () => { const domGlobals = ["document", "window", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; const previousDescriptors = Object.fromEntries( domGlobals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), @@ -77,6 +77,11 @@ test("Models page keeps the discovery-failure badge visible with fallback rows", const container = testWindow.document.createElement("div"); testWindow.document.body.append(container); let root: Root | undefined; + let poll: (() => void) | undefined; + Object.defineProperty(testWindow, "setInterval", { + configurable: true, + value: (handler: () => void) => { poll = handler; return 1; }, + }); Object.defineProperties(globalThis, { document: { configurable: true, value: testWindow.document }, @@ -84,26 +89,47 @@ test("Models page keeps the discovery-failure badge visible with fallback rows", localStorage: { configurable: true, value: testWindow.localStorage }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, }); - globalThis.fetch = (async (input) => { + const provider = "fallback-provider"; + const ids = ["claude-opus", "claude-sonnet", "gemini-pro", "gemini-flash", "gpt-oss"]; + let selected = ["gemini-pro", "gemini-flash"]; + const disabled = new Set(["gpt-oss"]); + const visibilityBodies: Array<{ scope: string; targets: Array<{ id: string }>; enabled: boolean }> = []; + let failNext = false; + let modelFetches = 0; + let resolveModels!: (response: Response) => void; + const firstModels = new Promise(resolve => { resolveModels = resolve; }); + const rows = () => ids.map(id => ({ provider, id, namespaced: `${provider}/${id}`, disabled: disabled.has(id) })); + globalThis.fetch = (async (input, init) => { const url = String(input); if (url.endsWith("/api/models")) { - return Response.json([{ - provider: "fallback-provider", - id: "configured-fallback", - namespaced: "fallback-provider/configured-fallback", - disabled: false, - }]); + modelFetches += 1; + return modelFetches === 1 ? firstModels : Response.json(rows()); } if (url.endsWith("/api/providers")) { return Response.json([{ - name: "fallback-provider", + name: provider, liveModels: true, - models: ["configured-fallback"], + models: ids, discovery: { status: "failed", reason: "http", httpStatus: 401 }, }]); } + if (url.endsWith("/api/selected-models")) return Response.json({ selected: { [provider]: selected }, available: { [provider]: ids } }); if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); - if (url.endsWith("/api/combos")) return Response.json([]); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: true, model: `${provider}/gemini-pro` }); + if (url.endsWith("/api/model-visibility") && init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as (typeof visibilityBodies)[number]; + visibilityBodies.push(body); + if (failNext) { failNext = false; return Response.json({ error: "failed" }, { status: 500 }); } + if (body.scope === "provider") { + if (body.enabled) { selected = []; disabled.clear(); } + else for (const target of body.targets) disabled.add(target.id); + } else for (const target of body.targets) { + if (body.enabled) { if (selected.length > 0 && !selected.includes(target.id)) selected.push(target.id); disabled.delete(target.id); } + else disabled.add(target.id); + } + return Response.json({ ok: true }); + } return new Response(null, { status: 404 }); }) as typeof fetch; @@ -121,10 +147,42 @@ test("Models page keeps the discovery-failure badge visible with fallback rows", await new Promise(resolve => testWindow.setTimeout(resolve, 0)); await Promise.resolve(); }); + poll?.(); + expect(modelFetches).toBe(1); + await act(async () => { + resolveModels(Response.json(rows())); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); - expect(container.textContent).toContain("fallback-provider/configured-fallback"); + const switchFor = (id: string) => container.querySelector(`button[aria-label="${provider}/${id}"]`)!; + const buttonText = (text: string) => [...container.querySelectorAll("button")].find(button => button.textContent === text)!; + expect(container.textContent).toContain("2/5 active"); + expect(switchFor("gemini-pro").getAttribute("aria-pressed")).toBe("true"); + expect(switchFor("claude-sonnet").getAttribute("aria-pressed")).toBe("false"); expect(container.querySelector(".badge.badge-amber")?.textContent).toContain("Discovery failed"); - expect(container.textContent).not.toContain("No models were discovered"); + expect(container.textContent).not.toContain("Not selected"); + + await act(async () => container.querySelector('button.select-trigger[aria-label="Shadow Call Intercept"]')?.click()); + const shadowOptions = [...container.querySelectorAll('[role="option"]')].map(option => option.textContent); + expect(shadowOptions).toContain(`${provider}/gemini-pro`); + expect(shadowOptions).not.toContain(`${provider}/claude-opus`); + + await act(async () => { switchFor("claude-sonnet").click(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + expect(visibilityBodies.at(-1)).toMatchObject({ scope: "models", targets: [{ id: "claude-sonnet" }], enabled: true }); + expect(container.textContent).toContain("3/5 active"); + + failNext = true; + await act(async () => { switchFor("claude-opus").click(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + expect(switchFor("claude-opus").getAttribute("aria-pressed")).toBe("false"); + expect(container.textContent).toContain("Save failed"); + + await act(async () => { buttonText("All on").click(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + expect(visibilityBodies.at(-1)).toMatchObject({ scope: "provider", enabled: true }); + expect(container.textContent).toContain("5/5 active"); + await act(async () => { buttonText("All off").click(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + expect(visibilityBodies.at(-1)).toMatchObject({ scope: "provider", enabled: false }); + expect(container.textContent).toContain("0/5 active"); } finally { if (root) { await act(async () => root?.unmount()); diff --git a/src/combos/index.ts b/src/combos/index.ts index 63b234b8c..f1a0e8ebd 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -11,6 +11,7 @@ export { listComboIds, normalizeComboConfig, parseComboModelId, + preservesPhysicalComboProvider, resolveComboId, targetKey, } from "./types"; diff --git a/src/combos/types.ts b/src/combos/types.ts index b152448f9..7ed952ffd 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -4,10 +4,19 @@ import type { OcxComboDefaultEffort, OcxComboStrategy, OcxComboTarget, + OcxConfig, OcxProviderConfig, } from "../types"; export const COMBO_NAMESPACE = "combo"; + +export function preservesPhysicalComboProvider( + config: Pick, +): boolean { + return Object.hasOwn(config.providers, COMBO_NAMESPACE) + && Object.keys(config.combos ?? {}).length === 0; +} + const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; /** * Public alias shape: one optional "/" segment, each segment id-shaped. Bare aliases diff --git a/src/router.ts b/src/router.ts index 48e4bc830..618f43b34 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types"; -import { COMBO_NAMESPACE, tryPickComboModel, type ComboPick } from "./combos"; +import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } from "./combos"; import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; @@ -235,10 +235,7 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId: } function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult { - const preservePhysicalComboProvider = - hasOwnProvider(config.providers, COMBO_NAMESPACE) - && Object.keys(config.combos ?? {}).length === 0; - if (!bypassCombos && !preservePhysicalComboProvider) { + if (!bypassCombos && !preservesPhysicalComboProvider(config)) { const combo = tryPickComboModel(config, modelId); if (combo) { const concrete = `${combo.target.provider}/${combo.target.model}`; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 39ea5c258..7e35531db 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -27,6 +27,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; +import { COMBO_NAMESPACE, comboModelId, comboPublicModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; @@ -128,6 +129,105 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise row.slug)); + const targets: Array<{ id: string; native: boolean }> = []; + const seen = new Set(); + for (const value of body.targets) { + if (!isPlainRecord(value) || typeof value.id !== "string" || (value.native !== undefined && typeof value.native !== "boolean")) { + return jsonResponse({ error: "invalid model visibility target" }, 400); + } + const id = value.id.trim(); + const native = value.native === true; + if (!id || (provider === "openai") !== native || (native && !supportedNative.has(id))) { + return jsonResponse({ error: "invalid model visibility target" }, 400); + } + const key = `${native ? "native" : "routed"}:${id}`; + if (!seen.has(key)) { + seen.add(key); + targets.push({ id, native }); + } + } + if (targets.length === 0) return jsonResponse({ error: "model visibility targets required" }, 400); + + const knownComboSelectors = new Set( + Object.entries(config.combos ?? {}).flatMap(([id, combo]) => [ + comboModelId(id), + comboPublicModelId(id, combo), + ]), + ); + const targetComboSelectors = new Map>(); + if (isVirtualComboNamespace) { + for (const target of targets) { + const combo = config.combos && Object.hasOwn(config.combos, target.id) ? config.combos[target.id] : undefined; + if (!combo) return jsonResponse({ error: "invalid model visibility target" }, 400); + targetComboSelectors.set(target.id, new Set([comboModelId(target.id), comboPublicModelId(target.id, combo)])); + } + } + const matchesTarget = (stored: string, target: { id: string; native: boolean }) => target.native + ? stored === target.id + : isVirtualComboNamespace + ? targetComboSelectors.get(target.id)!.has(stored) + : slugEquals(stored, provider, target.id); + + let disabled = [...new Set(config.disabledModels ?? [])]; + if (body.enabled) { + if (scope === "provider") { + if (providerConfig && !isVirtualComboNamespace) delete providerConfig.selectedModels; + if (isVirtualComboNamespace) { + disabled = disabled.filter(stored => !knownComboSelectors.has(stored)); + } else { + const nativeIds = provider === "openai" + ? disabledNativeSlugs({ disabledModels: disabled }) + : new Set(); + disabled = disabled.filter(stored => ( + knownComboSelectors.has(stored) + || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored)) + )); + } + } else { + if (!isVirtualComboNamespace && providerConfig?.selectedModels && providerConfig.selectedModels.length > 0) { + const additions = targets.filter(target => !target.native).map(target => target.id); + providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])]; + } + disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target))); + } + } else { + for (const target of targets) { + const canonical = target.native + ? target.id + : isVirtualComboNamespace + ? comboModelId(target.id) + : routedSlug(provider, target.id); + const alreadyDisabled = disabled.some(stored => matchesTarget(stored, target)); + if (!alreadyDisabled) disabled.push(canonical); + } + } + + config.disabledModels = disabled; + saveConfig(config); + await refreshCodexCatalogBestEffort(); + return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled }); + } + if (url.pathname === "/api/custom-models" && req.method === "GET") { return jsonResponse(config.customModels ?? []); } diff --git a/tests/combos.test.ts b/tests/combos.test.ts index d2068b2d7..417665ec1 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -27,6 +27,7 @@ import { parseComboModelId, parseRetryAfterMs, pickComboTarget, + preservesPhysicalComboProvider, resetComboEffortWarningStateForTests, resolveComboId, targetKey, @@ -534,6 +535,12 @@ describe("combo validation and normalization", () => { combo: { adapter: "openai-chat", baseUrl: "https://combo.example/v1" }, }, }; + expect(preservesPhysicalComboProvider(config)).toBeTrue(); + expect(preservesPhysicalComboProvider({ ...config, combos: {} })).toBeTrue(); + expect(preservesPhysicalComboProvider({ providers: {}, combos: {} })).toBeFalse(); + expect(preservesPhysicalComboProvider({ ...config, combos: { free: VALID_COMBO } })).toBeFalse(); + const inheritedProviders = Object.create({ combo: config.providers.combo }) as OcxConfig["providers"]; + expect(preservesPhysicalComboProvider({ providers: inheritedProviders, combos: {} })).toBeFalse(); expect(routeModel(config, "combo/model")).toMatchObject({ providerName: "combo", modelId: "model", diff --git a/tests/model-visibility-management-api.test.ts b/tests/model-visibility-management-api.test.ts new file mode 100644 index 000000000..b36a2016b --- /dev/null +++ b/tests/model-visibility-management-api.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { nativeModelRows } from "../src/codex/catalog"; +import { loadConfig, saveConfig } from "../src/config"; +import { handleManagementAPI } from "../src/server/management-api"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let refreshes = 0; + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + isolatedCodexHome = installIsolatedCodexHome("ocx-model-visibility-codex-"); + refreshes = 0; + saveConfig({ + port: 0, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "test-key", + liveModels: false, + models: ["claude-opus-4-6-thinking", "claude-sonnet-4-6", "gemini-3.1-pro", "gemini-3.6-flash", "gpt-oss-120b-medium", "vendor/model"], + selectedModels: ["gemini-3.1-pro", "gemini-3.6-flash"], + }, + }, + combos: { + free: { alias: "fast-chat", targets: [{ provider: "google-antigravity", model: "gemini-3.1-pro" }] }, + plain: { targets: [{ provider: "google-antigravity", model: "gemini-3.6-flash" }] }, + }, + disabledModels: ["google-antigravity/gpt-oss-120b-medium", "google-antigravity/temporarily-missing", "other/keep"], + }); +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); +}); + +async function putWithConfig(body: unknown, config = loadConfig()): Promise { + const url = new URL("http://localhost/api/model-visibility"); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }), url, config, { refreshCodexCatalog: async () => { refreshes += 1; } }); + if (!response) throw new Error("model visibility route was not handled"); + return response; +} + +async function put(body: unknown): Promise { + return putWithConfig(body); +} + +describe("atomic model visibility management", () => { + test("enables excluded or blocked models and disables without erasing the allowlist", async () => { + expect((await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "claude-sonnet-4-6" }], enabled: true })).status).toBe(200); + expect(loadConfig().providers["google-antigravity"].selectedModels) + .toEqual(["gemini-3.1-pro", "gemini-3.6-flash", "claude-sonnet-4-6"]); + + expect((await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "gpt-oss-120b-medium" }], enabled: true })).status).toBe(200); + expect(loadConfig().disabledModels).not.toContain("google-antigravity/gpt-oss-120b-medium"); + + expect((await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "gemini-3.1-pro" }], enabled: false })).status).toBe(200); + expect(loadConfig().disabledModels).toContain("google-antigravity/gemini-3.1-pro"); + expect(loadConfig().providers["google-antigravity"].selectedModels).toContain("gemini-3.1-pro"); + expect(refreshes).toBe(3); + }); + + test("all-on enters future-proof All mode while all-off blocks only current targets", async () => { + const targets = ["claude-sonnet-4-6", "gemini-3.1-pro", "gpt-oss-120b-medium"].map(id => ({ id })); + expect((await put({ scope: "provider", provider: "google-antigravity", targets, enabled: true })).status).toBe(200); + expect(loadConfig().providers["google-antigravity"].selectedModels).toBeUndefined(); + expect(loadConfig().disabledModels).toEqual(["other/keep"]); + + expect((await put({ scope: "provider", provider: "google-antigravity", targets, enabled: false })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual([ + "other/keep", + "google-antigravity/claude-sonnet-4-6", + "google-antigravity/gemini-3.1-pro", + "google-antigravity/gpt-oss-120b-medium", + ]); + expect(loadConfig().disabledModels).not.toContain("google-antigravity/future-model"); + expect(refreshes).toBe(2); + }); + + test("all-on clears stale native ids while preserving combo selectors", async () => { + const config = loadConfig(); + const targets = nativeModelRows(config).map(row => ({ id: row.slug, native: true })); + expect(targets.length).toBeGreaterThan(0); + config.disabledModels = [ + targets[0]!.id, + "stale-native-model", + "fast-chat", + "combo/free", + "google-antigravity/keep", + "other/keep", + ]; + saveConfig(config); + + expect((await put({ scope: "provider", provider: "openai", targets, enabled: true })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual([ + "fast-chat", + "combo/free", + "google-antigravity/keep", + "other/keep", + ]); + expect(refreshes).toBe(1); + }); + + test("treats a physical combo provider with no configured combos as a routed provider", async () => { + saveConfig({ + port: 0, + defaultProvider: "combo", + providers: { + combo: { + adapter: "openai-chat", + baseUrl: "https://combo.example.test/v1", + apiKey: "test-key", + liveModels: false, + models: ["model-a", "vendor/model"], + selectedModels: ["model-a"], + }, + }, + combos: {}, + disabledModels: ["combo/vendor-model", "combo/temporarily-missing", "other/keep"], + }); + + expect((await put({ scope: "models", provider: "combo", targets: [{ id: "vendor/model" }], enabled: true })).status).toBe(200); + expect(loadConfig().providers.combo.selectedModels).toEqual(["model-a", "vendor/model"]); + expect(loadConfig().disabledModels).toEqual(["combo/temporarily-missing", "other/keep"]); + expect(refreshes).toBe(1); + + expect((await put({ scope: "models", provider: "combo", targets: [{ id: "model-a" }], enabled: false })).status).toBe(200); + expect(loadConfig().providers.combo.selectedModels).toEqual(["model-a", "vendor/model"]); + expect(loadConfig().disabledModels).toEqual(["combo/temporarily-missing", "other/keep", "combo/model-a"]); + expect(refreshes).toBe(2); + + const targets = [{ id: "model-a" }, { id: "vendor/model" }]; + expect((await put({ scope: "provider", provider: "combo", targets, enabled: false })).status).toBe(200); + expect(loadConfig().providers.combo.selectedModels).toEqual(["model-a", "vendor/model"]); + expect(loadConfig().disabledModels).toEqual([ + "combo/temporarily-missing", + "other/keep", + "combo/model-a", + "combo/vendor-model", + ]); + expect(loadConfig().disabledModels).not.toContain("combo/future-model"); + expect(refreshes).toBe(3); + + expect((await put({ scope: "provider", provider: "combo", targets, enabled: true })).status).toBe(200); + expect(loadConfig().providers.combo.selectedModels).toBeUndefined(); + expect(loadConfig().disabledModels).toEqual(["other/keep"]); + expect(refreshes).toBe(4); + }); + + test("preserves provider-prefixed combo aliases until the combo provider enables them", async () => { + const config = loadConfig(); + config.providers.anthropic = { + adapter: "openai-chat", + baseUrl: "https://anthropic.example.test/v1", + apiKey: "test-key", + liveModels: false, + models: ["claude-a"], + selectedModels: ["claude-a"], + }; + config.combos!.free!.alias = "anthropic/fast"; + config.disabledModels = [ + "anthropic/claude-a", + "anthropic/temporarily-missing", + "anthropic/fast", + "combo/free", + "combo/plain", + "other/keep", + "other/provider", + ]; + saveConfig(config); + + expect((await put({ scope: "provider", provider: "anthropic", targets: [{ id: "claude-a" }], enabled: true })).status).toBe(200); + expect(loadConfig().providers.anthropic.selectedModels).toBeUndefined(); + expect(loadConfig().disabledModels).toEqual([ + "anthropic/fast", + "combo/free", + "combo/plain", + "other/keep", + "other/provider", + ]); + expect(refreshes).toBe(1); + + expect((await put({ scope: "provider", provider: "combo", targets: [{ id: "free" }, { id: "plain" }], enabled: true })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual(["other/keep", "other/provider"]); + expect(refreshes).toBe(2); + }); + + test("keeps a colliding physical combo allowlist untouched when virtual combos take precedence", async () => { + const config = loadConfig(); + config.providers.combo = { + adapter: "openai-chat", + baseUrl: "https://combo.example.test/v1", + models: ["physical-only"], + selectedModels: ["physical-only"], + }; + config.combos = { + free: { alias: "anthropic/fast", targets: [{ provider: "google-antigravity", model: "gemini-3.1-pro" }] }, + }; + config.disabledModels = ["anthropic/fast", "other/keep"]; + + expect((await putWithConfig({ scope: "models", provider: "combo", targets: [{ id: "free" }], enabled: true }, config)).status).toBe(200); + expect(config.providers.combo.selectedModels).toEqual(["physical-only"]); + expect(config.disabledModels).toEqual(["other/keep"]); + expect(refreshes).toBe(1); + + config.disabledModels = ["combo/free", "anthropic/fast", "other/keep"]; + expect((await putWithConfig({ scope: "provider", provider: "combo", targets: [{ id: "free" }], enabled: true }, config)).status).toBe(200); + expect(config.providers.combo.selectedModels).toEqual(["physical-only"]); + expect(config.disabledModels).toEqual(["other/keep"]); + expect(refreshes).toBe(2); + }); + + test("toggles canonical and aliased combo rows", async () => { + const config = loadConfig(); + config.disabledModels?.push("fast-chat", "combo/plain"); + saveConfig(config); + expect((await put({ scope: "models", provider: "combo", targets: [{ id: "free" }, { id: "plain" }], enabled: true })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual(["google-antigravity/gpt-oss-120b-medium", "google-antigravity/temporarily-missing", "other/keep"]); + expect((await put({ scope: "models", provider: "combo", targets: [{ id: "free" }], enabled: false })).status).toBe(200); + expect(loadConfig().disabledModels).toContain("combo/free"); + const beforeAllOn = loadConfig(); + beforeAllOn.disabledModels?.push("fast-chat", "combo/plain"); + saveConfig(beforeAllOn); + expect((await put({ scope: "provider", provider: "combo", targets: [{ id: "free" }, { id: "plain" }], enabled: true })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual(["google-antigravity/gpt-oss-120b-medium", "google-antigravity/temporarily-missing", "other/keep"]); + expect((await put({ scope: "provider", provider: "combo", targets: [{ id: "free" }, { id: "plain" }], enabled: false })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual(expect.arrayContaining(["combo/free", "combo/plain"])); + expect((await put({ scope: "models", provider: "combo", targets: [{ id: "missing" }], enabled: true })).status).toBe(400); + expect(refreshes).toBe(4); + }); + + test("uses raw allowlist ids, canonical routed slugs, and rejects invalid requests", async () => { + await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "vendor/model" }, { id: "vendor/model" }], enabled: true }); + expect(loadConfig().providers["google-antigravity"].selectedModels).toContain("vendor/model"); + expect(loadConfig().providers["google-antigravity"].selectedModels).not.toContain("google-antigravity/vendor-model"); + await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "vendor/model" }], enabled: false }); + expect(loadConfig().disabledModels).toContain("google-antigravity/vendor-model"); + + const before = loadConfig(); + expect((await put("{")).status).toBe(400); + for (const nonObject of [null, [], 1, JSON.stringify("value")]) { + expect((await put(nonObject)).status).toBe(400); + } + expect((await put({ scope: "bad", provider: "google-antigravity", targets: [], enabled: true })).status).toBe(400); + expect((await put({ scope: "models", provider: "missing-provider", targets: [{ id: "model" }], enabled: true })).status).toBe(400); + expect((await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "gpt-5.6-sol", native: true }], enabled: true })).status).toBe(400); + expect((await put({ scope: "models", provider: "openai", targets: [{ id: "gpt-5.6-sol" }], enabled: true })).status).toBe(400); + expect((await put({ scope: "models", provider: "combo", targets: [{ id: "toString" }], enabled: true })).status).toBe(400); + expect(loadConfig()).toEqual(before); + expect(refreshes).toBe(2); + }); +});