From c8c4358a1f3690ca4cfed85dbcbc8c1711296cf4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 02:36:35 +0900 Subject: [PATCH] feat(gui): surface per-target quota state in combo workspace (#1702) Show quota-exhausted indicators on combo target rows when a backing provider has 0 remaining credits. An attention banner appears in the combo detail panel when every target is exhausted, warning that requests will fail closed. Changes: - combo-workspace-data.ts: add exhaustedProvidersFromQuotaReports(), isProviderExhausted(), and 'all-targets-exhausted' attention type - combo-workspace-controls.tsx: render a '0 credits' badge on exhausted target rows - combo-workspace-detail-panel.tsx: show a warning banner when all targets are exhausted - combo-workspace-overview-panel.tsx: surface all-targets-exhausted in the attention list - Combos.tsx: fetch /api/provider-quotas and derive exhausted providers - i18n: add quota-exhaustion keys across all 8 locales - styles-combos-workspace.css: quota badge and exhausted banner styles Closes #1702 --- gui/src/combo-workspace-data.ts | 47 ++++++++++++++++++- gui/src/components/ComboWorkspace.tsx | 4 ++ .../components/combo-workspace-controls.tsx | 9 ++++ .../combo-workspace-detail-panel.tsx | 20 +++++++- .../combo-workspace-overview-panel.tsx | 11 +++-- gui/src/components/combo-workspace-types.ts | 2 + gui/src/i18n/de.ts | 4 ++ gui/src/i18n/en.ts | 4 ++ gui/src/i18n/ja.ts | 4 ++ gui/src/i18n/ko.ts | 4 ++ gui/src/i18n/ru.ts | 4 ++ gui/src/i18n/tr.ts | 4 ++ gui/src/i18n/zh-TW.ts | 4 ++ gui/src/i18n/zh.ts | 4 ++ gui/src/pages/Combos.tsx | 26 ++++++++++ gui/src/styles-combos-workspace.css | 37 +++++++++++++++ 16 files changed, 181 insertions(+), 7 deletions(-) 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")} + + )}