Skip to content

Commit a58106b

Browse files
authored
Merge pull request #73 from tyulyukov/marcode/pr-provider-rate-limit-meter
feat(web): surface provider rate limit usage in composer
2 parents 1bd7daf + dfda113 commit a58106b

5 files changed

Lines changed: 444 additions & 0 deletions

File tree

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,21 @@ function runtimeEventToActivities(
493493
];
494494
}
495495

496+
case "account.rate-limits.updated": {
497+
return [
498+
{
499+
id: event.eventId,
500+
createdAt: event.createdAt,
501+
tone: "info",
502+
kind: "account.rate-limits.updated",
503+
summary: "Account rate limits updated",
504+
payload: event.payload.rateLimits,
505+
turnId: toTurnId(event.turnId) ?? null,
506+
...maybeSequence,
507+
},
508+
];
509+
}
510+
496511
case "item.updated": {
497512
if (!isToolLifecycleItemType(event.payload.itemType)) {
498513
return [];

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
renderProviderTraitsPicker,
7777
} from "./composerProviderState";
7878
import { ContextWindowMeter } from "./ContextWindowMeter";
79+
import { ProviderUsageMeter } from "./ProviderUsageMeter";
7980
import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";
8081
import { basenameOfPath } from "../../vscode-icons";
8182
import { cn, randomUUID } from "~/lib/utils";
@@ -105,6 +106,7 @@ import type { SessionPhase, Thread } from "../../types";
105106
import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
106107
import type { PendingApproval, PendingUserInput } from "../../session-logic";
107108
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
109+
import { deriveLatestProviderUsageSnapshot } from "../../lib/providerUsage";
108110
import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
109111
import { searchProviderSkills } from "../../providerSkillSearch";
110112

@@ -273,6 +275,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
273275
const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
274276
compact: boolean;
275277
activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>;
278+
activeProviderUsage: ReturnType<typeof deriveLatestProviderUsageSnapshot>;
276279
isPreparingWorktree: boolean;
277280
pendingAction: {
278281
questionIndex: number;
@@ -293,6 +296,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
293296
}) {
294297
return (
295298
<>
299+
{props.activeProviderUsage ? <ProviderUsageMeter usage={props.activeProviderUsage} /> : null}
296300
{props.activeContextWindow ? <ContextWindowMeter usage={props.activeContextWindow} /> : null}
297301
{props.isPreparingWorktree ? (
298302
<span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
@@ -648,6 +652,14 @@ export const ChatComposer = memo(
648652
[activeThreadActivities],
649653
);
650654

655+
// ------------------------------------------------------------------
656+
// Provider usage (rate limits / session %)
657+
// ------------------------------------------------------------------
658+
const activeProviderUsage = useMemo(
659+
() => deriveLatestProviderUsageSnapshot(activeThreadActivities ?? []),
660+
[activeThreadActivities],
661+
);
662+
651663
// ------------------------------------------------------------------
652664
// Composer-local state
653665
// ------------------------------------------------------------------
@@ -1962,6 +1974,7 @@ export const ChatComposer = memo(
19621974
<ComposerFooterPrimaryActions
19631975
compact={isComposerPrimaryActionsCompact}
19641976
activeContextWindow={activeContextWindow}
1977+
activeProviderUsage={activeProviderUsage}
19651978
pendingAction={pendingPrimaryAction}
19661979
isRunning={phase === "running"}
19671980
showPlanFollowUpPrompt={
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { type ProviderUsageSnapshot } from "~/lib/providerUsage";
2+
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
3+
4+
function formatResetTime(resetsAt: number | null): string | null {
5+
if (resetsAt === null) {
6+
return null;
7+
}
8+
const date = new Date(resetsAt * 1000);
9+
return `Resets ${date.toLocaleDateString(undefined, {
10+
month: "short",
11+
day: "numeric",
12+
})}, ${date.toLocaleTimeString(undefined, {
13+
hour: "numeric",
14+
minute: "2-digit",
15+
timeZoneName: "short",
16+
})}`;
17+
}
18+
19+
function UsageBar(props: { percent: number; status: "ok" | "warning" | "rejected" }) {
20+
const barColor =
21+
props.status === "rejected" || props.percent >= 90
22+
? "bg-red-500"
23+
: props.status === "warning" || props.percent >= 70
24+
? "bg-amber-500"
25+
: "bg-rose-500";
26+
27+
return (
28+
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/50">
29+
<div
30+
className={`absolute inset-y-0 left-0 rounded-full transition-[width] duration-500 ease-out ${barColor}`}
31+
style={{ width: `${Math.min(100, Math.max(0, props.percent))}%` }}
32+
/>
33+
</div>
34+
);
35+
}
36+
37+
function BarGraphIcon(props: { status: "ok" | "warning" | "rejected" }) {
38+
const barColor =
39+
props.status === "rejected"
40+
? "var(--color-destructive)"
41+
: props.status === "warning"
42+
? "var(--color-warning, #f59e0b)"
43+
: "var(--color-muted-foreground)";
44+
45+
return (
46+
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
47+
<rect x="4" y="13" width="4" height="7" rx="1" fill={barColor} opacity={0.6} />
48+
<rect x="10" y="8" width="4" height="12" rx="1" fill={barColor} opacity={0.8} />
49+
<rect x="16" y="4" width="4" height="16" rx="1" fill={barColor} />
50+
</svg>
51+
);
52+
}
53+
54+
export function ProviderUsageMeter(props: { usage: ProviderUsageSnapshot }) {
55+
const { usage } = props;
56+
const maxPercent = Math.max(...usage.windows.map((w) => w.usedPercent), 0);
57+
58+
return (
59+
<Popover>
60+
<PopoverTrigger
61+
openOnHover
62+
delay={150}
63+
closeDelay={0}
64+
render={
65+
<button
66+
type="button"
67+
className="group inline-flex items-center justify-center rounded-full p-0.5 transition-opacity hover:opacity-85"
68+
aria-label={`${usage.providerLabel} usage: ${Math.round(maxPercent)}%`}
69+
>
70+
<BarGraphIcon status={usage.status} />
71+
</button>
72+
}
73+
/>
74+
<PopoverPopup tooltipStyle side="top" align="end" className="w-64 max-w-none px-4 py-3">
75+
<div className="space-y-3">
76+
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
77+
{usage.providerLabel}
78+
</div>
79+
80+
{usage.windows.map((window) => {
81+
const resetText = formatResetTime(window.resetsAt);
82+
return (
83+
<div key={window.label} className="space-y-1.5">
84+
<div className="flex items-baseline justify-between">
85+
<span className="text-xs font-semibold text-foreground">{window.label}</span>
86+
<span className="text-xs font-semibold text-foreground">
87+
{Math.round(window.usedPercent)}%
88+
</span>
89+
</div>
90+
<UsageBar percent={window.usedPercent} status={usage.status} />
91+
{resetText ? (
92+
<div className="text-[11px] text-muted-foreground">{resetText}</div>
93+
) : null}
94+
</div>
95+
);
96+
})}
97+
</div>
98+
</PopoverPopup>
99+
</Popover>
100+
);
101+
}

0 commit comments

Comments
 (0)