Skip to content

Commit 236d4d8

Browse files
feat(core): Add usage tracking for OAuth providers
Introduce a centralised `/usage` endpoint that fetches and caches rate-limit windows, credits and plan information for OpenAI ChatGPT, GitHub Copilot and Anthropic Claude. The server handles stale-while-revalidate caching (5 min TTL), per-provider error reporting and graceful fallback to cached data on network failures. - Add usage provider registry mapping auth keys to display names - Support two-call OAuth flow for Copilot usage token acquisition - Automatic token refresh on 401/expired Claude responses - TUI: shared usage client, dialog and sidebar components with live event-driven updates - Return structured `errors[]` alongside `entries[]` for granular UI feedback - Tests for usage endpoint and new Copilot-specific auth device flow - Regenerate SDK/OpenAPI with new Usage class and types Closes anomalyco#9281, anomalyco#728 Supersedes anomalyco#6905, anomalyco#7837 Alternate to anomalyco#9301
1 parent 6cd3a59 commit 236d4d8

File tree

32 files changed

+4448
-164
lines changed

32 files changed

+4448
-164
lines changed

packages/opencode/src/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export namespace Auth {
1010
type: z.literal("oauth"),
1111
refresh: z.string(),
1212
access: z.string(),
13+
usage: z.string().optional(),
1314
expires: z.number(),
1415
accountId: z.string().optional(),
1516
enterpriseUrl: z.string().optional(),

packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type CommandOption = DialogSelectOption<string> & {
2525
keybind?: keyof KeybindsConfig
2626
suggested?: boolean
2727
slash?: Slash
28+
slashDescription?: string
2829
hidden?: boolean
2930
enabled?: boolean
3031
}
@@ -87,7 +88,7 @@ function init() {
8788
if (!slash) return []
8889
return {
8990
display: "/" + slash.name,
90-
description: option.description ?? option.title,
91+
description: option.slashDescription ?? option.description ?? option.title,
9192
aliases: slash.aliases?.map((alias) => "/" + alias),
9293
onSelect: () => result.trigger(option.value),
9394
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { TextAttributes } from "@opentui/core"
2+
import { useKeyboard } from "@opentui/solid"
3+
import { useTheme } from "../context/theme"
4+
import { useDialog } from "@tui/ui/dialog"
5+
import { useSync } from "@tui/context/sync"
6+
import {
7+
formatCreditsLabel,
8+
formatPlanType,
9+
formatUsageResetLong,
10+
usageDisplay,
11+
type UsageDisplayMode,
12+
formatUsageWindowLabel,
13+
usageBarColor,
14+
usageBarString,
15+
} from "./usage-format"
16+
import type { UsageEntry, UsageError, UsageWindow } from "./usage-data"
17+
import { For, Show, createSignal } from "solid-js"
18+
19+
type Theme = ReturnType<typeof useTheme>["theme"]
20+
21+
export function DialogUsage(props: { entries: UsageEntry[]; errors?: UsageError[]; initialMode?: UsageDisplayMode }) {
22+
const { theme } = useTheme()
23+
const sync = useSync()
24+
const dialog = useDialog()
25+
const [hover, setHover] = createSignal(false)
26+
const [mode, setMode] = createSignal<UsageDisplayMode>(
27+
props.initialMode ?? sync.data.config.tui?.show_usage_value_mode ?? "used",
28+
)
29+
30+
useKeyboard((evt) => {
31+
if (evt.name !== "tab") return
32+
evt.preventDefault()
33+
setMode((value) => (value === "used" ? "remaining" : "used"))
34+
})
35+
36+
return (
37+
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1} flexDirection="column">
38+
<box flexDirection="row" justifyContent="space-between">
39+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
40+
Usage
41+
</text>
42+
<box flexDirection="row" gap={1} alignItems="center">
43+
<text fg={theme.textMuted}>
44+
<span style={{ fg: theme.text }}>tab</span> toggle view
45+
</text>
46+
<box
47+
paddingLeft={1}
48+
paddingRight={1}
49+
backgroundColor={hover() ? theme.primary : undefined}
50+
onMouseOver={() => setHover(true)}
51+
onMouseOut={() => setHover(false)}
52+
onMouseUp={() => dialog.clear()}
53+
>
54+
<text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text>
55+
</box>
56+
</box>
57+
</box>
58+
<Show when={props.entries.length > 0} fallback={<text fg={theme.text}>No usage data available.</text>}>
59+
<For each={props.entries}>
60+
{(entry, index) => {
61+
const planType = formatPlanType(entry.snapshot.planType)
62+
const entryErrors = (props.errors ?? [])
63+
.filter((error) => error.provider === entry.provider)
64+
.map((error) => error.message)
65+
return (
66+
<box flexDirection="column" marginTop={index() === 0 ? 0 : 1} gap={1}>
67+
<box flexDirection="column">
68+
<text fg={theme.text} attributes={TextAttributes.BOLD}>
69+
{entry.displayName} Usage
70+
<Show when={planType}>
71+
<span style={{ fg: theme.textMuted }}>{` (${planType})`}</span>
72+
</Show>
73+
</text>
74+
<text fg={theme.textMuted}>{"─".repeat(Math.max(24, entry.displayName.length + 20))}</text>
75+
</box>
76+
<Show when={entry.snapshot.primary}>
77+
{(window) => (
78+
<box flexDirection="column">{renderWindow(entry.provider, "primary", window(), mode(), theme)}</box>
79+
)}
80+
</Show>
81+
<Show when={entry.snapshot.secondary}>
82+
{(window) => (
83+
<box flexDirection="column">
84+
{renderWindow(entry.provider, "secondary", window(), mode(), theme)}
85+
</box>
86+
)}
87+
</Show>
88+
<Show when={entry.snapshot.tertiary}>
89+
{(window) => (
90+
<box flexDirection="column">
91+
{renderWindow(entry.provider, "tertiary", window(), mode(), theme)}
92+
</box>
93+
)}
94+
</Show>
95+
<Show when={entry.snapshot.credits}>
96+
{(credits) => (
97+
<text fg={theme.text}>
98+
{formatCreditsLabel(entry.provider, credits(), {
99+
mode: mode(),
100+
slot: "secondary",
101+
})}
102+
</text>
103+
)}
104+
</Show>
105+
<Show when={entryErrors.length > 0}>
106+
<text fg={theme.error} attributes={TextAttributes.DIM}>
107+
{entryErrors.join(" • ")}
108+
</text>
109+
</Show>
110+
</box>
111+
)
112+
}}
113+
</For>
114+
</Show>
115+
</box>
116+
)
117+
}
118+
119+
function renderWindow(
120+
provider: string,
121+
windowType: "primary" | "secondary" | "tertiary",
122+
window: UsageWindow,
123+
mode: UsageDisplayMode,
124+
theme: Theme,
125+
showReset = true,
126+
) {
127+
const usedPercent = usageDisplay(window.usedPercent, "used").percent
128+
const display = usageDisplay(window.usedPercent, mode)
129+
const windowLabel = formatUsageWindowLabel(provider, windowType, window.windowMinutes)
130+
131+
return (
132+
<box flexDirection="column">
133+
<text fg={theme.text}>
134+
{windowLabel} Limit: [
135+
<span style={{ fg: usageBarColor(usedPercent, theme) }}>{usageBarString(display.percent)}</span>]{" "}
136+
{display.percent.toFixed(0)}% {display.label}
137+
</text>
138+
<Show when={showReset && window.resetsAt !== null}>
139+
<text fg={theme.textMuted}>Resets {formatUsageResetLong(window.resetsAt!)}</text>
140+
</Show>
141+
</box>
142+
)
143+
}

0 commit comments

Comments
 (0)