diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fafb997744..68363152b5 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.discoveryFailedDependency": "Die Modellsuche ist für diesen Anbieter aktiv. Solange sie fehlschlägt, schalte „{control}“ in den Anbietereinstellungen aus, um stattdessen manuell hinzugefügte oder statische Modelle zu nutzen.", "models.loading": "Lädt…", "models.search": "Modelle suchen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b5f4771347..89fc5ec0d7 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.discoveryFailedDependency": "Model discovery is on for this provider. While it keeps failing, turn off “{control}” in provider settings to use manually added or static models instead.", "models.loading": "Loading…", "models.search": "Search models…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 86c2e848e9..4cf6818072 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.discoveryFailedDependency": "La découverte de modèles est activée pour ce fournisseur. Tant qu’elle échoue, désactivez « {control} » dans les paramètres du fournisseur pour utiliser à la place les modèles ajoutés manuellement ou statiques.", "models.loading": "Chargement…", "models.search": "Rechercher des modèles…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ea90d99909..c77edbe2c7 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.discoveryFailedDependency": "このプロバイダーではモデル検出が有効です。失敗が続く間は、プロバイダー設定で「{control}」をオフにすると、手動で追加したモデルや静的モデルを使えます。", "models.loading": "読み込み中…", "models.search": "モデルを検索…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 36a544b42a..34d5ceae87 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.discoveryFailedDependency": "이 프로바이더는 모델 검색이 켜져 있어요. 계속 실패한다면 프로바이더 설정에서 ‘{control}’을 꺼야 직접 추가한 모델이나 정적 모델을 쓸 수 있어요.", "models.loading": "불러오는 중…", "models.search": "모델 검색…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b452eb37a6..488f87d55b 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.discoveryFailedDependency": "Обнаружение моделей включено для этого провайдера. Пока оно не работает, отключите «{control}» в настройках провайдера, чтобы использовать вручную добавленные или статические модели.", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 77d6101a1c..2e46e2792f 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.discoveryFailedDependency": "Bu sağlayıcı için model keşfi açık. Başarısız olmaya devam ettiği sürece, elle eklenen veya statik modelleri kullanmak için sağlayıcı ayarlarından „{control}“ seçeneğini kapatın.", "models.loading": "Yükleniyor…", "models.search": "Modellerde ara…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b25bdc5767..b9a26da41c 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.discoveryFailedDependency": "此供應商已開啟模型探索。若持續失敗,可在供應商設定中關閉「{control}」,改用手動新增的模型或靜態模型。", "models.loading": "載入中…", "models.search": "搜尋模型…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7e08e17606..46866680b0 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.discoveryFailedDependency": "该提供方已开启模型发现。若持续失败,可在提供方设置中关闭“{control}”,改用手动添加的模型或静态模型。", "models.loading": "加载中…", "models.search": "搜索模型…", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 0a34364b43..0150cd29b0 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1724,7 +1724,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 7ff08ea996..8285c02d76 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"; /** * Provider-published cost class from model discovery (#3666). Absent means unknown — either * the provider publishes no per-token rates, or the row was cached by a build that predates diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c42d4c7782..b5ea45c4a3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -275,6 +275,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 19e0490512..8430622092 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"; /** * Discovered per-token cost class for this routed model (#3666). "free" means the provider's * own /models row reported a numeric zero for BOTH the prompt and the completion rate; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 25bdc0ce9c..cf4e4dddef 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"; @@ -2681,7 +2682,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..087b659140 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); @@ -430,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/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/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 new file mode 100644 index 0000000000..423ca834f6 --- /dev/null +++ b/tests/codex-integration/catalog-zero-credit-picker.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; +import { quotaInactiveReason } from "../../src/combos/resolve"; +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"; + +/** + * 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", () => { + // 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", () => { + 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); + } + }); + + 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); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d98a35aac4..d772205061 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -110,6 +110,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",