Skip to content

Commit fe4beae

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 7715252 commit fe4beae

31 files changed

Lines changed: 4435 additions & 163 deletions

packages/opencode/src/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export namespace Auth {
1616
type: Schema.Literal("oauth"),
1717
refresh: Schema.String,
1818
access: Schema.String,
19+
usage: Schema.optional(Schema.String),
1920
expires: Schema.Number,
2021
accountId: Schema.optional(Schema.String),
2122
enterpriseUrl: Schema.optional(Schema.String),

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

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

0 commit comments

Comments
 (0)