diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index b8f76fc52f..44992d7d1f 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -259,6 +259,11 @@ flux de travail des tâches. Ouvrez le tableau de bord local et choisissez **Modèles → Combos**. L'espace de travail crée, modifie, renomme et supprime combos, et son sélecteur de cible exclut les modèles désactivés et les combos imbriqués. +Chaque cible affiche aussi un badge de quota en direct : **Disponible**, **Quota épuisé** ou **Quota inconnu**. +Enregistrer et Créer ne sont désactivés que lorsque chaque cible activée dispose de preuves fraîches et complètes +que son quota est épuisé. Les données manquantes, obsolètes, mal formées ou agrégées de façon incomplète restent +inconnues et ne verrouillent jamais un contrôle. La récupération du quota réactive automatiquement l’action. + ### CLI Les commandes principales sont : diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 332dbc8c72..bc2365af29 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -268,6 +268,11 @@ task workflow. Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes combos, and its target picker excludes disabled models and nested combos. +Each target also shows a live quota badge: **Available**, **Out of quota**, or **Quota unknown**. Save and +Create are disabled only when every enabled target has fresh, complete evidence that its quota is exhausted. +Missing, stale, malformed, or incomplete aggregate evidence stays unknown and never locks a control. Polling +continues while the workspace is visible, so recovery automatically restores the action. + ### CLI The primary commands are: diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 6387c64392..e60e31b938 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -167,6 +167,10 @@ v1/base/v2 モードと完全な暗号化タスクのワークフローについ ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースはコンボを作成、編集、名前変更、削除し、そのターゲット ピッカーは無効なモデルとネストされたコンボを除外します。 +各ターゲットには **利用可能**、**クォータを使い切りました**、**クォータ不明** のライブバッジも表示されます。 +保存と作成が無効になるのは、有効な全ターゲットについて、クォータ枯渇を示す新鮮で完全な証拠がある場合だけです。 +欠落、古い、不正、または不完全な集約データは不明のままで、操作をロックしません。クォータが回復すると操作は自動で再び有効になります。 + ### CLI 主なコマンドは次のとおりです。 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 20402a5802..ebaddd0801 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -167,6 +167,10 @@ v1/base/v2 모드와 암호화된 작업의 전체 흐름은 [Sub-agent Surface] 로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스는 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있으며, 대상 선택기에서는 비활성 모델과 중첩 콤보를 제외합니다. +각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 저장과 만들기 버튼은 +활성화된 모든 대상에 할당량 소진을 입증하는 최신의 완전한 증거가 있을 때만 비활성화됩니다. 누락되거나 오래되거나 +형식이 잘못되었거나 집계가 불완전한 데이터는 알 수 없음으로 남으며 버튼을 잠그지 않습니다. 할당량이 복구되면 버튼도 자동으로 다시 활성화됩니다. + ### CLI 주요 명령은 다음과 같습니다. diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index d68f9c2b8e..0f8b0488aa 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -213,6 +213,11 @@ effort вызывающей стороне и цели. Откройте локальный дашборд и выберите **Models → Combos**. Рабочая область умеет создавать, редактировать, переименовывать и удалять combo, а селектор целей исключает отключённые модели и вложенные combo. +У каждой цели также отображается актуальный значок квоты: **Доступно**, **Квота исчерпана** или **Квота неизвестна**. +Кнопки сохранения и создания отключаются только тогда, когда для всех включённых целей есть свежие и полные +данные об исчерпании квоты. Отсутствующие, устаревшие, некорректные или неполные агрегированные данные остаются +неизвестными и никогда не блокируют управление. Восстановление квоты автоматически снова включает действие. + ### CLI Основные команды: diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 6662b88c10..b56bd28877 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -286,6 +286,11 @@ Yerel kontrol panelini açın ve **Modeller → Kombolar** seçeneğini belirley hedef seçicisi ise devre dışı bırakılmış modelleri ve iç içe geçmiş komboları hariç tutar. +Her hedef ayrıca canlı bir kota rozeti gösterir: **Kullanılabilir**, **Kota tükendi** veya **Kota bilinmiyor**. +Kaydet ve Oluştur yalnızca etkin hedeflerin tamamı için kotanın tükendiğini gösteren güncel ve eksiksiz kanıt varsa +devre dışı bırakılır. Eksik, eski, bozuk veya tamamlanmamış toplu kanıt bilinmiyor olarak kalır ve denetimleri asla +kilitlemez. Kota yenilendiğinde işlem otomatik olarak yeniden etkinleşir. + ### CLI Birincil komutlar şunlardır: @@ -381,4 +386,3 @@ Hata hedefe özgü olmaktan ziyade uç (terminal) bir hataydı. Geçersiz girdiy düzeltin, aşırı büyük bir bağlamı azaltın, bir politika reddini işleyin veya reddedilen istek kaynağını düzeltin. Kombolar bu durumlar için atlama yapmaz. - diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 54180df9b4..854270fe24 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -197,6 +197,10 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo,其目标选择器会排除已禁用的模型和嵌套 combo。 +每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当所有已启用目标都有最新、 +完整的额度耗尽证据时,保存和创建操作才会被禁用。缺失、过期、格式错误或聚合不完整的证据会保持为未知, +绝不会锁定控件。额度恢复后,操作会自动重新启用。 + ### CLI 主要命令如下: diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index ac0efda056..155baeb06b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -205,6 +205,10 @@ Codex v2 子代理有一個重要限制([issue #92](https://github.com/lidge-j 開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo,且其目標 picker 會排除已停用的模型與巢狀 combo。 +每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當所有已啟用目標都有最新、 +完整的額度耗盡證據時,儲存與建立操作才會停用。缺失、過期、格式錯誤或聚合不完整的證據會維持未知, +絕不會鎖住控制項。額度恢復後,操作會自動重新啟用。 + ### CLI 主要指令為: diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index 4f5e96e0b7..39a70f4281 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -49,6 +49,12 @@ export interface ComboTarget { clientKey?: string; } +export type ComboQuotaState = "available" | "exhausted" | "unknown"; +export type ProviderQuotaStates = Readonly>; + +/** Matches the management endpoint's bounded last-good quota lifetime. */ +export const COMBO_QUOTA_MAX_AGE_MS = 30 * 60_000; + let comboTargetKeySeq = 0; export function newComboTarget(partial: Partial = {}): ComboTarget { @@ -90,7 +96,7 @@ export interface ComboSections { export interface ComboAttentionItem { id: string; model: string; - reason: "few-targets" | "empty-targets" | "catalog-omitted"; + reason: "few-targets" | "empty-targets" | "catalog-omitted" | "all-targets-exhausted"; } export const COMBO_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/; @@ -212,9 +218,197 @@ export function filterCombos(items: ComboItem[], query: string): ComboItem[] { }); } +function recordFromUnknown(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function quotaTimestampIsFresh(value: unknown, now: number): boolean { + const timestamp = finiteNumber(value); + return timestamp !== null && now - timestamp < COMBO_QUOTA_MAX_AGE_MS; +} + +function nonNegativeInteger(value: unknown): number | null { + const number = finiteNumber(value); + return number !== null && Number.isInteger(number) && number >= 0 ? number : null; +} + +function aggregateWindowIsComplete(value: unknown, now: number): boolean { + const window = recordFromUnknown(value); + const usedPercent = finiteNumber(window?.usedPercent); + return !!window + && usedPercent !== null + && usedPercent >= 0 + && nonNegativeInteger(window.includedAccounts) !== null + && (nonNegativeInteger(window.includedAccounts) ?? 0) > 0 + && nonNegativeInteger(window.excludedAccounts) === 0 + && window.incomplete === false + && quotaTimestampIsFresh(window.updatedAt, now); +} + +function aggregateEvidenceIsComplete(value: unknown, now: number): boolean { + const aggregation = recordFromUnknown(value); + if ( + !aggregation + || aggregation.kind !== "capacity-weighted-v1" + || aggregation.scope !== "routable-known" + || aggregation.presentation !== "aggregate" + || aggregation.incomplete !== false + ) return false; + + for (const key of [ + "includedAccounts", + "excludedAccounts", + "unknownPlanAccounts", + "missingQuotaAccounts", + "pausedAccounts", + "reauthAccounts", + "staleQuotaAccounts", + "partialWindowAccounts", + ] as const) { + if (nonNegativeInteger(aggregation[key]) === null) return false; + } + if ((nonNegativeInteger(aggregation.includedAccounts) ?? 0) === 0) return false; + for (const key of [ + "excludedAccounts", + "unknownPlanAccounts", + "missingQuotaAccounts", + "pausedAccounts", + "reauthAccounts", + "staleQuotaAccounts", + "partialWindowAccounts", + ] as const) { + if (aggregation[key] !== 0) return false; + } + + let hasWindow = false; + for (const key of ["fiveHour", "weekly", "monthly"] as const) { + if (!Object.hasOwn(aggregation, key)) continue; + if (!aggregateWindowIsComplete(aggregation[key], now)) return false; + hasWindow = true; + } + if (Object.hasOwn(aggregation, "customWindows")) { + if (!Array.isArray(aggregation.customWindows)) return false; + for (const value of aggregation.customWindows) { + const custom = recordFromUnknown(value); + if (!custom || typeof custom.label !== "string" || !custom.label.trim()) return false; + if (!aggregateWindowIsComplete(custom, now)) return false; + hasWindow = true; + } + } + return hasWindow; +} + +function quotaStateFromReport(raw: Record, now: number): ComboQuotaState { + if (!quotaTimestampIsFresh(raw.updatedAt, now)) return "unknown"; + const quota = recordFromUnknown(raw.quota); + if (!quota || !quotaTimestampIsFresh(quota.updatedAt, now)) return "unknown"; + if (raw.aggregation !== undefined && !aggregateEvidenceIsComplete(raw.aggregation, now)) return "unknown"; + + let hasEvidence = false; + let exhausted = false; + for (const key of ["fiveHourPercent", "weeklyPercent", "monthlyPercent"] as const) { + if (!Object.hasOwn(quota, key)) continue; + const percent = finiteNumber(quota[key]); + if (percent === null || percent < 0) return "unknown"; + hasEvidence = true; + if (percent >= 100) exhausted = true; + } + for (const key of ["fiveHourResetAt", "weeklyResetAt", "monthlyResetAt"] as const) { + if (Object.hasOwn(quota, key) && finiteNumber(quota[key]) === null) return "unknown"; + } + + if (Object.hasOwn(quota, "customWindows")) { + if (!Array.isArray(quota.customWindows)) return "unknown"; + for (const value of quota.customWindows) { + const window = recordFromUnknown(value); + const percent = finiteNumber(window?.percent); + if (!window || typeof window.label !== "string" || !window.label.trim() || percent === null || percent < 0) { + return "unknown"; + } + if (Object.hasOwn(window, "resetAt") && finiteNumber(window.resetAt) === null) return "unknown"; + hasEvidence = true; + if (percent >= 100) exhausted = true; + } + } + + if (Object.hasOwn(quota, "creditsUsd")) { + const credits = recordFromUnknown(quota.creditsUsd); + if (!credits) return "unknown"; + const used = finiteNumber(credits.used); + const limit = finiteNumber(credits.limit); + const remaining = finiteNumber(credits.remaining); + const percent = finiteNumber(credits.percent); + if (used === null || used < 0 || limit === null || limit < 0 || remaining === null || percent === null || percent < 0) { + return "unknown"; + } + if (credits.unlimited !== undefined && typeof credits.unlimited !== "boolean") return "unknown"; + if (Object.hasOwn(credits, "expiresAt") && finiteNumber(credits.expiresAt) === null) return "unknown"; + hasEvidence = true; + if (credits.unlimited !== true && remaining <= 0) exhausted = true; + } + + if (!hasEvidence) return "unknown"; + return exhausted ? "exhausted" : "available"; +} + +/** Fail-unknown parser for the live `/api/provider-quotas` report array. */ +export function providerQuotaStatesFromReports( + reports: unknown, + now = Date.now(), +): Record { + if (!Array.isArray(reports)) return {}; + const states: Record = {}; + for (const value of reports) { + const report = recordFromUnknown(value); + const provider = typeof report?.provider === "string" ? report.provider.trim() : ""; + if (!report || !provider) continue; + const next = quotaStateFromReport(report, now); + states[provider] = Object.hasOwn(states, provider) && states[provider] !== next + ? "unknown" + : next; + } + return states; +} + +/** + * A combo is exhausted only when it has at least one configured, enabled, + * complete target and every such target has known exhausted quota evidence. + */ +export function comboQuotaState( + targets: readonly ComboTarget[], + providerQuotaStates: ProviderQuotaStates, + providers: Readonly>, +): ComboQuotaState { + const usableProviders = targets.flatMap((target) => { + const provider = target.provider.trim(); + if (!provider || !target.model.trim()) return []; + if (!Object.hasOwn(providers, provider) || providers[provider]?.disabled === true) return []; + return [provider]; + }); + if (usableProviders.length === 0) return "unknown"; + + let sawUnknown = false; + for (const provider of usableProviders) { + const state = providerQuotaStates[provider] ?? "unknown"; + if (state === "available") return "available"; + if (state === "unknown") sawUnknown = true; + } + return sawUnknown ? "unknown" : "exhausted"; +} + export function buildComboAttention( items: ComboItem[], - options: { cataloguedComboIds?: ReadonlySet } = {}, + options: { + cataloguedComboIds?: ReadonlySet; + providerQuotaStates?: ProviderQuotaStates; + providers?: Readonly>; + } = {}, ): ComboAttentionItem[] { const out: ComboAttentionItem[] = []; const catalogued = options.cataloguedComboIds; @@ -230,6 +424,13 @@ export function buildComboAttention( if (catalogued && item.targets.length > 0 && !catalogued.has(item.id)) { out.push({ id: item.id, model: item.model, reason: "catalog-omitted" }); } + if ( + options.providerQuotaStates + && options.providers + && comboQuotaState(item.targets, options.providerQuotaStates, options.providers) === "exhausted" + ) { + out.push({ id: item.id, model: item.model, reason: "all-targets-exhausted" }); + } } return out; } diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index 66cd751f64..28b31d67cd 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -18,6 +18,7 @@ export type { ModelOption, ProviderOption, ComboWorkspaceProps } from "./combo-w export default function ComboWorkspace({ combos, + providerQuotaStates, providers, models, cataloguedComboIds, @@ -172,6 +173,7 @@ export default function ComboWorkspace({ otherIds={otherComboIds} otherAliases={otherComboAliases} providerMap={providerMap} + providerQuotaStates={providerQuotaStates} providers={providers} models={models} onBack={() => trySelect(null)} @@ -199,6 +201,7 @@ export default function ComboWorkspace({ otherIds={[]} otherAliases={[]} providerMap={providerMap} + providerQuotaStates={providerQuotaStates} providers={providers} models={models} onSaved={(item) => { @@ -214,6 +217,8 @@ export default function ComboWorkspace({ trySelect(id)} onAdd={onAdd} /> @@ -225,6 +230,7 @@ export default function ComboWorkspace({ existingIds={combos.map((c) => c.id)} existingAliases={existingComboAliases} providerMap={providerMap} + providerQuotaStates={providerQuotaStates} providers={providers} models={models} onClose={onCloseAdd} diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index 4d334a759f..32cfe8b619 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ComboItem, + type ProviderQuotaStates, + comboQuotaState, comboPublicModelId, emptyDraft, intersectComboEfforts, @@ -17,6 +19,7 @@ export function AddComboModal({ existingIds, existingAliases, providerMap, + providerQuotaStates, providers, models, onClose, @@ -25,6 +28,7 @@ export function AddComboModal({ existingIds: string[]; existingAliases: string[]; providerMap: Readonly>; + providerQuotaStates: ProviderQuotaStates; providers: ProviderOption[]; models: ModelOption[]; onClose: () => void; @@ -46,6 +50,7 @@ export function AddComboModal({ () => intersectComboEfforts(draft.targets, effortMap), [draft.targets, effortMap], ); + const allTargetsExhausted = comboQuotaState(draft.targets, providerQuotaStates, providerMap) === "exhausted"; useEffect(() => { const dialog = dialogRef.current; @@ -104,6 +109,11 @@ export function AddComboModal({

{t("cws.addSubtitle")}

{error && {error}} + {allTargetsExhausted && ( +
+ {t("cws.quota.allExhausted")} +
+ )}
@@ -198,6 +208,7 @@ export function AddComboModal({ strategy={draft.strategy} providers={providers} models={models} + providerQuotaStates={providerQuotaStates} onChange={(targets) => setDraft((d) => ({ ...d, targets }))} />

@@ -214,7 +225,7 @@ export function AddComboModal({

-
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index 0a7ecaa65d..5ee586ad42 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data"; +import type { ComboEffort, ComboStrategy, ComboTarget, ProviderQuotaStates } from "../combo-workspace-data"; import { comboImagesSupported } from "../combo-capabilities"; import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data"; import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons"; @@ -133,12 +133,14 @@ export function TargetEditor({ strategy, providers, models, + providerQuotaStates, onChange, }: { targets: ComboTarget[]; strategy: ComboStrategy; providers: ProviderOption[]; models: ModelOption[]; + providerQuotaStates: ProviderQuotaStates; onChange: (next: ComboTarget[]) => void; }) { const t = useT(); @@ -172,6 +174,7 @@ export function TargetEditor({ const modelSelectDisabled = !row.provider; const dragging = dragIndex === index; const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index; + const quotaState = providerQuotaStates[row.provider.trim()] ?? "unknown"; return (
)} + + {t(`cws.quota.${quotaState}`)} +
)} -
{msg && {msg.text}} + {allTargetsExhausted && ( +
+ {t("cws.quota.allExhausted")} +
+ )} {/* Pills, not an underline row. Combos is a tab of the Models page now, so an @@ -349,6 +359,7 @@ export function DetailPanel({ strategy={draft.strategy} providers={providers} models={models} + providerQuotaStates={providerQuotaStates} onChange={(targets) => updateDraft((d) => ({ ...d, targets }))} />

diff --git a/gui/src/components/combo-workspace-overview-panel.tsx b/gui/src/components/combo-workspace-overview-panel.tsx index bc67d2bd96..1dcac6686b 100644 --- a/gui/src/components/combo-workspace-overview-panel.tsx +++ b/gui/src/components/combo-workspace-overview-panel.tsx @@ -1,31 +1,40 @@ -import type { ComboItem } from "../combo-workspace-data"; +import type { ComboItem, ProviderQuotaStates } from "../combo-workspace-data"; import { buildComboAttention, groupCombos } from "../combo-workspace-data"; import { IconAlert, IconChevron, IconPlus } from "../icons"; import { useT, type TFn } from "../i18n/shared"; function attentionCopy( - reason: "empty-targets" | "few-targets" | "catalog-omitted", + reason: "empty-targets" | "few-targets" | "catalog-omitted" | "all-targets-exhausted", t: TFn, ): string { if (reason === "empty-targets") return t("cws.attention.empty"); if (reason === "catalog-omitted") return t("cws.attention.catalogOmitted"); + if (reason === "all-targets-exhausted") return t("cws.attention.allTargetsExhausted"); return t("cws.attention.few"); } export function OverviewPanel({ combos, cataloguedComboIds, + providerMap, + providerQuotaStates, onSelect, onAdd, }: { combos: ComboItem[]; cataloguedComboIds?: ReadonlySet; + providerMap: Readonly>; + providerQuotaStates: ProviderQuotaStates; onSelect: (id: string) => void; onAdd: () => void; }) { const t = useT(); const sections = groupCombos(combos); - const attention = buildComboAttention(combos, { cataloguedComboIds }); + const attention = buildComboAttention(combos, { + cataloguedComboIds, + providers: providerMap, + providerQuotaStates, + }); return (

diff --git a/gui/src/components/combo-workspace-types.ts b/gui/src/components/combo-workspace-types.ts index c4dc1eac6c..44325225e9 100644 --- a/gui/src/components/combo-workspace-types.ts +++ b/gui/src/components/combo-workspace-types.ts @@ -1,4 +1,4 @@ -import type { ComboItem } from "../combo-workspace-data"; +import type { ComboItem, ProviderQuotaStates } from "../combo-workspace-data"; export type ProviderOption = { name: string; @@ -18,6 +18,7 @@ export type ModelOption = { export interface ComboWorkspaceProps { combos: ComboItem[]; + providerQuotaStates: ProviderQuotaStates; providers: ProviderOption[]; models: ModelOption[]; /** Combo ids currently present in the live catalog (`provider === "combo"`). */ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 41ae5d02e5..12fa962020 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1863,6 +1863,7 @@ export const de: Record = { "cws.attention.empty": "Keine Ziele konfiguriert", "cws.attention.few": "Nur ein Ziel — Failover hat kein Ersatzziel", "cws.attention.catalogOmitted": "Fehlt im Modellkatalog — Mitgliederfähigkeiten sind unvollständig oder inkompatibel (fehlendes Context-Window/Metadaten oder leere Modalitäts-Schnittmenge). Routing per Alias funktioniert weiterhin", + "cws.attention.allTargetsExhausted": "Alle aktivierten Ziele haben ihr Kontingent ausgeschöpft", "cws.emptyTitle": "Erste Combo erstellen", "cws.empty.createDesc": "Virtuelles Modell benennen und zwei oder mehr Backends verketten.", "cws.backToAll": "Zurück zu allen Combos", @@ -1914,6 +1915,10 @@ export const de: Record = { "cws.target.drag": "Ziehen zum Umsortieren", "cws.target.moveUp": "Nach oben", "cws.target.moveDown": "Nach unten", + "cws.quota.available": "Verfügbar", + "cws.quota.exhausted": "Kontingent erschöpft", + "cws.quota.unknown": "Kontingent unbekannt", + "cws.quota.allExhausted": "Alle aktivierten Ziele haben ihr Kontingent ausgeschöpft. Wählen Sie ein anderes Ziel oder warten Sie auf die Erholung.", "cws.aboutTitle": "Laufzeit", "cws.aboutBody": "Fehlgeschlagene Ziele kühlen kurz ab; Retry-After wird beachtet. Ungültige oder Kontextfehler springen nicht. Jedes Ziel passt den Aufwand an seine Fähigkeiten an; erschöpfte Combos schlagen geschlossen fehl. Protokolle und Nutzung behalten geordnete physische Versuche samt Nutzung je Versuch.", "cws.removeConfirmTitle": "{model} entfernen?", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 01d69f0fd1..d0acc18dde 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1903,6 +1903,7 @@ export const en = { "cws.attention.empty": "No targets configured", "cws.attention.few": "Only one target — failover has nowhere to hop", "cws.attention.catalogOmitted": "Missing from the model catalog — member capabilities are incomplete or incompatible (missing context window / metadata, or empty modality intersection). Routing by alias still works", + "cws.attention.allTargetsExhausted": "All enabled targets are out of quota", "cws.emptyTitle": "Create your first combo", "cws.empty.createDesc": "Name a virtual model and chain two or more backends.", "cws.backToAll": "Back to all combos", @@ -1954,6 +1955,10 @@ export const en = { "cws.target.drag": "Drag to reorder", "cws.target.moveUp": "Move up", "cws.target.moveDown": "Move down", + "cws.quota.available": "Available", + "cws.quota.exhausted": "Out of quota", + "cws.quota.unknown": "Quota unknown", + "cws.quota.allExhausted": "All enabled targets are out of quota. Choose another target or wait for quota recovery.", "cws.aboutTitle": "Runtime", "cws.aboutBody": "Failed targets cool down briefly, honoring Retry-After. Invalid or context errors do not hop. Each target adapts effort to its own capabilities; exhausted combos fail closed. Logs and Usage retain ordered physical attempts and per-attempt usage.", "cws.removeConfirmTitle": "Remove {model}?", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3765f4d95f..983ac2a23b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1864,6 +1864,7 @@ export const fr: Record = { "cws.attention.empty": "Aucune cible configurée", "cws.attention.few": "Une seule cible — le basculement n’a aucune autre destination", "cws.attention.catalogOmitted": "Absent du catalogue de modèles — les capacités des membres sont incomplètes ou incompatibles (fenêtre de contexte ou métadonnées manquantes, ou intersection des modalités vide). Le routage par alias fonctionne toujours", + "cws.attention.allTargetsExhausted": "Toutes les cibles activées ont épuisé leur quota", "cws.emptyTitle": "Créer votre première combinaison", "cws.empty.createDesc": "Nommez un modèle virtuel et enchaînez au moins deux services principaux.", "cws.backToAll": "Retour à toutes les combinaisons", @@ -1915,6 +1916,10 @@ export const fr: Record = { "cws.target.drag": "Faire glisser pour réorganiser", "cws.target.moveUp": "Monter", "cws.target.moveDown": "Descendre", + "cws.quota.available": "Disponible", + "cws.quota.exhausted": "Quota épuisé", + "cws.quota.unknown": "Quota inconnu", + "cws.quota.allExhausted": "Toutes les cibles activées ont épuisé leur quota. Choisissez une autre cible ou attendez le rétablissement du quota.", "cws.aboutTitle": "Exécution", "cws.aboutBody": "Les cibles en échec sont temporairement mises en attente conformément à Retry-After. Les erreurs de contexte ou de validité ne provoquent aucun basculement. Chaque cible adapte l’effort à ses propres capacités ; les combinaisons épuisées bloquent les requêtes. Les journaux et la section Utilisation conservent les tentatives physiques ordonnées et l’utilisation de chaque tentative.", "cws.removeConfirmTitle": "Supprimer {model}?", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 63e20efd3b..05215462be 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1921,6 +1921,7 @@ export const ja: Record = { "cws.attention.empty": "ターゲットが設定されていません", "cws.attention.few": "ターゲットが 1 つだけ — フェイルオーバーのホップ先がありません", "cws.attention.catalogOmitted": "モデルカタログにありません — メンバー能力が不完全または非互換です(context window / メタデータ不足、または modality 交差が空)。エイリアス指定のルーティングは動作します", + "cws.attention.allTargetsExhausted": "有効なすべてのターゲットがクォータを使い切っています", "cws.emptyTitle": "最初のコンボを作成", "cws.empty.createDesc": "仮想モデルに名前を付け、2 つ以上のバックエンドをつなぎます。", "cws.backToAll": "すべてのコンボに戻る", @@ -1973,6 +1974,10 @@ export const ja: Record = { "cws.target.drag": "ドラッグで並べ替え", "cws.target.moveUp": "上へ移動", "cws.target.moveDown": "下へ移動", + "cws.quota.available": "利用可能", + "cws.quota.exhausted": "クォータを使い切りました", + "cws.quota.unknown": "クォータ不明", + "cws.quota.allExhausted": "有効なすべてのターゲットがクォータを使い切っています。別のターゲットを選ぶか、クォータの回復を待ってください。", "cws.aboutTitle": "ランタイム", "cws.aboutBody": "失敗したターゲットは Retry-After を尊重して短時間クールダウンします。無効またはコンテキストエラーはホップしません。各ターゲットは自身の能力に推論負荷を適応させます; 枯渇したコンボはフェイルクローズします。ログと使用量は順序付きの物理試行と試行ごとの使用量を保持します。", "cws.removeConfirmTitle": "{model} を削除しますか?", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b13cd141ff..f3cd6fe9ec 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1890,6 +1890,7 @@ export const ko: Record = { "cws.attention.empty": "구성된 대상 없음", "cws.attention.few": "대상이 하나뿐 — 장애 조치할 곳이 없음", "cws.attention.catalogOmitted": "모델 카탈로그에 없음 — 멤버 능력이 불완전하거나 호환되지 않음(context window/메타데이터 부족 또는 modality 교집합이 비어 있음). 별칭 라우팅은 계속 동작", + "cws.attention.allTargetsExhausted": "활성화된 모든 대상의 할당량이 소진됨", "cws.emptyTitle": "첫 콤보 만들기", "cws.empty.createDesc": "가상 모델 이름을 정하고 백엔드를 둘 이상 연결하세요.", "cws.backToAll": "모든 콤보로", @@ -1941,6 +1942,10 @@ export const ko: Record = { "cws.target.drag": "드래그하여 순서 변경", "cws.target.moveUp": "위로", "cws.target.moveDown": "아래로", + "cws.quota.available": "사용 가능", + "cws.quota.exhausted": "할당량 소진", + "cws.quota.unknown": "할당량 알 수 없음", + "cws.quota.allExhausted": "활성화된 모든 대상의 할당량이 소진되었습니다. 다른 대상을 선택하거나 할당량이 복구될 때까지 기다리세요.", "cws.aboutTitle": "런타임", "cws.aboutBody": "실패한 대상은 Retry-After를 반영해 잠시 쿨다운됩니다. 잘못된 요청과 컨텍스트 오류는 다음 대상으로 넘기지 않습니다. 각 대상은 자체 기능에 맞게 추론 수준을 조정하며, 모든 대상 소진 시 우회 없이 실패합니다. 로그와 사용량에는 순서가 있는 실제 시도와 시도별 사용량이 남습니다.", "cws.removeConfirmTitle": "{model}을(를) 삭제할까요?", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 05cf793779..2cf73ba065 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1973,6 +1973,7 @@ export const ru: Record = { "cws.attention.empty": "Цели не настроены", "cws.attention.few": "Только одна цель — failover некуда переключаться", "cws.attention.catalogOmitted": "Отсутствует в каталоге моделей — возможности участников неполны или несовместимы (нет context window / метаданных, или пустое пересечение modalities). Маршрутизация по alias всё ещё работает", + "cws.attention.allTargetsExhausted": "Квота исчерпана у всех включённых целей", "cws.emptyTitle": "Создайте первое комбо", "cws.empty.createDesc": "Задайте имя виртуальной модели и объедините в цепочку два и более бэкенда.", "cws.backToAll": "Назад ко всем комбо", @@ -2024,6 +2025,10 @@ export const ru: Record = { "cws.target.drag": "Перетащите, чтобы изменить порядок", "cws.target.moveUp": "Переместить вверх", "cws.target.moveDown": "Переместить вниз", + "cws.quota.available": "Доступно", + "cws.quota.exhausted": "Квота исчерпана", + "cws.quota.unknown": "Квота неизвестна", + "cws.quota.allExhausted": "Квота исчерпана у всех включённых целей. Выберите другую цель или дождитесь восстановления квоты.", "cws.aboutTitle": "Поведение во время работы", "cws.aboutBody": "После сбоя цель на короткое время выводится из ротации с учётом заголовка Retry-After. При ошибках валидации или превышения контекста переключение не выполняется. Каждая цель адаптирует уровень рассуждений к своим возможностям; полностью исчерпанное комбо завершает запрос ошибкой. Разделы «Логи» и «Использование» сохраняют упорядоченные физические попытки и расход по каждой попытке.", "cws.removeConfirmTitle": "Удалить {model}?", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c98421183e..4b84ddeaf5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1893,6 +1893,7 @@ export const tr: Record = { "cws.attention.empty": "Yapılandırılmış hedef yok", "cws.attention.few": "Yalnızca bir hedef — geçiş yapılacak yer yok", "cws.attention.catalogOmitted": "Model kataloğunda eksik — üye yetenekleri eksik veya uyumsuz (eksik bağlam penceresi / meta veri veya boş modalite kesişimi). Takma ada göre yönlendirme hâlâ çalışır", + "cws.attention.allTargetsExhausted": "Etkin hedeflerin tüm kotaları tükendi", "cws.emptyTitle": "İlk kombonuzu oluşturun", "cws.empty.createDesc": "Sanal bir model adlandırın ve iki veya daha fazla arka ucu bağlayın.", "cws.backToAll": "Tüm kombolara dön", @@ -1944,6 +1945,10 @@ export const tr: Record = { "cws.target.drag": "Yeniden sıralamak için sürükleyin", "cws.target.moveUp": "Yukarı taşı", "cws.target.moveDown": "Aşağı taşı", + "cws.quota.available": "Kullanılabilir", + "cws.quota.exhausted": "Kota tükendi", + "cws.quota.unknown": "Kota bilinmiyor", + "cws.quota.allExhausted": "Etkin hedeflerin tüm kotaları tükendi. Başka bir hedef seçin veya kotanın yenilenmesini bekleyin.", "cws.aboutTitle": "Çalışma zamanı", "cws.aboutBody": "Başarısız hedefler Retry-After süresine uyarak kısa süreliğine soğumaya alınır. Geçersiz istek veya bağlam (context) hataları diğer hedefe atlamaz. Her hedef çabayı kendi yeteneklerine uyarlar; tükenen kombolar kapalı olarak başarısız olur (fail closed). Günlükler ve Kullanım bölümü sıralı fiziksel denemeleri ve deneme başına kullanımı saklar.", "cws.removeConfirmTitle": "{model} kaldırılsın mı?", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index d35a96bd34..f4d226c4dd 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1422,6 +1422,7 @@ export const zhTW: Record = { "cws.attention.empty": "未配置目標", "cws.attention.few": "只有一個目標 — 容錯移轉無處可跳", "cws.attention.catalogOmitted": "未出現在模型目錄中 — 成員能力不完整或不相容(缺少上下文視窗/中繼資料,或模態交集為空)。依別名路由仍可用", + "cws.attention.allTargetsExhausted": "所有已啟用目標的額度均已用盡", "cws.emptyTitle": "建立第一個組合", "cws.empty.createDesc": "命名虛擬模型並串聯兩個或多個後端。", "cws.backToAll": "返回全部組合", @@ -1468,6 +1469,10 @@ export const zhTW: Record = { "cws.target.drag": "拖動以重新排序", "cws.target.moveUp": "上移", "cws.target.moveDown": "下移", + "cws.quota.available": "可用", + "cws.quota.exhausted": "額度已用盡", + "cws.quota.unknown": "額度未知", + "cws.quota.allExhausted": "所有已啟用目標的額度均已用盡。請選擇其他目標,或等待額度恢復。", "cws.aboutTitle": "執行時", "cws.aboutBody": "失敗目標會短暫冷卻並遵循 Retry-After。無效請求與上下文錯誤不會切換。每個目標按自身能力調整推理級別;所有目標耗盡時直接失敗。日誌與用量會保留有序的實際嘗試及每次嘗試的用量。", "cws.removeConfirmTitle": "刪除 {model}?", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index dc749ac530..02c774fc16 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1883,6 +1883,7 @@ export const zh: Record = { "cws.attention.empty": "未配置目标", "cws.attention.few": "只有一个目标 — 故障转移无处可跳", "cws.attention.catalogOmitted": "未出现在模型目录中 — 成员能力不完整或不兼容(缺少上下文窗口/元数据,或模态交集为空)。按别名路由仍可用", + "cws.attention.allTargetsExhausted": "所有已启用目标的额度均已用尽", "cws.emptyTitle": "创建第一个组合", "cws.empty.createDesc": "命名虚拟模型并串联两个或多个后端。", "cws.backToAll": "返回全部组合", @@ -1934,6 +1935,10 @@ export const zh: Record = { "cws.target.drag": "拖动以重新排序", "cws.target.moveUp": "上移", "cws.target.moveDown": "下移", + "cws.quota.available": "可用", + "cws.quota.exhausted": "额度已用尽", + "cws.quota.unknown": "额度未知", + "cws.quota.allExhausted": "所有已启用目标的额度均已用尽。请选择其他目标,或等待额度恢复。", "cws.aboutTitle": "运行时", "cws.aboutBody": "失败目标会短暂冷却并遵循 Retry-After。无效请求与上下文错误不会切换。每个目标按自身能力调整推理级别;所有目标耗尽时直接失败。日志与用量会保留有序的实际尝试及每次尝试的用量。", "cws.removeConfirmTitle": "删除 {model}?", diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index da924893ca..ca05ca8fd5 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -4,6 +4,7 @@ import { type ComboItem, comboModelId, parseComboList, + providerQuotaStatesFromReports, toPutBody, } from "../combo-workspace-data"; import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; @@ -36,6 +37,7 @@ type CachedCombosPage = { models: ModelOption[]; cataloguedComboIds: string[]; }; +type ProviderQuotasDto = { reports?: unknown }; function responseError(data: unknown): string | undefined { if (!data || typeof data !== "object" || Array.isArray(data)) return undefined; @@ -218,6 +220,32 @@ export default function Combos({ ); const { state } = resource; + const loadProviderQuotas = useCallback(async (signal?: AbortSignal): Promise => { + const response = await fetch(`${apiBase}/api/provider-quotas`, { signal }); + if (!response.ok) throw new Error("combo quota load failed"); + const payload = await response.json() as unknown; + return payload && typeof payload === "object" && !Array.isArray(payload) + ? payload as ProviderQuotasDto + : {}; + }, [apiBase]); + const quotaResource = useDataSurface( + `ocx.combos.provider-quotas.v1:${apiBase}`, + [apiBase], + loadProviderQuotas, + { + isEmpty: () => false, + pollMs: 60_000, + pauseWhenHidden: true, + enabled: active, + }, + ); + const providerQuotaStates = useMemo( + () => quotaResource.lastAttemptOk + ? providerQuotaStatesFromReports(quotaResource.data?.reports) + : {}, + [quotaResource.data, quotaResource.lastAttemptOk], + ); + const data = state.data ?? retainedData ?? undefined; const combos = data?.combos ?? []; @@ -328,6 +356,7 @@ export default function Combos({ .input:nth-child(4) { grid-column: 3 / -1; } + + .cwi-target-row > .cwi-quota-badge { + grid-column: 3; + justify-self: start; + } + } /* Narrow compatibility for pwi-* classes consumed by ComboWorkspace only. */ diff --git a/gui/tests/combo-native-alias-editor.test.tsx b/gui/tests/combo-native-alias-editor.test.tsx index 2303b5d735..b955527b59 100644 --- a/gui/tests/combo-native-alias-editor.test.tsx +++ b/gui/tests/combo-native-alias-editor.test.tsx @@ -65,6 +65,7 @@ async function renderPanel(baseline: ComboItem, isCreate: boolean, onSave: (item otherIds={[]} otherAliases={[]} providerMap={{ openai: {} }} + providerQuotaStates={{ openai: "available" }} providers={providers} models={models} onSaved={() => {}} @@ -166,4 +167,4 @@ test("edit clears native-alias metadata when alias leaves native family", async }); await act(async () => root.unmount()); container.remove(); -}); \ No newline at end of file +}); diff --git a/gui/tests/combo-workspace-dirty.test.tsx b/gui/tests/combo-workspace-dirty.test.tsx index c2b0e10f13..1f3566dbbe 100644 --- a/gui/tests/combo-workspace-dirty.test.tsx +++ b/gui/tests/combo-workspace-dirty.test.tsx @@ -88,6 +88,7 @@ test("Strict Mode: edit, revert, and unsaved navigation keep dirty state coheren { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + const render = (quotaState: "available" | "exhausted") => ( + + {}} + onSave={async () => ({ ok: true })} + onRemove={async () => ({ ok: true })} + onAdd={() => {}} + adding={false} + onCloseAdd={() => {}} + onCreated={() => {}} + /> + + ); + + await act(async () => { root.render(render("exhausted")); }); + await flushTimers(); + await act(async () => { railButton(container, "combo/alpha").click(); }); + await flushTimers(); + await act(async () => { + setInputValue(container.querySelector("#cwi-edit-alias")!, "dirty"); + }); + + expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(true); + expect(container.textContent).toContain("All enabled targets are out of quota"); + + await act(async () => { root.render(render("available")); }); + expect(container.querySelector("#cwi-edit-save")!.disabled).toBe(false); + expect(container.textContent).not.toContain("All enabled targets are out of quota"); + + await act(async () => { root.unmount(); }); + container.remove(); +}); diff --git a/gui/tests/combo-workspace-empty.test.tsx b/gui/tests/combo-workspace-empty.test.tsx index d2fd7b30a7..4fb8416067 100644 --- a/gui/tests/combo-workspace-empty.test.tsx +++ b/gui/tests/combo-workspace-empty.test.tsx @@ -36,6 +36,7 @@ test("an empty combo list renders the first-combo editor inline", () => { { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + const render = (quotaState: "available" | "exhausted") => ( + + {}} + onSave={async () => ({ ok: true })} + onRemove={async () => ({ ok: true })} + onAdd={() => {}} + adding={false} + onCloseAdd={() => {}} + onCreated={() => {}} + /> + + ); + + await act(async () => { root.render(render("exhausted")); }); + await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); }); + + const providerSelect = container.querySelector('select[aria-label="Provider"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(providerSelect, "openai"); + providerSelect.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + + const createButton = container.querySelector("#cwi-edit-create")!; + expect(createButton.disabled).toBe(true); + expect(container.textContent).toContain("All enabled targets are out of quota"); + + await act(async () => { root.render(render("available")); }); + expect(container.querySelector("#cwi-edit-create")!.disabled).toBe(false); + expect(container.textContent).not.toContain("All enabled targets are out of quota"); + + await act(async () => { root.unmount(); }); + container.remove(); +}); diff --git a/gui/tests/combos-detail-tabs-dom.test.tsx b/gui/tests/combos-detail-tabs-dom.test.tsx index 94f6e8790d..ba03b2fc7e 100644 --- a/gui/tests/combos-detail-tabs-dom.test.tsx +++ b/gui/tests/combos-detail-tabs-dom.test.tsx @@ -54,6 +54,7 @@ async function mountDetail(): Promise<{ container: HTMLElement; root: Root }> { otherIds={[]} otherAliases={[]} providerMap={{}} + providerQuotaStates={{}} providers={[]} models={[]} onSave={async () => ({ ok: true })} diff --git a/tests/combo-workspace-data.test.ts b/tests/combo-workspace-data.test.ts index d621375162..c62d9b3640 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -3,6 +3,7 @@ import { type ComboItem, COMBO_EFFORTS, buildComboAttention, + comboQuotaState, comboPublicModelId, draftEquals, emptyDraft, @@ -11,6 +12,7 @@ import { intersectComboEfforts, isValidComboId, parseComboList, + providerQuotaStatesFromReports, toPutBody, updateComboAliasDraft, validateComboDraft, @@ -25,6 +27,21 @@ const configuredProviders = { disabled: { disabled: true }, } as const; +const QUOTA_NOW = Date.UTC(2026, 7, 24, 12); + +function quotaReport( + provider: string, + quota: Record, + overrides: Record = {}, +) { + return { + provider, + updatedAt: QUOTA_NOW, + quota: { updatedAt: QUOTA_NOW, ...quota }, + ...overrides, + }; +} + function combo(overrides: Partial = {}): ComboItem { return { id: "free", @@ -235,6 +252,124 @@ describe("combo-workspace-data", () => { ]); }); + test("derives exhausted state from USD, percentage, and custom-window evidence", () => { + expect(providerQuotaStatesFromReports([ + quotaReport("usd", { + creditsUsd: { used: 10, limit: 10, remaining: 0, percent: 100 }, + }), + quotaReport("percent", { fiveHourPercent: 100 }), + quotaReport("custom", { customWindows: [{ label: "Daily", percent: 101 }] }), + ], QUOTA_NOW)).toEqual({ + usd: "exhausted", + percent: "exhausted", + custom: "exhausted", + }); + }); + + test("keeps unlimited credits available and stale or malformed evidence unknown", () => { + expect(providerQuotaStatesFromReports([ + quotaReport("unlimited", { + creditsUsd: { used: 0, limit: 0, remaining: 0, percent: 0, unlimited: true }, + }), + quotaReport("stale", { weeklyPercent: 100 }, { updatedAt: QUOTA_NOW - 30 * 60_000 }), + quotaReport("malformed", { fiveHourPercent: "100" }), + quotaReport("missing", {}), + ], QUOTA_NOW)).toEqual({ + unlimited: "available", + stale: "unknown", + malformed: "unknown", + missing: "unknown", + }); + }); + + test("trims provider ids and rejects incomplete aggregate quota evidence", () => { + expect(providerQuotaStatesFromReports([ + quotaReport(" openai ", { weeklyPercent: 75 }), + quotaReport("pool", { weeklyPercent: 100 }, { + aggregation: { + kind: "capacity-weighted-v1", + scope: "routable-known", + presentation: "aggregate", + incomplete: true, + excludedAccounts: 1, + unknownPlanAccounts: 0, + partialWindowAccounts: 0, + }, + }), + quotaReport("malformed-pool", { weeklyPercent: 100 }, { + aggregation: { + kind: "capacity-weighted-v1", + scope: "routable-known", + presentation: "aggregate", + incomplete: false, + }, + }), + quotaReport("complete-pool", { weeklyPercent: 100 }, { + aggregation: { + kind: "capacity-weighted-v1", + scope: "routable-known", + presentation: "aggregate", + incomplete: false, + includedAccounts: 2, + excludedAccounts: 0, + unknownPlanAccounts: 0, + missingQuotaAccounts: 0, + pausedAccounts: 0, + reauthAccounts: 0, + staleQuotaAccounts: 0, + partialWindowAccounts: 0, + weekly: { + usedPercent: 100, + includedAccounts: 2, + excludedAccounts: 0, + incomplete: false, + updatedAt: QUOTA_NOW, + }, + }, + }), + ], QUOTA_NOW)).toEqual({ + openai: "available", + pool: "unknown", + "malformed-pool": "unknown", + "complete-pool": "exhausted", + }); + }); + + test("combo quota excludes disabled targets and disables only when every usable target is exhausted", () => { + const states = { a: "exhausted", b: "available", disabled: "available" } as const; + expect(comboQuotaState(combo().targets, states, configuredProviders)).toBe("available"); + expect(comboQuotaState([ + { provider: " a ", model: "m1" }, + { provider: "disabled", model: "m2" }, + ], states, configuredProviders)).toBe("exhausted"); + expect(comboQuotaState([ + { provider: "a", model: "m1" }, + { provider: "missing", model: "m2" }, + ], states, configuredProviders)).toBe("exhausted"); + expect(comboQuotaState([ + { provider: "disabled", model: "m1" }, + ], states, configuredProviders)).toBe("unknown"); + expect(comboQuotaState(combo().targets, { a: "exhausted" }, configuredProviders)).toBe("unknown"); + }); + + test("combo quota recovers as soon as live provider evidence becomes available", () => { + const targets = [{ provider: "a", model: "m1" }]; + expect(comboQuotaState(targets, { a: "exhausted" }, configuredProviders)).toBe("exhausted"); + expect(comboQuotaState(targets, { a: "available" }, configuredProviders)).toBe("available"); + expect(comboQuotaState(targets, { a: "unknown" }, configuredProviders)).toBe("unknown"); + }); + + test("attention includes combos whose usable targets are all exhausted", () => { + expect(buildComboAttention([combo()], { + providerQuotaStates: { a: "exhausted", b: "exhausted" }, + providers: configuredProviders, + })).toContainEqual({ + id: "free", + model: "combo/free", + reason: "all-targets-exhausted", + }); + }); + test("validates combo id boundaries and duplicate ids on create and rename", () => { expect(isValidComboId("a")).toBe(true); expect(isValidComboId(`a${"x".repeat(63)}`)).toBe(true);