diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index 56ce088238..5152bd3429 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -84,7 +84,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}$/; @@ -207,10 +207,15 @@ export function filterCombos(items: ComboItem[], query: string): ComboItem[] { export function buildComboAttention( items: ComboItem[], - options: { cataloguedComboIds?: ReadonlySet } = {}, + options: { + cataloguedComboIds?: ReadonlySet; + /** Providers whose quota is fully exhausted (0 credits remaining). */ + exhaustedProviders?: ReadonlySet; + } = {}, ): ComboAttentionItem[] { const out: ComboAttentionItem[] = []; const catalogued = options.cataloguedComboIds; + const exhausted = options.exhaustedProviders; for (const item of items) { if (item.targets.length === 0) { out.push({ id: item.id, model: item.model, reason: "empty-targets" }); @@ -223,6 +228,15 @@ export function buildComboAttention( if (catalogued && item.targets.length > 0 && !catalogued.has(item.id)) { out.push({ id: item.id, model: item.model, reason: "catalog-omitted" }); } + // Every target points at a provider with an exhausted quota. + if ( + exhausted + && exhausted.size > 0 + && item.targets.length > 0 + && item.targets.every((target) => exhausted.has(target.provider.trim())) + ) { + out.push({ id: item.id, model: item.model, reason: "all-targets-exhausted" }); + } } return out; } @@ -375,3 +389,32 @@ export function emptyDraft(id = ""): ComboItem { targets: [newComboTarget()], }; } + +/** + * Derives a set of provider names whose quota is fully exhausted + * from the /api/provider-quotas response shape. + */ +export function exhaustedProvidersFromQuotaReports( + reports: ReadonlyArray<{ provider: string; quota?: { creditsUsd?: { remaining?: number; unlimited?: boolean } } }>, +): Set { + const exhausted = new Set(); + for (const report of reports) { + const q = report.quota; + if (!q) continue; + const credits = q.creditsUsd; + if (!credits) continue; + if (credits.unlimited) continue; + if (typeof credits.remaining === "number" && credits.remaining <= 0) { + exhausted.add(report.provider); + } + } + return exhausted; +} + +/** True when the provider has a quota report showing 0 remaining credits. */ +export function isProviderExhausted( + provider: string, + exhaustedProviders: ReadonlySet | undefined, +): boolean { + return exhaustedProviders !== undefined && exhaustedProviders.has(provider.trim()); +} diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index 66cd751f64..0b14cf178a 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -21,6 +21,7 @@ export default function ComboWorkspace({ providers, models, cataloguedComboIds, + exhaustedProviders, loading, onRefresh, onSave, @@ -190,6 +191,7 @@ export default function ComboWorkspace({ onRequestRemove={() => setRemoveId(baseline.id)} onSave={onSave} onDirtyChange={setDetailDirty} + exhaustedProviders={exhaustedProviders} /> ) : creatingFirstCombo ? ( ) : ( trySelect(id)} onAdd={onAdd} /> diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index 8a7277fd48..0d4207e937 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -6,6 +6,7 @@ import { useT } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { clampedNumberInput, enabledProviders, modelsForProvider } from "./combo-workspace-utils"; +import { isProviderExhausted } from "../combo-workspace-data"; export function StrategySeg({ value, @@ -89,12 +90,14 @@ export function TargetEditor({ providers, models, onChange, + exhaustedProviders, }: { targets: ComboTarget[]; strategy: ComboStrategy; providers: ProviderOption[]; models: ModelOption[]; onChange: (next: ComboTarget[]) => void; + exhaustedProviders?: ReadonlySet; }) { const t = useT(); const provs = enabledProviders(providers); @@ -127,6 +130,7 @@ export function TargetEditor({ const modelSelectDisabled = !row.provider; const dragging = dragIndex === index; const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index; + const targetExhausted = row.provider && isProviderExhausted(row.provider, exhaustedProviders); return (
)} + {targetExhausted && ( + + {t("cws.target.quotaExhaustedShort")} + + )}