Skip to content

Commit cefd39b

Browse files
committed
Wire up gateway usage endpoint in Plan & Usage settings
1 parent 9c81717 commit cefd39b

6 files changed

Lines changed: 198 additions & 73 deletions

File tree

apps/code/src/main/services/llm-gateway/schemas.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,21 @@ export interface AnthropicErrorResponse {
5656
code?: string;
5757
};
5858
}
59+
60+
export const usageBucketSchema = z.object({
61+
used_usd: z.number(),
62+
limit_usd: z.number(),
63+
remaining_usd: z.number(),
64+
resets_in_seconds: z.number(),
65+
exceeded: z.boolean(),
66+
});
67+
68+
export const usageOutput = z.object({
69+
product: z.string(),
70+
user_id: z.number(),
71+
sustained: usageBucketSchema,
72+
burst: usageBucketSchema,
73+
is_rate_limited: z.boolean(),
74+
});
75+
76+
export type UsageOutput = z.infer<typeof usageOutput>;

apps/code/src/main/services/llm-gateway/service.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
1+
import {
2+
getGatewayUsageUrl,
3+
getLlmGatewayUrl,
4+
} from "@posthog/agent/posthog-api";
25
import { net } from "electron";
36
import { inject, injectable } from "inversify";
47
import { MAIN_TOKENS } from "../../di/tokens";
@@ -10,6 +13,7 @@ import type {
1013
AnthropicMessagesResponse,
1114
LlmMessage,
1215
PromptOutput,
16+
UsageOutput,
1317
} from "./schemas";
1418

1519
const log = logger.scope("llm-gateway");
@@ -134,4 +138,27 @@ export class LlmGatewayService {
134138
},
135139
};
136140
}
141+
142+
async fetchUsage(): Promise<UsageOutput> {
143+
const auth = await this.authService.getValidAccessToken();
144+
const usageUrl = getGatewayUsageUrl(auth.apiHost);
145+
146+
log.debug("Fetching usage from gateway", { url: usageUrl });
147+
148+
const response = await this.authService.authenticatedFetch(
149+
net.fetch,
150+
usageUrl,
151+
);
152+
153+
if (!response.ok) {
154+
throw new LlmGatewayError(
155+
`Failed to fetch usage: HTTP ${response.status}`,
156+
"usage_error",
157+
undefined,
158+
response.status,
159+
);
160+
}
161+
162+
return (await response.json()) as UsageOutput;
163+
}
137164
}

apps/code/src/main/trpc/routers/llm-gateway.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { container } from "../../di/container";
22
import { MAIN_TOKENS } from "../../di/tokens";
3-
import { promptInput, promptOutput } from "../../services/llm-gateway/schemas";
3+
import {
4+
promptInput,
5+
promptOutput,
6+
usageOutput,
7+
} from "../../services/llm-gateway/schemas";
48
import type { LlmGatewayService } from "../../services/llm-gateway/service";
59
import { publicProcedure, router } from "../trpc";
610

@@ -18,4 +22,8 @@ export const llmGatewayRouter = router({
1822
model: input.model,
1923
}),
2024
),
25+
26+
usage: publicProcedure
27+
.output(usageOutput)
28+
.query(() => getService().fetchUsage()),
2129
});

apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx

Lines changed: 123 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,63 @@ import {
1515
Spinner,
1616
Text,
1717
} from "@radix-ui/themes";
18+
import { trpcClient } from "@renderer/trpc/client";
19+
import { logger } from "@utils/logger";
1820
import { getPostHogUrl } from "@utils/urls";
19-
import { useState } from "react";
21+
import { useEffect, useState } from "react";
22+
23+
const log = logger.scope("plan-usage");
24+
25+
interface UsageBucket {
26+
used_usd: number;
27+
limit_usd: number;
28+
remaining_usd: number;
29+
resets_in_seconds: number;
30+
exceeded: boolean;
31+
}
32+
33+
interface UsageData {
34+
sustained: UsageBucket;
35+
burst: UsageBucket;
36+
is_rate_limited: boolean;
37+
}
38+
39+
function formatUsd(amount: number): string {
40+
return `$${amount.toFixed(2)}`;
41+
}
42+
43+
function formatResetTime(seconds: number): string {
44+
const days = Math.ceil(seconds / 86400);
45+
if (days === 1) return "1 day";
46+
return `${days} days`;
47+
}
48+
49+
function useUsage() {
50+
const [usage, setUsage] = useState<UsageData | null>(null);
51+
const [isLoading, setIsLoading] = useState(true);
52+
53+
useEffect(() => {
54+
let cancelled = false;
55+
56+
trpcClient.llmGateway.usage
57+
.query()
58+
.then((data) => {
59+
if (!cancelled) setUsage(data);
60+
})
61+
.catch((error) => {
62+
log.warn("Failed to fetch usage", error);
63+
})
64+
.finally(() => {
65+
if (!cancelled) setIsLoading(false);
66+
});
67+
68+
return () => {
69+
cancelled = true;
70+
};
71+
}, []);
72+
73+
return { usage, isLoading };
74+
}
2075

2176
export function PlanUsageSettings() {
2277
const {
@@ -31,6 +86,7 @@ export function PlanUsageSettings() {
3186
const { upgradeToPro, cancelSeat, reactivateSeat, clearError } =
3287
useSeatStore();
3388
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
89+
const { usage, isLoading: usageLoading } = useUsage();
3490

3591
const formattedActiveUntil = activeUntil
3692
? activeUntil.toLocaleDateString(undefined, {
@@ -181,59 +237,30 @@ export function PlanUsageSettings() {
181237
<Text size="2" weight="medium" style={{ color: "var(--gray-9)" }}>
182238
Usage
183239
</Text>
184-
{isPro ? (
240+
{usageLoading ? (
185241
<Flex
186-
direction="column"
187-
gap="3"
242+
align="center"
243+
justify="center"
188244
p="4"
189245
style={{
190-
border: "1px solid var(--accent-7)",
246+
border: "1px solid var(--gray-5)",
191247
borderRadius: "var(--radius-3)",
192248
}}
193249
>
194-
<Flex align="center" justify="between">
195-
<Text size="2" weight="medium">
196-
Token usage
197-
</Text>
198-
<Text
199-
size="2"
200-
weight="medium"
201-
style={{ color: "var(--accent-9)" }}
202-
>
203-
Unlimited
204-
</Text>
205-
</Flex>
206-
<div
207-
style={{
208-
position: "relative",
209-
height: 8,
210-
borderRadius: 4,
211-
overflow: "hidden",
212-
background: "var(--gray-4)",
213-
}}
214-
>
215-
<div
216-
style={{
217-
position: "absolute",
218-
inset: 0,
219-
borderRadius: 4,
220-
background:
221-
"linear-gradient(90deg, var(--amber-9), var(--orange-9), var(--amber-8), var(--orange-10), var(--amber-9))",
222-
backgroundSize: "300% 100%",
223-
animation: "lava-flow 4s linear infinite",
224-
boxShadow: "0 0 10px var(--orange-8)",
225-
}}
226-
/>
227-
<style>{`
228-
@keyframes lava-flow {
229-
0% { background-position: 300% 50%; }
230-
100% { background-position: 0% 50%; }
231-
}
232-
`}</style>
233-
</div>
234-
<Text size="1" style={{ color: "var(--gray-9)" }}>
235-
Unlimited tokens included with Pro (go crazy)
236-
</Text>
250+
<Spinner size="2" />
251+
</Flex>
252+
) : usage ? (
253+
<Flex direction="column" gap="3">
254+
<UsageMeter
255+
label="Sustained"
256+
bucket={usage.sustained}
257+
color={usage.sustained.exceeded ? "red" : undefined}
258+
/>
259+
<UsageMeter
260+
label="Burst"
261+
bucket={usage.burst}
262+
color={usage.burst.exceeded ? "red" : undefined}
263+
/>
237264
</Flex>
238265
) : (
239266
<Flex
@@ -245,17 +272,8 @@ export function PlanUsageSettings() {
245272
borderRadius: "var(--radius-3)",
246273
}}
247274
>
248-
<Flex align="center" justify="between">
249-
<Text size="2" weight="medium">
250-
Token usage
251-
</Text>
252-
<Text size="2" weight="medium">
253-
0%
254-
</Text>
255-
</Flex>
256-
<Progress value={0} size="2" />
257-
<Text size="1" style={{ color: "var(--gray-9)" }}>
258-
0 tokens used this period
275+
<Text size="2" color="gray">
276+
Unable to load usage data
259277
</Text>
260278
</Flex>
261279
)}
@@ -349,6 +367,52 @@ export function PlanUsageSettings() {
349367
);
350368
}
351369

370+
interface UsageMeterProps {
371+
label: string;
372+
bucket: UsageBucket;
373+
color?: "red";
374+
}
375+
376+
function UsageMeter({ label, bucket, color }: UsageMeterProps) {
377+
const percentage =
378+
bucket.limit_usd > 0
379+
? Math.min(100, (bucket.used_usd / bucket.limit_usd) * 100)
380+
: 0;
381+
382+
const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)";
383+
384+
return (
385+
<Flex
386+
direction="column"
387+
gap="3"
388+
p="4"
389+
style={{
390+
border: `1px solid ${borderColor}`,
391+
borderRadius: "var(--radius-3)",
392+
}}
393+
>
394+
<Flex align="center" justify="between">
395+
<Text size="2" weight="medium">
396+
{label}
397+
</Text>
398+
<Text size="2" weight="medium">
399+
{formatUsd(bucket.used_usd)} / {formatUsd(bucket.limit_usd)}
400+
</Text>
401+
</Flex>
402+
<Progress
403+
value={percentage}
404+
size="2"
405+
color={color === "red" ? "red" : undefined}
406+
/>
407+
<Text size="1" style={{ color: "var(--gray-9)" }}>
408+
{bucket.exceeded
409+
? "Limit exceeded"
410+
: `${formatUsd(bucket.remaining_usd)} remaining \u00b7 resets in ${formatResetTime(bucket.resets_in_seconds)}`}
411+
</Text>
412+
</Flex>
413+
);
414+
}
415+
352416
interface PlanCardProps {
353417
name: string;
354418
price: string;

packages/agent/src/posthog-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import type {
77
TaskRun,
88
TaskRunArtifact,
99
} from "./types";
10-
import { getLlmGatewayUrl } from "./utils/gateway";
10+
import { getGatewayUsageUrl, getLlmGatewayUrl } from "./utils/gateway";
1111

12-
export { getLlmGatewayUrl };
12+
export { getGatewayUsageUrl, getLlmGatewayUrl };
1313

1414
const DEFAULT_USER_AGENT = `posthog/agent.hog.dev; version: ${packageJson.version}`;
1515

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
export type GatewayProduct = "posthog_code" | "background_agents";
22

3-
export function getLlmGatewayUrl(
4-
posthogHost: string,
5-
product: GatewayProduct = "posthog_code",
6-
): string {
3+
function getGatewayBaseUrl(posthogHost: string): string {
74
const url = new URL(posthogHost);
85
const hostname = url.hostname;
96

10-
// Local development (normalize 127.0.0.1 to localhost)
117
if (hostname === "localhost" || hostname === "127.0.0.1") {
12-
return `${url.protocol}//localhost:3308/${product}`;
8+
return `${url.protocol}//localhost:3308`;
139
}
1410

15-
// Docker containers accessing host
1611
if (hostname === "host.docker.internal") {
17-
return `${url.protocol}//host.docker.internal:3308/${product}`;
12+
return `${url.protocol}//host.docker.internal:3308`;
1813
}
1914

20-
// Production - extract region from hostname, default to US
2115
const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us";
22-
return `https://gateway.${region}.posthog.com/${product}`;
16+
return `https://gateway.${region}.posthog.com`;
17+
}
18+
19+
export function getLlmGatewayUrl(
20+
posthogHost: string,
21+
product: GatewayProduct = "posthog_code",
22+
): string {
23+
return `${getGatewayBaseUrl(posthogHost)}/${product}`;
24+
}
25+
26+
export function getGatewayUsageUrl(
27+
posthogHost: string,
28+
product: GatewayProduct = "posthog_code",
29+
): string {
30+
return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}`;
2331
}

0 commit comments

Comments
 (0)