From 04c4a8041a486015b8dbe7c254c6baa107afa616 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 08:58:54 +0900 Subject: [PATCH 1/3] feat(catalog): mark quota-exhausted models and combos inactive Served catalog rows were always stamped visibility: "list" with no field that could say "offered, but a request would fail right now". The quota was already known -- ProviderQuota.creditsUsd.remaining is written on probe and read back within 30 minutes, and the combo loop already refuses exhausted targets and throws NoAvailableComboTargetsError -- but it never reached the catalog. The catalog's only "inactive" mechanisms REMOVE the row: the live visibility filter, the disabled-routed-key merge drop, and native visibility: "hide". Hiding is precisely what the issue rejects. quotaInactiveReason() reuses the runtime rules in targetProviderIsUsable rather than the Dashboard's quotaStateFromReport, which is harsher -- it treats remaining <= 0 as exhausted without requiring percent >= 100 and ignores an elapsed resetAt. A row marked inactive on the harsher rule would contradict the router, which would still send the request. Three inherited rules carry it: a removed or disabled target drops out of the vote rather than counting as evidence; the canonical ChatGPT forward provider is exempt because native account selection owns model-scoped quota; and a stale cache is not exhaustion, because getCachedProviderQuota returns null past its window and a null reading ends the vote. "Every usable target" is the bar, since one target that can still serve makes the row serviceable. The field is stamped once, in the single place that has both the finished model list and the config -- so routed, combo, and custom rows are treated alike -- and deriveEntry writes it as opencodex_inactive_reason, matching the existing opencodex_* extension style. visibility is never touched, the operator-disable filter is not involved, and ManagementModelRow.disabled is not reused, because that means the operator's own disabledModels. Closes #1711. --- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Models.tsx | 10 +- gui/src/pages/models-shared.ts | 6 + scripts/test-layout/layout.json | 1 + src/codex/catalog/parsing.ts | 11 ++ src/codex/catalog/provider-fetch.ts | 12 +- src/codex/catalog/sync.ts | 10 ++ src/combos/index.ts | 2 + src/combos/resolve.ts | 52 +++++++ .../catalog-zero-credit-picker.test.ts | 139 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 19 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 tests/codex-integration/catalog-zero-credit-picker.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b92691411a..3f8fe73c56 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -647,6 +647,8 @@ export const de: Record = { "models.discoveryFailedProvider": "Der Anbieter meldete einen Fehler bei der Modellerkennung.", "models.discoveryFailedGeneric": "Die Modellerkennung ist fehlgeschlagen.", "models.openProviderSettings": "Anbietereinstellungen öffnen", + "models.inactiveNoCredit": "Kein Guthaben", + "models.inactiveNoCreditHint": "Alle Anbieter hinter diesem Eintrag haben gerade kein Guthaben, eine Anfrage würde also fehlschlagen. Der Eintrag bleibt gelistet und ist wieder nutzbar, sobald das Guthaben zurückgesetzt oder aufgeladen wird.", "models.loading": "Lädt…", "models.search": "Modelle suchen…", "models.showMore": "{n} weitere anzeigen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a8f6cf20a0..3d9b9e3c64 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -676,6 +676,8 @@ export const en = { "models.discoveryFailedProvider": "The provider reported a model discovery error.", "models.discoveryFailedGeneric": "Model discovery failed.", "models.openProviderSettings": "Open provider settings", + "models.inactiveNoCredit": "No credit", + "models.inactiveNoCreditHint": "Every provider behind this entry is out of credit right now, so a request would fail. It stays listed and becomes usable again when credit resets or is topped up.", "models.loading": "Loading…", "models.search": "Search models…", "models.showMore": "Show {n} more", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 855a8a146f..0f550e59f5 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -661,6 +661,8 @@ export const fr: Record = { "models.discoveryFailedProvider": "Le fournisseur a signalé une erreur de détection des modèles.", "models.discoveryFailedGeneric": "Échec de la détection des modèles.", "models.openProviderSettings": "Ouvrir les paramètres du fournisseur", + "models.inactiveNoCredit": "Aucun crédit", + "models.inactiveNoCreditHint": "Tous les fournisseurs derrière cette entrée sont à court de crédit ; une requête échouerait. L’entrée reste listée et redevient utilisable une fois le crédit réinitialisé ou rechargé.", "models.loading": "Chargement…", "models.search": "Rechercher des modèles…", "models.showMore": "Afficher {n} de plus", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index be78373e77..96fb0df0c1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -605,6 +605,8 @@ export const ja: Record = { "models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。", "models.discoveryFailedGeneric": "モデル検出に失敗しました。", "models.openProviderSettings": "プロバイダー設定を開く", + "models.inactiveNoCredit": "クレジットなし", + "models.inactiveNoCreditHint": "このエントリの背後にあるプロバイダーはすべて現在クレジットがないため、リクエストは失敗します。一覧には残り、クレジットがリセットまたは追加されれば再び使えます。", "models.loading": "読み込み中…", "models.search": "モデルを検索…", "models.showMore": "さらに {n} 件表示", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 49b3b4f203..ba8067d718 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -658,6 +658,8 @@ export const ko: Record = { "models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.", "models.discoveryFailedGeneric": "모델 검색에 실패했습니다.", "models.openProviderSettings": "프로바이더 설정 열기", + "models.inactiveNoCredit": "크레딧 없음", + "models.inactiveNoCreditHint": "이 항목이 쓰는 프로바이더가 모두 크레딧이 없어서 지금 요청하면 실패해요. 목록에는 그대로 남고, 크레딧이 초기화되거나 충전되면 다시 쓸 수 있어요.", "models.loading": "불러오는 중…", "models.search": "모델 검색…", "models.showMore": "{n}개 더 보기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 72df6bb9db..96f8d0114a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -660,6 +660,8 @@ export const ru: Record = { "models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.", "models.discoveryFailedGeneric": "Не удалось обнаружить модели.", "models.openProviderSettings": "Открыть настройки провайдера", + "models.inactiveNoCredit": "Нет кредитов", + "models.inactiveNoCreditHint": "У всех провайдеров этой записи сейчас закончились кредиты, поэтому запрос завершится ошибкой. Запись остаётся в списке и снова заработает после сброса или пополнения кредитов.", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", "models.showMore": "Показать ещё {n}", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index da99115c81..0808817028 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -663,6 +663,8 @@ export const tr: Record = { "models.discoveryFailedProvider": "Sağlayıcı bir model keşfi hatası bildirdi.", "models.discoveryFailedGeneric": "Model keşfi başarısız oldu.", "models.openProviderSettings": "Sağlayıcı ayarlarını aç", + "models.inactiveNoCredit": "Kredi yok", + "models.inactiveNoCreditHint": "Bu girdinin arkasındaki tüm sağlayıcıların kredisi şu anda bitmiş durumda, bu yüzden istek başarısız olur. Girdi listede kalır ve kredi sıfırlandığında veya yüklendiğinde yeniden kullanılabilir.", "models.loading": "Yükleniyor…", "models.search": "Modellerde ara…", "models.showMore": "{n} tane daha göster", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 7f5f06a770..0c04867689 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -524,6 +524,8 @@ export const zhTW: Record = { "models.discoveryFailedProvider": "供應商報告了模型發現錯誤。", "models.discoveryFailedGeneric": "模型發現失敗。", "models.openProviderSettings": "開啟供應商設定", + "models.inactiveNoCredit": "無額度", + "models.inactiveNoCreditHint": "此項目背後的供應商目前都沒有額度,送出請求會失敗。項目仍會列出,額度重置或儲值後即可繼續使用。", "models.loading": "載入中…", "models.search": "搜尋模型…", "models.showMore": "再顯示 {n} 個", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 628f458030..0a0f6a6acf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -655,6 +655,8 @@ export const zh: Record = { "models.discoveryFailedProvider": "提供方报告了模型发现错误。", "models.discoveryFailedGeneric": "模型发现失败。", "models.openProviderSettings": "打开提供方设置", + "models.inactiveNoCredit": "无额度", + "models.inactiveNoCreditHint": "该条目背后的提供方当前都没有额度,请求会失败。条目仍会列出,额度重置或充值后即可继续使用。", "models.loading": "加载中…", "models.search": "搜索模型…", "models.showMore": "再显示 {n} 个", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index d14a81005d..ba6b938cab 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1675,7 +1675,15 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; >
void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> - {m.initialSelectionPending && {t("models.initialSelectionPending")}} + {m.initialSelectionPending && {t("models.initialSelectionPending")}} + {/* #1711: listed and selectable, but every usable target is out of credit. + Not a visibility change and not the operator's disable flag — the row is + still offered, which is what the issue asks for. */} + {m.quotaInactiveReason === "no_credit" && ( + + {t("models.inactiveNoCredit")} + + )} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} {m.native ? modelLabel(m.id) : m.namespaced} diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 19d9bb67f7..31f5ce453f 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -38,6 +38,12 @@ export interface ModelRow { displayNameOverride?: string; displayNameSource?: "operator" | "provider" | "fallback"; manualPricing?: boolean; + /** + * Listed but currently unable to serve, because every usable target is quota-exhausted + * (#1711). Distinct from `disabled`, which is the operator's own choice, and from visibility: + * the row is still offered. + */ + quotaInactiveReason?: "no_credit"; inputModalities?: string[]; contextWindow?: number; contextCap?: number; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 826d5a63b9..2f3e054d6a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -274,6 +274,7 @@ "catalog-retain-models.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", + "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-refusal.test.ts": "responses", diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0677da9a8a..f253894900 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -144,6 +144,17 @@ export interface CatalogModel { codexToolMode?: "code_mode_only" | "shell"; /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; + /** + * This row is listed but cannot currently serve a request (#1711). Today the only value is + * "no_credit", set when every usable target has positive quota-exhaustion evidence. + * + * It is NOT visibility. The row stays `visibility: "list"` on purpose: the issue explicitly + * rejects hiding, and Codex Desktop only understands "list" and "hide" anyway, so hiding would + * be the one outcome the reporter asked not to have. An OpenCodex-aware consumer greys the + * entry; the native picker ignores the field, which is the honest limit of what a custom + * catalog field can do. + */ + quotaInactiveReason?: "no_credit"; /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */ catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND | typeof CODEX_PROVIDER_MODEL_CATALOG_KIND; } diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 9b50636f8f..91c0c658eb 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -60,6 +60,7 @@ import { comboModelId, getCombo, listComboIds, + quotaInactiveReason, targetKey, } from "../../combos"; import type { NormalizedComboConfig } from "../../combos/types"; @@ -2630,7 +2631,16 @@ async function gatherRoutedModelsUncached( return { models: models.map(model => { const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`); - return displayName && !model.displayName ? { ...model, displayName } : model; + // #1711: one stamping point for every row this gather produces — routed, combo, and custom + // alike — because it is the only place that has both the finished list and the config the + // quota rules need. A combo votes over its own targets; anything else votes over the single + // provider that would serve it. + const targets = model.provider === COMBO_NAMESPACE + ? config.combos?.[model.id]?.targets ?? [] + : [{ provider: model.provider }]; + const inactive = quotaInactiveReason(config, targets); + const named = displayName && !model.displayName ? { ...model, displayName } : model; + return inactive ? { ...named, quotaInactiveReason: inactive } : named; }), comboOmissions: localOmissions, providerAuthOutcomes: localProviderAuthOutcomes, diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 972b6d74c6..5ae658d4b8 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -99,6 +99,12 @@ export const PICKER_ORDER_PRIORITY_BASE = 1_000; // independent of display order. It does not freeze native advertisements. Absent on unmoved rows. export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority"; +// OpenCodex-private catalog field: this row is listed but currently unable to serve (#1711). +// Codex ignores unknown catalog fields (same as opencodex_catalog_kind and the spawn priority +// above) and ensureStrictCatalogFields does not strip extras, so this is invisible to the native +// picker and cannot change what Codex offers. It never touches `visibility`. +export const CATALOG_INACTIVE_REASON_FIELD = "opencodex_inactive_reason"; + export type SpawnAgentSurface = "v1" | "v2"; export type SubagentRosterExclusionReason = @@ -383,6 +389,10 @@ export function deriveEntry( if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; + // Additive only. `visibility` is untouched: an inactive row must still be OFFERED, which is + // the whole point of #1711 — operator disable is what removes rows, and it stays a separate + // path from this one. + if (model?.quotaInactiveReason) e[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason; } else { applyNativeOpenAiContextOverride(e, contextCap); if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e); diff --git a/src/combos/index.ts b/src/combos/index.ts index 92f74f9304..eff3c7e654 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -27,9 +27,11 @@ export { noteComboSuccess, pickComboTarget, pickComboTargetWithWait, + quotaInactiveReason, tryPickComboModel, UnknownComboError, type ComboPick, + type QuotaInactiveReason, } from "./resolve"; export { clearComboTargetCooldowns, diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index bd88e82244..71ea750b6e 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -92,6 +92,58 @@ export function cachedProviderQuotaIsExhausted( return false; } +/** + * Why a catalog row is offered but cannot currently serve a request (#1711). + * + * Only one reason exists today. It is a string rather than a boolean so a later cause — a + * cooldown, a revoked key — can be told apart by a consumer that already reads the field. + */ +export type QuotaInactiveReason = "no_credit"; + +/** + * `"no_credit"` when every USABLE target of a catalog row has positive exhaustion evidence + * (#1711), otherwise undefined. + * + * This deliberately reuses the runtime rules in `targetProviderIsUsable` above rather than the + * Dashboard's `quotaStateFromReport`, which is harsher: it treats `remaining <= 0` as exhausted + * without requiring `percent >= 100` and ignores an elapsed `resetAt`. A catalog row marked + * inactive on the harsher rule would contradict the router, which would still happily send the + * request. + * + * Three rules carry the correctness, all inherited rather than restated: + * + * - A target the operator has removed or disabled is not usable and is not evidence either way; + * it drops out before the vote. If nothing is left, the row is unavailable for an operator + * reason rather than a quota one, so this returns undefined. + * - The canonical ChatGPT forward provider is exempt. Native account selection owns model-scoped + * quota, and a provider-level summary cannot veto it. + * - A stale cache is NOT exhaustion. `getCachedProviderQuota` returns null past its 30-minute + * window, and a null reading ends the vote rather than counting as evidence, so an unprobed + * provider is never marked inactive. + * + * "Every" is the bar on purpose: one target that can still serve makes the row serviceable, which + * is exactly what the combo loop concludes at request time. + */ +export function quotaInactiveReason( + config: OcxConfig, + targets: readonly { provider: string }[], + now = Date.now(), +): QuotaInactiveReason | undefined { + const usable = targets.filter(target => { + if (!Object.hasOwn(config.providers, target.provider)) return false; + const provider = config.providers[target.provider]; + return !!provider && provider.disabled !== true; + }); + if (usable.length === 0) return undefined; + for (const target of usable) { + const provider = config.providers[target.provider]!; + if (isCanonicalOpenAiForwardProvider(provider)) return undefined; + const quota = getCachedProviderQuota(target.provider, now); + if (!quota || !cachedProviderQuotaIsExhausted(quota, now)) return undefined; + } + return "no_credit"; +} + function smoothWeightedIndex( targets: Required[], state: SelectionState, diff --git a/tests/codex-integration/catalog-zero-credit-picker.test.ts b/tests/codex-integration/catalog-zero-credit-picker.test.ts new file mode 100644 index 0000000000..1f437a6717 --- /dev/null +++ b/tests/codex-integration/catalog-zero-credit-picker.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; +import { quotaInactiveReason } from "../../src/combos/resolve"; +import { CATALOG_INACTIVE_REASON_FIELD, deriveEntry } from "../../src/codex/catalog/sync"; +import type { OcxConfig } from "../../src/types"; +import type { ProviderQuota } from "../../src/providers/quota"; + +/** + * Regression coverage for #1711 — zero-credit models and combos were still offered as ordinary + * selectable catalog entries, with nothing to say a request would fail. + * + * The quota was already known: `ProviderQuota.creditsUsd.remaining` is written on probe and read + * back within 30 minutes, and the combo loop already refuses exhausted targets at request time. + * It simply never reached the catalog, whose only "inactive" mechanisms REMOVE the row — the live + * visibility filter, the disabled-routed-key merge drop, and native `visibility: "hide"`. Hiding + * is what the issue explicitly rejects, so this adds a field that means "listed but not + * currently serviceable" and leaves visibility alone. + * + * The predicate deliberately follows the RUNTIME rules, not the Dashboard's harsher + * `quotaStateFromReport`, which treats `remaining <= 0` as exhausted without requiring + * `percent >= 100` and ignores an elapsed `resetAt`. Marking a row inactive on the harsher rule + * would contradict the router, which would still send the request. + */ +const NOW = Date.now(); + +function exhausted(updatedAt = NOW): ProviderQuota { + return { updatedAt, creditsUsd: { remaining: 0, percent: 100, unlimited: false } } as ProviderQuota; +} + +function funded(updatedAt = NOW): ProviderQuota { + return { updatedAt, creditsUsd: { remaining: 12.5, percent: 40, unlimited: false } } as ProviderQuota; +} + +function config(extra: Partial = {}): OcxConfig { + return { + port: 10100, + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", apiKey: "sk-a" }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", apiKey: "sk-b" }, + }, + ...extra, + } as OcxConfig; +} + +afterEach(() => { clearCachedProviderQuotas(); }); + +describe("quota-inactive catalog rows (#1711)", () => { + test("a single provider out of credit marks the row no_credit", () => { + setCachedProviderQuotaForTests("alpha", exhausted()); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBe("no_credit"); + }); + + test("a refill clears the field", () => { + setCachedProviderQuotaForTests("alpha", funded()); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + + test("a combo needs EVERY usable target exhausted", () => { + const targets = [{ provider: "alpha" }, { provider: "beta" }]; + setCachedProviderQuotaForTests("alpha", exhausted()); + setCachedProviderQuotaForTests("beta", funded()); + // One target can still serve, so the combo is serviceable — the same conclusion the request + // path reaches when it hops past the exhausted target. + expect(quotaInactiveReason(config(), targets, NOW)).toBeUndefined(); + setCachedProviderQuotaForTests("beta", exhausted()); + expect(quotaInactiveReason(config(), targets, NOW)).toBe("no_credit"); + }); + + test("a stale cache entry is not exhaustion evidence", () => { + // getCachedProviderQuota returns null past 30 minutes. An unprobed provider must never be + // advertised as out of credit on a reading nobody refreshed. + setCachedProviderQuotaForTests("alpha", exhausted(NOW - 31 * 60_000)); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + + test("percent below 100 is not exhaustion, even at zero remaining", () => { + // This is exactly where the Dashboard predicate disagrees with the router. The catalog + // follows the router. + setCachedProviderQuotaForTests("alpha", { + updatedAt: NOW, + creditsUsd: { remaining: 0, percent: 40, unlimited: false }, + } as ProviderQuota); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + + test("an unlimited plan is never out of credit", () => { + setCachedProviderQuotaForTests("alpha", { + updatedAt: NOW, + creditsUsd: { remaining: 0, percent: 100, unlimited: true }, + } as ProviderQuota); + expect(quotaInactiveReason(config(), [{ provider: "alpha" }], NOW)).toBeUndefined(); + }); + + test("the canonical ChatGPT forward provider is exempt", () => { + // Native account selection owns model-scoped quota; a provider-level summary cannot veto it. + const forward = config({ + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + } as Partial); + setCachedProviderQuotaForTests("openai", exhausted()); + expect(quotaInactiveReason(forward, [{ provider: "openai" }], NOW)).toBeUndefined(); + }); + + test("an operator-disabled or unknown target is not evidence, and never the sole reason", () => { + const withDisabled = config({ + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", apiKey: "sk-a", disabled: true }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", apiKey: "sk-b" }, + }, + } as Partial); + // The disabled target drops out of the vote; the one usable target decides. + setCachedProviderQuotaForTests("beta", funded()); + expect(quotaInactiveReason(withDisabled, [{ provider: "alpha" }, { provider: "beta" }], NOW)).toBeUndefined(); + setCachedProviderQuotaForTests("beta", exhausted()); + expect(quotaInactiveReason(withDisabled, [{ provider: "alpha" }, { provider: "beta" }], NOW)).toBe("no_credit"); + // Nothing usable at all is an operator outcome, not a quota one. + expect(quotaInactiveReason(withDisabled, [{ provider: "alpha" }], NOW)).toBeUndefined(); + expect(quotaInactiveReason(config(), [{ provider: "absent" }], NOW)).toBeUndefined(); + expect(quotaInactiveReason(config(), [], NOW)).toBeUndefined(); + }); + + test("the served entry stays visibility list and carries the reason as an extension field", () => { + const entry = deriveEntry(null, "alpha/model-x", "desc", 5, { + id: "model-x", + provider: "alpha", + quotaInactiveReason: "no_credit", + }); + // The whole point of the issue: still offered, just marked. + expect(entry.visibility).toBe("list"); + expect(entry[CATALOG_INACTIVE_REASON_FIELD]).toBe("no_credit"); + }); + + test("a serviceable row carries no field at all", () => { + const entry = deriveEntry(null, "alpha/model-y", "desc", 5, { id: "model-y", provider: "alpha" }); + expect(entry.visibility).toBe("list"); + expect(Object.hasOwn(entry, CATALOG_INACTIVE_REASON_FIELD)).toBe(false); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b7c52659fe..ac6220d661 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -109,6 +109,7 @@ "catalog-retain-models.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", + "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-endpoint.test.ts": "responses", "chat-json-sse-fallback.test.ts": "responses", "chat-refusal.test.ts": "responses", From 593360f2837130d6b1fb7a0f5b611d67b4a6f58a Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 09:15:09 +0900 Subject: [PATCH 2/3] fix(catalog): stamp the inactive reason on the no-template derivation path too deriveEntry builds a served entry twice over: once by cloning a cached template and once from scratch when none is available. The inactive-reason stamp landed only on the templated path, so whether a quota-exhausted row carried the field depended on whether a template happened to be cached -- the same row would be marked or unmarked across rebuilds. The regression test caught it because it passed template: null, which is the fallback path. It now runs both paths explicitly rather than one, so a stamp added to one branch and not the other fails here instead of shipping. --- src/codex/catalog/sync.ts | 4 +++ .../catalog-zero-credit-picker.test.ts | 31 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 5ae658d4b8..087b659140 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -440,6 +440,10 @@ export function deriveEntry( if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(entry, model); if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind; + // Same additive stamp as the templated path above. A routed row that reaches the no-template + // fallback is still a served row, so omitting it here would make the field depend on whether a + // template happened to be cached — which is exactly what the regression test caught. + if (model?.quotaInactiveReason) entry[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason; if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap); return ensureStrictCatalogFields(normalizeServiceTiers(entry), { preserveExactInputModalities: preserveExact, diff --git a/tests/codex-integration/catalog-zero-credit-picker.test.ts b/tests/codex-integration/catalog-zero-credit-picker.test.ts index 1f437a6717..12704f4c54 100644 --- a/tests/codex-integration/catalog-zero-credit-picker.test.ts +++ b/tests/codex-integration/catalog-zero-credit-picker.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; import { quotaInactiveReason } from "../../src/combos/resolve"; import { CATALOG_INACTIVE_REASON_FIELD, deriveEntry } from "../../src/codex/catalog/sync"; +import type { RawEntry } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; import type { ProviderQuota } from "../../src/providers/quota"; @@ -121,19 +122,27 @@ describe("quota-inactive catalog rows (#1711)", () => { }); test("the served entry stays visibility list and carries the reason as an extension field", () => { - const entry = deriveEntry(null, "alpha/model-x", "desc", 5, { - id: "model-x", - provider: "alpha", - quotaInactiveReason: "no_credit", - }); - // The whole point of the issue: still offered, just marked. - expect(entry.visibility).toBe("list"); - expect(entry[CATALOG_INACTIVE_REASON_FIELD]).toBe("no_credit"); + // Both derivation paths, because deriveEntry builds the entry twice over: once by cloning a + // cached template and once from scratch when none is available. Covering only one of them is + // what let the fallback ship unstamped, so the field would have appeared or vanished + // depending on whether a template happened to be cached. + for (const template of [null, { slug: "gpt-5.6-sol", visibility: "list" } as RawEntry]) { + const entry = deriveEntry(template, "alpha/model-x", "desc", 5, { + id: "model-x", + provider: "alpha", + quotaInactiveReason: "no_credit", + }); + // The whole point of the issue: still offered, just marked. + expect(entry.visibility).toBe("list"); + expect(entry[CATALOG_INACTIVE_REASON_FIELD]).toBe("no_credit"); + } }); test("a serviceable row carries no field at all", () => { - const entry = deriveEntry(null, "alpha/model-y", "desc", 5, { id: "model-y", provider: "alpha" }); - expect(entry.visibility).toBe("list"); - expect(Object.hasOwn(entry, CATALOG_INACTIVE_REASON_FIELD)).toBe(false); + for (const template of [null, { slug: "gpt-5.6-sol", visibility: "list" } as RawEntry]) { + const entry = deriveEntry(template, "alpha/model-y", "desc", 5, { id: "model-y", provider: "alpha" }); + expect(entry.visibility).toBe("list"); + expect(Object.hasOwn(entry, CATALOG_INACTIVE_REASON_FIELD)).toBe(false); + } }); }); From 5c3e7e7ffaed2629b18f8517e990ee548d1f9131 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 09:33:01 +0900 Subject: [PATCH 3/3] fix(catalog): carry the inactive reason onto custom dashboard rows and pin the built entry Two gaps in the same field. listManagementModelRows REBUILDS custom rows from config.customModels instead of spreading the CatalogModel, and the slug dedup then drops the gather-derived row it replaces. So a custom model whose provider is out of credit was the one row on the page that could never show as inactive, while routed and combo rows beside it did. The reason gather already computed for that slug is now carried across. The regression test covered the predicate and deriveEntry, but nothing built a catalog. Every one of those cases would stay green if a refactor moved the stamp out of the entry builder. The new case seeds the routing cache, lets the predicate read it, and builds through buildCatalogEntries -- asserting the exhausted row is present, still visibility "list", and marked, while a funded provider's row beside it carries no field at all. --- src/server/management/model-rows.ts | 17 ++++++++++- .../catalog-zero-credit-picker.test.ts | 30 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 07405c4362..e406979468 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -147,10 +147,25 @@ export async function listManagementModelRows( }; }); const publicModels = uniqueCatalogModelsForPublicList(models); + // Custom rows below are REBUILT from config.customModels rather than spread from a + // CatalogModel, so every field gather computed for the same slug has to be carried across by + // hand. Without this a custom model whose provider is out of credit would be the one row on + // the page that never shows as inactive (#1711), because the gather-derived row it replaces + // is dropped by the slug dedup below. + const quotaInactiveByNamespaced = new Map( + publicModels + .filter(model => model.quotaInactiveReason !== undefined) + .map(model => [catalogModelSlug(model), model.quotaInactiveReason!] as const), + ); const comboNamespaced = new Set( publicModels.filter(model => model.provider === "combo").map(catalogModelSlug), ); - const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced)); + const visibleCustomModels = customModels + .filter(model => !comboNamespaced.has(model.namespaced)) + .map(model => { + const quotaInactiveReason = quotaInactiveByNamespaced.get(model.namespaced); + return quotaInactiveReason ? { ...model, quotaInactiveReason } : model; + }); // Custom metadata wins when a physical live/static row resolves to the same Codex-facing // slug, while a combo keeps the same precedence it has in routing and /v1/models. const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced)); diff --git a/tests/codex-integration/catalog-zero-credit-picker.test.ts b/tests/codex-integration/catalog-zero-credit-picker.test.ts index 12704f4c54..423ca834f6 100644 --- a/tests/codex-integration/catalog-zero-credit-picker.test.ts +++ b/tests/codex-integration/catalog-zero-credit-picker.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; import { quotaInactiveReason } from "../../src/combos/resolve"; -import { CATALOG_INACTIVE_REASON_FIELD, deriveEntry } from "../../src/codex/catalog/sync"; +import { buildCatalogEntries, CATALOG_INACTIVE_REASON_FIELD, deriveEntry } from "../../src/codex/catalog/sync"; import type { RawEntry } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; import type { ProviderQuota } from "../../src/providers/quota"; @@ -145,4 +145,32 @@ describe("quota-inactive catalog rows (#1711)", () => { expect(Object.hasOwn(entry, CATALOG_INACTIVE_REASON_FIELD)).toBe(false); } }); + + test("a seeded quota reaches the built catalog entry, still listed", () => { + // The whole path in one case: seed the routing cache, let the predicate read it, carry the + // result on the CatalogModel, and build the served entries. The unit cases above would all + // stay green if a refactor moved the stamp out of the entry builder, and this one would not. + setCachedProviderQuotaForTests("alpha", exhausted()); + setCachedProviderQuotaForTests("beta", funded()); + const models = [ + { id: "model-x", provider: "alpha" }, + { id: "model-y", provider: "beta" }, + ].map(model => { + const reason = quotaInactiveReason(config(), [{ provider: model.provider }], NOW); + return reason ? { ...model, quotaInactiveReason: reason } : model; + }); + + const entries = buildCatalogEntries(null, [], models); + const bySlug = new Map(entries.map(entry => [entry.slug as string, entry])); + const exhaustedRow = bySlug.get("alpha/model-x")!; + const fundedRow = bySlug.get("beta/model-y")!; + + // Present, listed, and marked — the row is still offered, which is the issue's requirement. + expect(exhaustedRow).toBeDefined(); + expect(exhaustedRow.visibility).toBe("list"); + expect(exhaustedRow[CATALOG_INACTIVE_REASON_FIELD]).toBe("no_credit"); + // The funded provider's row is untouched, so the marker is per-row rather than catalog-wide. + expect(fundedRow.visibility).toBe("list"); + expect(Object.hasOwn(fundedRow, CATALOG_INACTIVE_REASON_FIELD)).toBe(false); + }); });