diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac8..d81b8c24ccf 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -10,6 +10,7 @@ export namespace Auth { type: z.literal("oauth"), refresh: z.string(), access: z.string(), + usage: z.string().optional(), expires: z.number(), accountId: z.string().optional(), enterpriseUrl: z.string().optional(), diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c1..6d4577a5133 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -81,7 +81,8 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - if ("refresh" in result) { + const hasRefresh = "refresh" in result + if (hasRefresh) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result await Auth.set(saveProvider, { type: "oauth", @@ -113,7 +114,8 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - if ("refresh" in result) { + const hasRefresh = "refresh" in result + if (hasRefresh) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result await Auth.set(saveProvider, { type: "oauth", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx new file mode 100644 index 00000000000..bb02ebb33df --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx @@ -0,0 +1,121 @@ +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { + formatCreditsLabel, + formatPlanType, + formatUsageResetLong, + formatUsageWindowLabel, + usageBarColor, + usageBarString, +} from "./usage-format" +import type { UsageEntry, UsageError, UsageWindow } from "./usage-data" +import { For, Show } from "solid-js" + +type Theme = ReturnType["theme"] + +export function DialogUsage(props: { entries: UsageEntry[]; errors?: UsageError[] }) { + const { theme } = useTheme() + + return ( + + + + Usage + + esc + + 0} fallback={No usage data available.}> + + {(entry, index) => { + const mergeReset = entry.provider.startsWith("github-copilot") + const resetAt = + entry.snapshot.primary?.resetsAt ?? + entry.snapshot.secondary?.resetsAt ?? + entry.snapshot.tertiary?.resetsAt ?? + null + const planType = formatPlanType(entry.snapshot.planType) + const entryErrors = (props.errors ?? []) + .filter((error) => error.provider === entry.provider) + .map((error) => error.message) + return ( + + + + {entry.displayName} Usage + + {` (${planType})`} + + + {"─".repeat(Math.max(24, entry.displayName.length + 20))} + + + Resets {formatUsageResetLong(resetAt!)} + + + {(window) => ( + + {renderWindow(entry.provider, "primary", window(), theme, !mergeReset)} + + )} + + + {(window) => ( + + {renderWindow(entry.provider, "secondary", window(), theme, !mergeReset)} + + )} + + + {(window) => ( + + {renderWindow(entry.provider, "tertiary", window(), theme, !mergeReset)} + + )} + + + {(credits) => {formatCreditsLabel(entry.provider, credits())}} + + 0}> + + {entryErrors.join(" • ")} + + + + ) + }} + + + + ) +} + +function renderWindow( + provider: string, + windowType: "primary" | "secondary" | "tertiary", + window: UsageWindow, + theme: Theme, + showReset = true, +) { + const usedPercent = clampPercent(window.usedPercent) + const windowLabel = formatUsageWindowLabel(provider, windowType, window.windowMinutes) + + return ( + + + {windowLabel} Limit: [ + {usageBarString(usedPercent)}]{" "} + {usedPercent.toFixed(0)}% used + + + Resets {formatUsageResetLong(window.resetsAt!)} + + + ) +} + +function clampPercent(value: number): number { + if (Number.isNaN(value)) return 0 + if (value < 0) return 0 + if (value > 100) return 100 + return value +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8576dd5763a..d5569ad2332 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -30,6 +30,8 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" +import { DialogUsage } from "../dialog-usage" +import { fetchUsage, resolveUsageProvider } from "../usage-client" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" @@ -75,6 +77,60 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() + function handleUsageCommand(commandText: string) { + const parts = commandText.trim().split(/\s+/) + const args = parts.slice(1) + const rawProvider = args.find((part) => !part.startsWith("-")) + const hasAll = args.includes("--all") + const hasCurrent = args.includes("--current") + if (hasAll && hasCurrent) { + DialogAlert.show(dialog, "Usage", "Choose only one of --all or --current.") + return + } + + const provider = iife(() => { + if (hasAll || hasCurrent) return undefined + return rawProvider + }) + + const usageScope = iife(() => { + if (hasAll) return "all" + if (hasCurrent) return "current" + return sync.data.config.tui?.show_usage_scope ?? "current" + }) + + const currentProvider = resolveUsageProvider({ + scope: usageScope, + modelProviderID: local.model.current()?.providerID ?? null, + }) + + if (usageScope === "current" && !provider && !currentProvider) { + DialogAlert.show(dialog, "Usage", "Usage tracking is not available for the current provider.") + return + } + + const resolvedProvider = resolveUsageProvider({ + scope: usageScope, + providerOverride: provider ?? null, + modelProviderID: local.model.current()?.providerID ?? null, + }) + const params = resolvedProvider ? { provider: resolvedProvider, refresh: true } : { refresh: true } + + fetchUsage(sdk, params) + .then((data) => { + if (data.entries.length > 0) { + dialog.replace(() => ) + return + } + const message = data.error ?? "No usage data available." + DialogAlert.show(dialog, "Usage", message) + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + DialogAlert.show(dialog, "Usage", message) + }) + } + function promptModelWarning() { toast.show({ variant: "warning", @@ -304,16 +360,23 @@ export function Prompt(props: PromptProps) { return part }) - .filter((part) => part !== null) + .filter(Boolean) as typeof nonTextParts - setStore("prompt", { - input: content, - // keep only the non-text parts because the text parts were - // already expanded inline - parts: updatedNonTextParts, - }) + setStore("prompt", "input", content) + setStore("prompt", "parts", updatedNonTextParts) restoreExtmarksFromParts(updatedNonTextParts) - input.cursorOffset = Bun.stringWidth(content) + }, + }, + { + title: "Usage", + value: "usage.show", + description: "Show usage limits for current or all providers (--current, --all)", + category: "Provider", + slash: { + name: "usage", + }, + onSelect: () => { + handleUsageCommand("/usage") }, }, { @@ -514,7 +577,7 @@ export function Prompt(props: PromptProps) { async function submit() { if (props.disabled) return - if (autocomplete?.visible) return + if (autocomplete?.visible && !store.prompt.input.startsWith("/usage")) return if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { @@ -558,7 +621,50 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() - if (store.mode === "shell") { + const isUsage = inputText.startsWith("/usage") + const isShell = store.mode === "shell" + const isCommand = + inputText.startsWith("/") && + iife(() => { + const firstLine = inputText.split("\n")[0] + const command = firstLine.split(" ")[0].slice(1) + return sync.data.command.some((x) => x.name === command) + }) + + const finalizeSubmit = (options?: { history?: boolean }) => { + const includeHistory = options?.history ?? true + if (includeHistory) { + history.append({ + ...store.prompt, + mode: currentMode, + }) + } + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + + // temporary hack to make sure the message is sent + if (!props.sessionID) + setTimeout(() => { + route.navigate({ + type: "session", + sessionID, + }) + }, 50) + input.clear() + } + + if (isUsage && !isShell) { + handleUsageCommand(inputText) + finalizeSubmit({ history: false }) + return + } + + if (isShell) { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, @@ -569,14 +675,11 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - iife(() => { - const firstLine = inputText.split("\n")[0] - const command = firstLine.split(" ")[0].slice(1) - return sync.data.command.some((x) => x.name === command) - }) - ) { + finalizeSubmit() + return + } + + if (isCommand) { // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) @@ -599,50 +702,32 @@ export function Prompt(props: PromptProps) { ...x, })), }) - } else { - sdk.client.session - .prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) - .catch(() => {}) + finalizeSubmit() + return } - history.append({ - ...store.prompt, - mode: currentMode, - }) - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) - props.onSubmit?.() - - // temporary hack to make sure the message is sent - if (!props.sessionID) - setTimeout(() => { - route.navigate({ - type: "session", - sessionID, - }) - }, 50) - input.clear() + + sdk.client.session + .prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + .catch(() => {}) + finalizeSubmit() } const exit = useExit() diff --git a/packages/opencode/src/cli/cmd/tui/component/usage-client.ts b/packages/opencode/src/cli/cmd/tui/component/usage-client.ts new file mode 100644 index 00000000000..d33a053f314 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/usage-client.ts @@ -0,0 +1,79 @@ +import { createMemo, createResource, onCleanup, type Accessor } from "solid-js" +import { isUsageProvider } from "@/usage/registry" +import { useLocal } from "@tui/context/local" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import type { UsageResult } from "./usage-data" + +type UsageScope = "current" | "all" + +type UsageResource = { + data: Accessor + refetch: () => void + scope: () => UsageScope + provider: () => string | null +} + +export async function fetchUsage( + sdk: ReturnType, + params: { provider?: string; refresh?: boolean }, +): Promise { + const response = await sdk.client.usage.get(params) + return { + entries: (response.data?.entries ?? []) as UsageResult["entries"], + errors: response.data?.errors ?? [], + error: response.data?.error, + } +} + +export function resolveUsageProvider(options: { + scope: UsageScope + providerOverride?: string | null + modelProviderID?: string | null +}): string | null { + if (options.providerOverride) return options.providerOverride + if (options.scope !== "current") return null + if (!options.modelProviderID) return null + if (!isUsageProvider(options.modelProviderID)) return null + return options.modelProviderID +} + +export function useUsageResource(): UsageResource { + const sync = useSync() + const local = useLocal() + const sdk = useSDK() + + const scope = createMemo(() => sync.data.config.tui?.show_usage_scope ?? "current") + const provider = createMemo(() => + resolveUsageProvider({ + scope: scope(), + modelProviderID: local.model.current()?.providerID ?? null, + }), + ) + + const [data, { refetch }] = createResource( + () => ({ scope: scope(), provider: provider() }), + async ({ scope, provider }) => { + if (scope === "current" && !provider) { + return { entries: [], errors: [] } + } + return fetchUsage(sdk, { provider: provider ?? undefined, refresh: false }) + }, + { initialValue: { entries: [], errors: [] } }, + ) + + const unsubscribe = sdk.event.on("usage.updated", (evt) => { + const currentScope = scope() + const currentProvider = provider() + if (currentScope === "current" && currentProvider && evt.properties.provider !== currentProvider) return + refetch() + }) + onCleanup(() => unsubscribe()) + + return { + data, + refetch, + scope, + provider, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/usage-data.ts b/packages/opencode/src/cli/cmd/tui/component/usage-data.ts new file mode 100644 index 00000000000..8f3093a1772 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/usage-data.ts @@ -0,0 +1,33 @@ +export type UsageWindow = { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null +} + +export type UsageEntry = { + provider: string + displayName: string + snapshot: { + primary: UsageWindow | null + secondary: UsageWindow | null + tertiary: UsageWindow | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: string | null + updatedAt: number + } +} + +export type UsageError = { + provider: string + message: string +} + +export type UsageResult = { + entries: UsageEntry[] + error?: string + errors: UsageError[] +} diff --git a/packages/opencode/src/cli/cmd/tui/component/usage-format.ts b/packages/opencode/src/cli/cmd/tui/component/usage-format.ts new file mode 100644 index 00000000000..cf109d4db72 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/usage-format.ts @@ -0,0 +1,118 @@ +type Credits = { + hasCredits: boolean + unlimited: boolean + balance: string | null +} + +type WindowType = "primary" | "secondary" | "tertiary" + +export function formatUsageWindowLabel(provider: string, windowType: WindowType, windowMinutes: number | null): string { + const base = windowBaseLabel(provider, windowType) + return formatWindowLabel(base, windowMinutes) +} + +export function formatPlanType(planType: string | null): string | null { + if (!planType) return null + const normalized = planType.replace(/_/g, " ") + const parts: string[] = [] + for (const part of normalized.split(" ")) { + if (!part) continue + parts.push(part.slice(0, 1).toUpperCase() + part.slice(1)) + } + return parts.join(" ") +} + +export function formatCreditsLabel(provider: string, credits: Credits): string { + if (provider.startsWith("github-copilot")) { + if (credits.unlimited) return "Quota: Unlimited" + if (!credits.hasCredits) return "Quota: Exhausted" + if (credits.balance) return `Quota: ${credits.balance}` + return "Quota: Available" + } + if (provider === "anthropic") return `Extra Usage: ${formatCredits(credits)}` + return `Credits: ${formatCredits(credits)}` +} + +type UsageTheme = { + error: unknown + warning: unknown + success: unknown +} + +export function formatUsageResetShort(resetAt: number | null): string { + if (!resetAt) return "" + const now = Math.floor(Date.now() / 1000) + const diff = resetAt - now + if (diff <= 0) return "refreshing" + if (diff < 60) return `${diff}s` + if (diff < 3600) return `${Math.round(diff / 60)}m` + if (diff < 86400) return `${Math.round(diff / 3600)}h` + return `${Math.round(diff / 86400)}d` +} + +export function formatUsageResetLong(resetAt: number): string { + const now = Math.floor(Date.now() / 1000) + const diff = resetAt - now + if (diff <= 0) return "now" + if (diff < 60) return `in ${diff} seconds` + if (diff < 3600) return `in ${Math.round(diff / 60)} minutes` + if (diff < 86400) return `in ${Math.round(diff / 3600)} hours` + return `in ${Math.round(diff / 86400)} days` +} + +export function usageBarString(percent: number, width = 10): string { + const clamped = Math.max(0, Math.min(100, percent)) + const filled = Math.round((clamped / 100) * width) + return "█".repeat(filled) + "░".repeat(width - filled) +} + +export function usageBarColor( + percent: number, + theme: T, +): T["error"] | T["warning"] | T["success"] { + if (percent >= 90) return theme.error + if (percent >= 70) return theme.warning + return theme.success +} + +function windowBaseLabel(provider: string, windowType: WindowType): string { + if (provider.startsWith("github-copilot")) { + if (windowType === "primary") return "Usage" + if (windowType === "secondary") return "Completions" + } + if (provider === "anthropic") { + if (windowType === "primary") return "5h" + if (windowType === "secondary") return "7d" + } + return windowType === "primary" ? "Hourly" : "Weekly" +} + +function formatWindowLabel(base: string, windowMinutes: number | null): string { + if (!windowMinutes) return base + if (base !== "Hourly" && base !== "Weekly") return base + const minutesPerHour = 60 + const minutesPerDay = 24 * minutesPerHour + const minutesPerWeek = 7 * minutesPerDay + if (windowMinutes >= minutesPerWeek) return "Weekly" + + if (windowMinutes % minutesPerHour === 0) { + const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour)) + if (hours === 1) return "Hourly" + return `${hours}h` + } + + if (windowMinutes < minutesPerHour) return `${windowMinutes}m` + const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour)) + return `${hours}h` +} + +function formatCredits(credits: Credits): string { + if (!credits.hasCredits) return "None" + if (credits.unlimited) return "Unlimited" + if (credits.balance) { + const numeric = Number(credits.balance) + if (!Number.isNaN(numeric)) return String(Math.floor(numeric)) + return credits.balance + } + return "Available" +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4ffe91558ed..3c11e8f4cb6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,7 +1,8 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, For, Show, Switch, Match, createEffect, createSignal, on } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" +import { TextAttributes } from "@opentui/core" import { Locale } from "@/util/locale" import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" @@ -11,6 +12,16 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { + formatCreditsLabel, + formatPlanType, + formatUsageResetShort, + formatUsageWindowLabel, + usageBarColor, + usageBarString, +} from "../../component/usage-format" +import { useUsageResource } from "../../component/usage-client" +import { useLocal } from "@tui/context/local" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -19,27 +30,16 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + const local = useLocal() const [expanded, setExpanded] = createStore({ + usage: true, mcp: true, - diff: true, - todo: true, lsp: true, + todo: true, + diff: true, }) - // Sort MCP servers alphabetically for consistent display order - const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) - - // Count connected and error MCP servers for collapsed header display - const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length) - const errorMcpCount = createMemo( - () => - mcpEntries().filter( - ([_, item]) => - item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration", - ).length, - ) - const cost = createMemo(() => { const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) return new Intl.NumberFormat("en-US", { @@ -68,6 +68,46 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { ) const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) + const usage = useUsageResource() + + const status = createMemo(() => sync.data.session_status?.[props.sessionID] ?? { type: "idle" }) + const [prevStatus, setPrevStatus] = createSignal("idle") + + createEffect( + on( + () => status().type, + (currentType) => { + if (prevStatus() !== "idle" && currentType === "idle") { + usage.refetch() + } + setPrevStatus(currentType) + }, + ), + ) + + const usageSections = createMemo(() => { + const entries = usage.data()?.entries ?? [] + return entries.filter( + (entry) => + entry.snapshot.primary || entry.snapshot.secondary || entry.snapshot.tertiary || entry.snapshot.credits, + ) + }) + + const usageErrors = createMemo(() => usage.data()?.errors ?? []) + + // Sort MCP servers alphabetically for consistent display order + const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) + + // Count connected and error MCP servers for collapsed header display + const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length) + const errorMcpCount = createMemo( + () => + mcpEntries().filter( + ([_, item]) => + item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration", + ).length, + ) + return ( {context()?.percentage ?? 0}% used {cost()} spent + 0}> + + setExpanded("usage", !expanded.usage)}> + {expanded.usage ? "▼" : "▶"} + + Usage + + + + + {(entry, index) => { + const planType = formatPlanType(entry.snapshot.planType) + const entryErrors = usageErrors() + .filter((error) => error.provider === entry.provider) + .map((error) => error.message) + return ( + + + {entry.displayName} + + {` (${planType})`} + + + + {(window) => ( + + {formatUsageWindowLabel(entry.provider, "primary", window().windowMinutes)}{" "} + + {usageBarString(window().usedPercent, 10)} + {" "} + {Math.round(window().usedPercent)}%{" "} + + ({formatUsageResetShort(window().resetsAt)}) + + + )} + + + {(window) => ( + + {formatUsageWindowLabel(entry.provider, "secondary", window().windowMinutes)}{" "} + + {usageBarString(window().usedPercent, 10)} + {" "} + {Math.round(window().usedPercent)}%{" "} + + ({formatUsageResetShort(window().resetsAt)}) + + + )} + + + {(window) => ( + + {formatUsageWindowLabel(entry.provider, "tertiary", window().windowMinutes)}{" "} + + {usageBarString(window().usedPercent, 10)} + {" "} + {Math.round(window().usedPercent)}%{" "} + + ({formatUsageResetShort(window().resetsAt)}) + + + )} + + + {(credits) => ( + {formatCreditsLabel(entry.provider, credits())} + )} + + 0}> + + {entryErrors.join(" • ")} + + + + ) + }} + + + + 0}> { + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + client_id: clientId, + scope, + }), + }) + + if (!response.ok) return null + + const data = (await response.json()) as { + verification_uri: string + user_code: string + device_code: string + interval: number + expires_in?: number + } + + if (!data.device_code || !data.user_code || !data.verification_uri) return null + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + interval: data.interval, + expiresIn: data.expires_in ?? 0, + } +} + +async function pollCopilotDeviceToken( + url: string, + device: CopilotDeviceCode, + clientId: string, + signal?: AbortSignal, +): Promise { + const deadline = device.expiresIn > 0 ? Date.now() + device.expiresIn * 1000 : null + + const poll = async (intervalMs: number): Promise => { + if (signal?.aborted) return null + if (deadline && Date.now() >= deadline) return null + + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + client_id: clientId, + device_code: device.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }) + + if (!response.ok) return null + + const data = (await response.json()) as { + access_token?: string + error?: string + interval?: number + } + + if (data.access_token) return data.access_token + if (data.error === "authorization_pending") { + await Bun.sleep(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS) + return poll(intervalMs) + } + + if (data.error === "slow_down") { + const newInterval = iife(() => { + const serverInterval = data.interval + if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { + return serverInterval * 1000 + } + return (device.interval + 5) * 1000 + }) + await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) + return poll(newInterval) + } + + if (data.error === "expired_token") return null + if (data.error) return null + + await Bun.sleep(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS) + return poll(intervalMs) + } + + return poll(device.interval * 1000) +} + +type CopilotServiceTokenResponse = { + token?: string + access_token?: string + refresh_token?: string + expires_at?: number | string +} + +type CopilotServiceToken = { + access: string + refresh: string + expires: number +} + +async function fetchCopilotServiceToken(url: string, accessToken: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `token ${accessToken}`, + Accept: "application/vnd.github+json", + "Editor-Version": "vscode/1.96.2", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + "User-Agent": `opencode/${Installation.VERSION}`, + "X-Github-Api-Version": "2025-04-01", + }, + signal: AbortSignal.timeout(10_000), + }).catch((error) => { + log.warn("copilot service token request failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + }) + + if (!response) return null + if (!response.ok) { + const detail = await response.json().catch(() => null) + log.warn("copilot service token request rejected", { + status: response.status, + detail, + }) + return null + } + + const body = (await response.json().catch(() => null)) as CopilotServiceTokenResponse | null + if (!body) { + log.warn("copilot service token response empty") + return null + } + + const token = iife(() => { + if (typeof body.token === "string") return body.token + if (typeof body.access_token === "string") return body.access_token + return null + }) + if (!token) { + log.warn("copilot service token missing") + return null + } + + const refresh = typeof body.refresh_token === "string" ? body.refresh_token : token + const expires = iife(() => { + const value = body.expires_at + if (typeof value === "number") return value + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10) + if (!Number.isNaN(parsed)) return parsed + const ms = new Date(value).getTime() + if (!Number.isNaN(ms)) return Math.floor(ms / 1000) + } + return 0 + }) + + return { + access: token, + refresh, + expires, } } @@ -194,107 +393,86 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const urls = getUrls(domain) - const deviceResponse = await fetch(urls.DEVICE_CODE_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - scope: "read:user", - }), - }) + const deviceData = await requestCopilotDeviceCode(urls.DEVICE_CODE_URL, CLIENT_ID, "read:user") + if (!deviceData) throw new Error("Failed to initiate device authorization") - if (!deviceResponse.ok) { - throw new Error("Failed to initiate device authorization") - } + const usageDeviceData = await requestCopilotDeviceCode( + urls.DEVICE_CODE_URL, + COPILOT_USAGE_CLIENT_ID, + COPILOT_USAGE_SCOPE, + ).catch(() => null) - const deviceData = (await deviceResponse.json()) as { - verification_uri: string - user_code: string - device_code: string - interval: number + if (!usageDeviceData) { + log.warn("copilot usage device authorization unavailable") } + const instructions = iife(() => { + if (!usageDeviceData) return `Enter code: ${deviceData.userCode}` + return [ + `Enter code: ${deviceData.userCode}`, + "", + "To enable usage tracking, also complete:", + `Go to: ${usageDeviceData.verificationUri}`, + `Enter code: ${usageDeviceData.userCode}`, + ].join("\n") + }) + return { - url: deviceData.verification_uri, - instructions: `Enter code: ${deviceData.user_code}`, + url: deviceData.verificationUri, + instructions, method: "auto" as const, async callback() { - while (true) { - const response = await fetch(urls.ACCESS_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - device_code: deviceData.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }) - - if (!response.ok) return { type: "failed" as const } - - const data = (await response.json()) as { - access_token?: string - error?: string - interval?: number - } - - if (data.access_token) { - const result: { - type: "success" - refresh: string - access: string - expires: number - provider?: string - enterpriseUrl?: string - } = { - type: "success", - refresh: data.access_token, - access: data.access_token, - expires: 0, - } - - if (actualProvider === "github-copilot-enterprise") { - result.provider = "github-copilot-enterprise" - result.enterpriseUrl = domain - } - - return result - } - - if (data.error === "authorization_pending") { - await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) - continue - } - - if (data.error === "slow_down") { - // Based on the RFC spec, we must add 5 seconds to our current polling interval. - // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) - let newInterval = (deviceData.interval + 5) * 1000 - - // GitHub OAuth API may return the new interval in seconds in the response. - // We should try to use that if provided with safety margin. - const serverInterval = data.interval - if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { - newInterval = serverInterval * 1000 - } - - await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) - continue - } - - if (data.error) return { type: "failed" as const } + const usageController = usageDeviceData ? new AbortController() : null + const usagePromise = usageDeviceData + ? pollCopilotDeviceToken( + urls.ACCESS_TOKEN_URL, + usageDeviceData, + COPILOT_USAGE_CLIENT_ID, + usageController?.signal, + ) + : Promise.resolve(null) + + const deviceToken = await pollCopilotDeviceToken(urls.ACCESS_TOKEN_URL, deviceData, CLIENT_ID) + if (!deviceToken) return { type: "failed" as const } + + const usageToken = await iife(async () => { + if (!usageDeviceData) return null + const timeout = Bun.sleep(COPILOT_USAGE_TIMEOUT_MS).then(() => ({ type: "timeout" as const })) + const result = await Promise.race([ + usagePromise.then((token) => ({ type: "token" as const, token })), + timeout, + ]) + if (result.type === "token") return result.token + usageController?.abort() + return null + }) + + const serviceToken = await fetchCopilotServiceToken(urls.COPILOT_TOKEN_URL, deviceToken) + const accessToken = serviceToken?.access ?? deviceToken + const refreshToken = serviceToken?.refresh ?? accessToken + const expires = serviceToken?.expires ?? 0 + const result: { + type: "success" + refresh: string + access: string + expires: number + usage?: string + provider?: string + enterpriseUrl?: string + } = { + type: "success", + refresh: refreshToken, + access: accessToken, + expires, + ...(usageToken ? { usage: usageToken } : {}), + } - await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) - continue + if (actualProvider === "github-copilot-enterprise") { + result.provider = "github-copilot-enterprise" + result.enterpriseUrl = domain } + + return result }, } }, diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e6681ff0891..3864f219e0d 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -108,6 +108,12 @@ export namespace ProviderAuth { if (result.accountId) { info.accountId = result.accountId } + if (result.enterpriseUrl) { + info.enterpriseUrl = result.enterpriseUrl + } + if (result.usage) { + info.usage = result.usage + } await Auth.set(input.providerID, info) } return diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 015553802a4..fe97b91b511 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -39,6 +39,7 @@ import { errors } from "./error" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { UsageRoutes } from "./usage" import { MDNS } from "./mdns" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 @@ -223,6 +224,7 @@ export namespace Server { .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) + .route("/usage", UsageRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) .route("/mcp", McpRoutes()) diff --git a/packages/opencode/src/server/usage.ts b/packages/opencode/src/server/usage.ts new file mode 100644 index 00000000000..343eb42f500 --- /dev/null +++ b/packages/opencode/src/server/usage.ts @@ -0,0 +1,206 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Auth } from "../auth" +import { Usage } from "../usage" +import type { Snapshot as UsageSnapshot } from "../usage" + +const USAGE_CACHE_TTL_MS = 5 * 60 * 1000 + +const usageResponseSchema = z.object({ + entries: z.array( + z.object({ + provider: z.string(), + displayName: z.string(), + snapshot: Usage.snapshotSchema, + }), + ), + error: z.string().optional(), + errors: z + .array( + z.object({ + provider: z.string(), + message: z.string(), + }), + ) + .optional(), +}) + +const refreshSchema = z.preprocess((value) => { + if (typeof value === "boolean") return value + if (typeof value !== "string") return value + const normalized = value.trim().toLowerCase() + if (normalized === "true" || normalized === "1") return true + if (normalized === "false" || normalized === "0") return false + return undefined +}, z.boolean()) + +export function UsageRoutes() { + return new Hono().get( + "/", + describeRoute({ + summary: "Get usage", + description: "Fetch usage limits for authenticated providers.", + operationId: "usage.get", + responses: { + 200: { + description: "Usage response", + content: { + "application/json": { + schema: resolver(usageResponseSchema), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + provider: z.string().optional(), + refresh: refreshSchema.optional(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const providerInput = query.provider?.trim() + const refresh = query.refresh ?? false + const resolved = providerInput ? Usage.resolveProvider(providerInput) : null + if (providerInput && !resolved) { + return c.json({ + entries: [], + error: `Unknown provider: "${providerInput}"`, + }) + } + + const auth = await Auth.all() + const providers = resolved ? [resolved] : await Usage.getAuthenticatedProviders(auth) + if (providers.length === 0) { + return c.json({ + entries: [], + error: "No OAuth providers with usage tracking are authenticated. Run: opencode auth login", + }) + } + + const providerTasks = providers.map(async (provider) => { + const errors: string[] = [] + const providerErrors: Array<{ provider: string; message: string }> = [] + const pushError = (message: string) => { + errors.push(message) + providerErrors.push({ provider, message }) + } + + const info = Usage.getProviderInfo(provider) + if (!info) { + pushError(`Provider "${provider}" does not support usage tracking.`) + return { provider, entry: null, errors, providerErrors } + } + + const authEntry = await Usage.getProviderAuth(provider, auth) + if (!authEntry) { + pushError(`Not authenticated with ${info.displayName}. Run: opencode auth add ${info.authKeys[0]}`) + return { provider, entry: null, errors, providerErrors } + } + const oauthAuth = authEntry.auth.type === "oauth" ? authEntry.auth : null + if (info.requiresOAuth && !oauthAuth) { + pushError(`Not authenticated with ${info.displayName} OAuth. Run: opencode auth add ${info.authKeys[0]}`) + return { provider, entry: null, errors, providerErrors } + } + + const oauth = oauthAuth + if (!oauth) { + pushError(`Missing OAuth access token for ${info.displayName}.`) + return { provider, entry: null, errors, providerErrors } + } + + const accessToken = oauth.access + if (!accessToken) { + pushError(`Missing OAuth access token for ${info.displayName}.`) + return { provider, entry: null, errors, providerErrors } + } + + const isCopilot = provider === "github-copilot" || provider === "github-copilot-enterprise" + if (isCopilot && !oauth.usage) { + pushError("Copilot usage requires a GitHub OAuth device token. Run: opencode auth login") + return { provider, entry: null, errors, providerErrors } + } + + const usageToken = isCopilot ? (oauth.usage ?? null) : accessToken + if (!usageToken) { + pushError(`Missing OAuth access token for ${info.displayName}.`) + return { provider, entry: null, errors, providerErrors } + } + + const cached = await Usage.getUsage(provider) + const stale = !cached || Date.now() - cached.updatedAt > USAGE_CACHE_TTL_MS + const snapshot = await (async () => { + if (!refresh && !stale) return cached + + const fetched = await (async (): Promise<{ snapshot: UsageSnapshot | null; error?: string }> => { + if (isCopilot) { + return Usage.fetchCopilotUsage({ + access: accessToken, + refresh: oauth.refresh, + usage: usageToken, + enterpriseUrl: oauth.enterpriseUrl, + }) + } + if (provider === "anthropic") { + return Usage.fetchClaudeUsage(authEntry.key, oauth) + } + return Usage.fetchChatgptUsage(accessToken, oauth.accountId) + })() + + const fetchedSnapshot = fetched.snapshot + const detail = fetched.error ?? `Unable to refresh usage data for ${info.displayName}.` + + if (fetched.error) { + if (cached) { + pushError(`${detail} Showing cached results.`) + return cached + } + if (fetchedSnapshot) { + pushError(detail) + await Usage.updateUsage(provider, fetchedSnapshot) + return fetchedSnapshot + } + pushError(detail) + return null + } + + if (!fetchedSnapshot) { + if (cached) { + pushError(`${detail} Showing cached results.`) + return cached + } + pushError(detail) + return null + } + + return Usage.updateUsage(provider, fetchedSnapshot) + })() + + if (!snapshot) { + return { provider, entry: null, errors, providerErrors } + } + + return { + provider, + entry: { provider, displayName: info.displayName, snapshot }, + errors, + providerErrors, + } + }) + + const results = await Promise.all(providerTasks) + const entries = results.flatMap((result) => (result.entry ? [result.entry] : [])) + const errors = results.flatMap((result) => result.errors) + const providerErrors = results.flatMap((result) => result.providerErrors) + + return c.json({ + entries, + error: errors.length > 0 ? errors.join("\n") : undefined, + errors: providerErrors.length > 0 ? providerErrors : undefined, + }) + }, + ) +} diff --git a/packages/opencode/src/usage/index.ts b/packages/opencode/src/usage/index.ts new file mode 100644 index 00000000000..a2eeb814a4d --- /dev/null +++ b/packages/opencode/src/usage/index.ts @@ -0,0 +1,832 @@ +import z from "zod" +import { Auth } from "../auth" +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Storage } from "../storage/storage" +import { Log } from "../util/log" +import { iife } from "../util/iife" +import { getUsageProviderInfo, isUsageProvider, listUsageProviders, type UsageProviderInfo } from "./registry" + +const log = Log.create({ service: "usage" }) + +export const planTypeSchema = z.enum([ + "guest", + "free", + "go", + "plus", + "pro", + "free_workspace", + "team", + "business", + "education", + "quorum", + "k12", + "enterprise", + "edu", +]) +export type PlanType = z.infer + +export const rateLimitWindowSchema = z.object({ + usedPercent: z.number(), + windowMinutes: z.number().nullable(), + resetsAt: z.number().nullable(), +}) +export type RateLimitWindow = z.infer + +export const creditsSnapshotSchema = z.object({ + hasCredits: z.boolean(), + unlimited: z.boolean(), + balance: z.string().nullable(), +}) +export type CreditsSnapshot = z.infer + +export const snapshotSchema = z.object({ + primary: rateLimitWindowSchema.nullable(), + secondary: rateLimitWindowSchema.nullable(), + tertiary: rateLimitWindowSchema.nullable(), + credits: creditsSnapshotSchema.nullable(), + planType: planTypeSchema.nullable(), + updatedAt: z.number(), +}) +export type Snapshot = z.infer + +export const UsageEvent = { + Updated: BusEvent.define( + "usage.updated", + z.object({ + provider: z.string(), + snapshot: snapshotSchema, + }), + ), +} + +const chatgptUsageEndpoint = "https://chatgpt.com/backend-api/wham/usage" +const copilotUsageEndpoint = "https://api.github.com/copilot_internal/user" +const claudeUsageEndpoint = "https://api.anthropic.com/api/oauth/usage" +const claudeTokenEndpoint = "https://console.anthropic.com/v1/oauth/token" +const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + +export async function getUsage(provider: string): Promise { + return Storage.read(storageKey(provider)).catch(() => null) +} + +export async function updateUsage(provider: string, update: Partial): Promise { + const existing = await getUsage(provider) + const primary = update.primary !== undefined ? update.primary : (existing?.primary ?? null) + const secondary = update.secondary !== undefined ? update.secondary : (existing?.secondary ?? null) + const tertiary = update.tertiary !== undefined ? update.tertiary : (existing?.tertiary ?? null) + const credits = update.credits !== undefined ? update.credits : (existing?.credits ?? null) + const planType = update.planType !== undefined ? update.planType : (existing?.planType ?? null) + const snapshot: Snapshot = { + primary, + secondary, + tertiary, + credits, + planType, + updatedAt: Date.now(), + } + + await Storage.write(storageKey(provider), snapshot).catch((error) => { + log.debug("usage write failed", { provider, error }) + }) + + await Bus.publish(UsageEvent.Updated, { provider, snapshot }).catch((error) => { + log.debug("usage publish failed", { provider, error }) + }) + + return snapshot +} + +export async function clearUsage(provider: string): Promise { + await Storage.remove(storageKey(provider)) +} + +export function resolveProvider(input: string): string | null { + const normalized = input.trim().toLowerCase() + if (isUsageProvider(normalized)) return normalized + return null +} + +export function getProviderInfo(provider: string): UsageProviderInfo | null { + return getUsageProviderInfo(provider) +} + +export async function getAuthenticatedProviders(auth?: Record): Promise { + const entries = auth ?? (await Auth.all()) + const providers = listUsageProviders() + const result: string[] = [] + + for (const provider of providers) { + const matched = provider.authKeys.some((key) => { + const providerAuth = entries[key] + if (!providerAuth) return false + if (provider.requiresOAuth && providerAuth.type !== "oauth") return false + return true + }) + if (matched) result.push(provider.id) + } + + return result +} + +export async function getProviderAuth( + provider: string, + auth?: Record, +): Promise<{ key: string; auth: Auth.Info } | null> { + const info = getUsageProviderInfo(provider) + if (!info) return null + const entries = auth ?? (await Auth.all()) + + for (const key of info.authKeys) { + const providerAuth = entries[key] + if (!providerAuth) continue + if (info.requiresOAuth && providerAuth.type !== "oauth") continue + return { key, auth: providerAuth } + } + + return null +} + +type UsageFetchResult = { + snapshot: Snapshot | null + error?: string +} + +function usageFetchError(provider: string, detail: string | null): string { + if (!detail) return `${provider} usage request failed` + return `${provider} usage request failed (${detail})` +} + +export async function fetchChatgptUsage(accessToken: string, accountId?: string): Promise { + const headers = iife(() => { + const base = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + } + if (!accountId) return base + return { ...base, "ChatGPT-Account-Id": accountId } + }) + + const response = await fetch(chatgptUsageEndpoint, { + headers, + signal: AbortSignal.timeout(10_000), + }).catch((error) => { + log.warn("usage fetch failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + }) + + if (!response) { + return { + snapshot: null, + error: usageFetchError("OpenAI ChatGPT", "network"), + } + } + + if (!response.ok) { + log.warn("usage fetch failed", { status: response.status }) + return { + snapshot: null, + error: usageFetchError("OpenAI ChatGPT", String(response.status)), + } + } + + const body = await response.json().catch(() => null) + if (!body) { + return { + snapshot: null, + error: usageFetchError("OpenAI ChatGPT", "empty response"), + } + } + + const parsed = chatgptUsageResponseSchema.safeParse(body) + if (!parsed.success) { + log.warn("usage fetch parse failed", { issues: parsed.error.issues.length }) + return { + snapshot: null, + error: usageFetchError("OpenAI ChatGPT", "parse failed"), + } + } + + const rateLimit = parsed.data.rate_limit + const primary = toChatgptRateLimitWindow(rateLimit.primary_window) + const secondary = toChatgptRateLimitWindow(rateLimit.secondary_window) + const credits = toChatgptCreditsSnapshot(parsed.data.credits) + const planType = toChatgptPlanType(parsed.data.plan_type) + + return { + snapshot: { + primary, + secondary, + tertiary: null, + credits, + planType, + updatedAt: Date.now(), + }, + } +} + +type ClaudeUsageWindow = { + utilization: number + resets_at: string | null +} + +type ClaudeUsageResponse = { + five_hour?: ClaudeUsageWindow | null + seven_day?: ClaudeUsageWindow | null + extra_usage?: { + is_enabled?: boolean | null + monthly_limit?: number | null + used_credits?: number | null + utilization?: number | null + } | null +} + +type ClaudeAuth = Extract + +type ClaudeTokenResponse = { + access_token?: string + refresh_token?: string + expires_in?: number +} + +type ClaudeErrorResponse = { + error?: { + message?: string + details?: { + error_code?: string + } + } +} + +const claudeUsageWindowSchema = z.object({ + utilization: z.number(), + resets_at: z.string().nullable(), +}) + +const claudeUsageResponseSchema = z.object({ + five_hour: claudeUsageWindowSchema.nullish(), + seven_day: claudeUsageWindowSchema.nullish(), + extra_usage: z + .object({ + is_enabled: z.boolean().nullish(), + monthly_limit: z.number().nullish(), + used_credits: z.number().nullish(), + utilization: z.number().nullish(), + }) + .nullish(), +}) satisfies z.ZodType + +export async function fetchClaudeUsage(authKey: string, auth: ClaudeAuth): Promise { + return fetchClaudeUsageInternal(authKey, auth, false) +} + +async function fetchClaudeUsageInternal( + authKey: string, + auth: ClaudeAuth, + refreshed: boolean, +): Promise { + const currentAuth = await iife(async () => { + if (auth.expires > 0 && Date.now() >= auth.expires) { + return refreshClaudeAuth(authKey, auth) + } + return auth + }) + + if (!currentAuth) { + return { + snapshot: null, + error: usageFetchError("Claude", "token expired"), + } + } + + const response = await requestClaudeUsage(currentAuth.access) + if (!response) { + return { + snapshot: null, + error: usageFetchError("Claude", "network"), + } + } + + if (response.status === 401) { + const body = (await response.json().catch(() => null)) as ClaudeErrorResponse | null + const expired = isClaudeTokenExpired(body) + if (expired && !refreshed) { + const nextAuth = await refreshClaudeAuth(authKey, currentAuth) + if (!nextAuth) { + return { + snapshot: null, + error: usageFetchError("Claude", String(response.status)), + } + } + return fetchClaudeUsageInternal(authKey, nextAuth, true) + } + + log.warn("claude usage fetch failed", { status: response.status }) + return { + snapshot: null, + error: usageFetchError("Claude", String(response.status)), + } + } + + if (!response.ok) { + log.warn("claude usage fetch failed", { status: response.status }) + return { + snapshot: null, + error: usageFetchError("Claude", String(response.status)), + } + } + + const body = await response.json().catch(() => null) + if (!body) { + return { + snapshot: null, + error: usageFetchError("Claude", "empty response"), + } + } + + const parsed = claudeUsageResponseSchema.safeParse(body) + if (!parsed.success) { + log.warn("claude usage parse failed", { issues: parsed.error.issues.length }) + return { + snapshot: null, + error: usageFetchError("Claude", "parse failed"), + } + } + + const data = parsed.data + const primary = toClaudeWindow(data.five_hour, 5 * 60) + const secondary = toClaudeWindow(data.seven_day, 7 * 24 * 60) + const credits = toClaudeCredits(data.extra_usage) + + return { + snapshot: { + primary, + secondary, + tertiary: null, + credits, + planType: null, + updatedAt: Date.now(), + }, + } +} + +async function requestClaudeUsage(accessToken: string): Promise { + return fetch(claudeUsageEndpoint, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + Accept: "application/json", + }, + signal: AbortSignal.timeout(10_000), + }).catch((error) => { + log.warn("claude usage fetch failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + }) +} + +function isClaudeTokenExpired(body: ClaudeErrorResponse | null): boolean { + if (!body || typeof body !== "object") return false + const error = body.error + if (!error) return false + const code = error.details?.error_code + if (code === "token_expired") return true + if (error.message?.toLowerCase().includes("expired")) return true + return false +} + +async function refreshClaudeAuth(authKey: string, auth: ClaudeAuth): Promise { + const response = await fetch(claudeTokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: auth.refresh, + client_id: CLAUDE_CLIENT_ID, + }), + signal: AbortSignal.timeout(10_000), + }).catch((error) => { + log.warn("claude token refresh failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + }) + + if (!response) return null + if (!response.ok) { + log.warn("claude token refresh failed", { status: response.status }) + return null + } + + const body = (await response.json().catch(() => null)) as ClaudeTokenResponse | null + if (!body) return null + + const access = typeof body.access_token === "string" ? body.access_token : null + if (!access) return null + + const refresh = typeof body.refresh_token === "string" ? body.refresh_token : auth.refresh + const expires = typeof body.expires_in === "number" ? Date.now() + body.expires_in * 1000 : auth.expires + const next: Auth.Info = { + type: "oauth", + refresh, + access, + expires, + ...(auth.accountId ? { accountId: auth.accountId } : {}), + ...(auth.enterpriseUrl ? { enterpriseUrl: auth.enterpriseUrl } : {}), + ...(auth.usage ? { usage: auth.usage } : {}), + } + + await Auth.set(authKey, next) + return next +} + +function storageKey(provider: string): string[] { + return ["usage", provider] +} + +function clampPercent(value: number): number { + if (Number.isNaN(value)) return 0 + if (value < 0) return 0 + if (value > 100) return 100 + return value +} + +type ChatgptUsageResponseWindow = { + used_percent: number + limit_window_seconds: number + reset_after_seconds: number + reset_at: number +} + +type ChatgptUsageResponse = { + plan_type: string | null + rate_limit: { + allowed: boolean + limit_reached: boolean + primary_window: ChatgptUsageResponseWindow | null + secondary_window: ChatgptUsageResponseWindow | null + } + credits: { + has_credits: boolean + unlimited: boolean + balance: string | null + } | null +} + +const chatgptUsageResponseWindowSchema = z.object({ + used_percent: z.number(), + limit_window_seconds: z.number(), + reset_after_seconds: z.number(), + reset_at: z.number(), +}) + +const chatgptUsageResponseSchema = z.object({ + plan_type: z.string().nullable(), + rate_limit: z.object({ + allowed: z.boolean(), + limit_reached: z.boolean(), + primary_window: chatgptUsageResponseWindowSchema.nullable(), + secondary_window: chatgptUsageResponseWindowSchema.nullable(), + }), + credits: z + .object({ + has_credits: z.boolean(), + unlimited: z.boolean(), + balance: z.string().nullable(), + }) + .nullable(), +}) satisfies z.ZodType + +function toChatgptRateLimitWindow(window: ChatgptUsageResponseWindow | null): RateLimitWindow | null { + if (!window) return null + return { + usedPercent: window.used_percent, + windowMinutes: Math.round(window.limit_window_seconds / 60), + resetsAt: window.reset_at, + } +} + +function toChatgptCreditsSnapshot(credits: ChatgptUsageResponse["credits"]): CreditsSnapshot | null { + if (!credits) return null + return { + hasCredits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance, + } +} + +function toChatgptPlanType(value: ChatgptUsageResponse["plan_type"]): PlanType | null { + if (!value) return null + const parsed = planTypeSchema.safeParse(value) + if (!parsed.success) return null + return parsed.data +} + +function toClaudeWindow( + window: ClaudeUsageWindow | null | undefined, + windowMinutes: number | null, +): RateLimitWindow | null { + if (!window) return null + + const resetsAt = iife(() => { + if (!window.resets_at) return null + const ms = new Date(window.resets_at).getTime() + if (Number.isNaN(ms)) return null + return Math.floor(ms / 1000) + }) + + return { + usedPercent: window.utilization, + windowMinutes, + resetsAt, + } +} + +function toClaudeCredits(extra: ClaudeUsageResponse["extra_usage"]): CreditsSnapshot | null { + if (!extra) return null + if (extra.is_enabled === false) return null + + const limit = typeof extra.monthly_limit === "number" ? extra.monthly_limit : null + const used = typeof extra.used_credits === "number" ? extra.used_credits : null + if (limit !== null && used !== null) { + const remaining = Math.max(0, Math.round((limit - used) * 100) / 100) + return { + hasCredits: remaining > 0, + unlimited: false, + balance: String(remaining), + } + } + + const utilization = typeof extra.utilization === "number" ? extra.utilization : null + if (utilization !== null) { + return { + hasCredits: utilization < 100, + unlimited: false, + balance: null, + } + } + + return null +} + +type CopilotTokenMetadata = { + tid?: string + exp?: number + sku?: string + proxyEndpoint?: string + quotaLimit?: number + resetDate?: number +} + +const COPILOT_SKU_PLAN_MAP: Record = { + free_limited_copilot: "free", + copilot_for_individual: "pro", + copilot_individual: "pro", + copilot_business: "business", + copilot_enterprise: "enterprise", + copilot_for_business: "business", +} + +const copilotUsageQuotaSchema = z.object({ + entitlement: z.number(), + remaining: z.number(), + percent_remaining: z.number(), + quota_id: z.string(), +}) + +const copilotUsageResponseSchema = z.object({ + quota_snapshots: z.object({ + premium_interactions: copilotUsageQuotaSchema.nullish(), + chat: copilotUsageQuotaSchema.nullish(), + }), + copilot_plan: z.string().optional(), + assigned_date: z.string().optional(), + quota_reset_date: z.string().optional(), +}) + +export function parseCopilotAccessToken(accessToken: string): CopilotTokenMetadata { + const result: CopilotTokenMetadata = {} + const parts = accessToken.split(";") + + for (const part of parts) { + const eqIndex = part.indexOf("=") + if (eqIndex === -1) continue + const key = part.slice(0, eqIndex) + const value = part.slice(eqIndex + 1) + + switch (key) { + case "tid": + result.tid = value + break + case "exp": + result.exp = Number.parseInt(value, 10) + break + case "sku": + result.sku = value + break + case "proxy-ep": + result.proxyEndpoint = value + break + case "cq": + result.quotaLimit = Number.parseInt(value, 10) + break + case "rd": { + const colonIdx = value.indexOf(":") + if (colonIdx > 0) { + result.resetDate = Number.parseInt(value.slice(0, colonIdx), 10) + } + break + } + } + } + + return result +} + +export function copilotSkuToPlan(sku: string | undefined): PlanType | null { + if (!sku) return null + return COPILOT_SKU_PLAN_MAP[sku] ?? copilotSkuToPlanType(sku) +} + +function resolveCopilotUsageUrl(enterpriseUrl: string | undefined): string { + if (!enterpriseUrl) return copilotUsageEndpoint + const base = enterpriseUrl.startsWith("http") ? enterpriseUrl : `https://${enterpriseUrl}` + const trimmed = base.replace(/\/$/, "") + if (trimmed.endsWith("/api/v3")) return `${trimmed}/copilot_internal/user` + return `${trimmed}/api/v3/copilot_internal/user` +} + +function parseCopilotResetDate(value: string | undefined): number | null { + if (!value) return null + const ms = new Date(value).getTime() + if (Number.isNaN(ms)) return null + return Math.floor(ms / 1000) +} + +function copilotSnapshotFromToken(tokenMetadata: CopilotTokenMetadata): Snapshot | null { + const planType = copilotSkuToPlan(tokenMetadata.sku) + const credits = tokenMetadata.quotaLimit + ? { + hasCredits: true, + unlimited: false, + balance: String(tokenMetadata.quotaLimit), + } + : null + + if (!credits && !planType) return null + + return { + primary: null, + secondary: null, + tertiary: null, + credits, + planType, + updatedAt: Date.now(), + } +} + +type CopilotAuthInfo = { + access: string + refresh: string + usage: string + enterpriseUrl?: string +} + +export async function fetchCopilotUsage(auth: CopilotAuthInfo): Promise { + const tokenMetadata = parseCopilotAccessToken(auth.access) + const fallback = copilotSnapshotFromToken(tokenMetadata) + + const response = await fetch(resolveCopilotUsageUrl(auth.enterpriseUrl), { + method: "GET", + headers: { + Authorization: `token ${auth.usage}`, + Accept: "application/json", + "Editor-Version": "vscode/1.96.2", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + signal: AbortSignal.timeout(10_000), + }).catch((error) => { + log.warn("copilot usage fetch failed", { + error: error instanceof Error ? error.message : String(error), + }) + return null + }) + + if (!response) { + return { + snapshot: fallback, + error: usageFetchError("Copilot", "network"), + } + } + + if (!response.ok) { + log.warn("copilot usage fetch failed", { status: response.status }) + return { + snapshot: fallback, + error: usageFetchError("Copilot", String(response.status)), + } + } + + const body = await response.json().catch(() => null) + if (!body) { + return { + snapshot: fallback, + error: usageFetchError("Copilot", "empty response"), + } + } + + const parsed = copilotUsageResponseSchema.safeParse(body) + if (!parsed.success) { + log.warn("copilot usage parse failed", { issues: parsed.error.issues.length }) + return { + snapshot: fallback, + error: usageFetchError("Copilot", "parse failed"), + } + } + + const data = parsed.data + const premium = data.quota_snapshots.premium_interactions ?? null + const chat = data.quota_snapshots.chat ?? null + const resetAt = parseCopilotResetDate(data.quota_reset_date) ?? tokenMetadata.resetDate ?? null + const planType = copilotSkuToPlan(data.copilot_plan) ?? copilotSkuToPlan(tokenMetadata.sku) + + const primary: RateLimitWindow | null = iife(() => { + if (!premium) return null + return { + usedPercent: clampPercent(100 - premium.percent_remaining), + windowMinutes: null, + resetsAt: resetAt, + } + }) + + const secondary: RateLimitWindow | null = iife(() => { + if (!chat) return null + return { + usedPercent: clampPercent(100 - chat.percent_remaining), + windowMinutes: null, + resetsAt: resetAt, + } + }) + + const quotaRemaining = iife(() => { + if (premium?.remaining !== undefined) return premium.remaining + if (chat?.remaining !== undefined) return chat.remaining + return tokenMetadata.quotaLimit ?? null + }) + + const credits = + quotaRemaining !== null + ? { + hasCredits: quotaRemaining > 0, + unlimited: false, + balance: String(quotaRemaining), + } + : null + + return { + snapshot: { + primary, + secondary, + tertiary: null, + credits, + planType, + updatedAt: Date.now(), + }, + } +} + +function copilotSkuToPlanType(sku: string): PlanType | null { + const normalized = sku.toLowerCase() + if (normalized.includes("free")) return "free" + if (normalized.includes("individual") || normalized.includes("pro")) return "pro" + if (normalized.includes("business")) return "business" + if (normalized.includes("enterprise")) return "enterprise" + return null +} + +export const Usage = { + planTypeSchema, + rateLimitWindowSchema, + creditsSnapshotSchema, + snapshotSchema, + getUsage, + updateUsage, + clearUsage, + resolveProvider, + getProviderInfo, + getAuthenticatedProviders, + getProviderAuth, + fetchChatgptUsage, + fetchClaudeUsage, + fetchCopilotUsage, + parseCopilotAccessToken, + copilotSkuToPlan, +} as const diff --git a/packages/opencode/src/usage/registry.ts b/packages/opencode/src/usage/registry.ts new file mode 100644 index 00000000000..8ec8aeefcdf --- /dev/null +++ b/packages/opencode/src/usage/registry.ts @@ -0,0 +1,44 @@ +export type UsageProviderInfo = { + authKeys: readonly string[] + displayName: string + requiresOAuth: boolean +} + +export const usageProviders = { + openai: { + authKeys: ["openai"], + displayName: "OpenAI ChatGPT", + requiresOAuth: true, + }, + "github-copilot": { + authKeys: ["github-copilot"], + displayName: "GitHub Copilot", + requiresOAuth: true, + }, + "github-copilot-enterprise": { + authKeys: ["github-copilot-enterprise"], + displayName: "GitHub Copilot Enterprise", + requiresOAuth: true, + }, + anthropic: { + authKeys: ["anthropic"], + displayName: "Anthropic Claude", + requiresOAuth: true, + }, +} as const + +export type UsageProviderId = keyof typeof usageProviders + +export function isUsageProvider(provider: string): provider is UsageProviderId { + return provider in usageProviders +} + +export function getUsageProviderInfo(provider: string): UsageProviderInfo | null { + if (!isUsageProvider(provider)) return null + return usageProviders[provider] +} + +export function listUsageProviders(): Array<{ id: UsageProviderId } & UsageProviderInfo> { + const providers = Object.keys(usageProviders) as UsageProviderId[] + return providers.map((id) => ({ id, ...usageProviders[id] })) +} diff --git a/packages/opencode/test/plugin/copilot-auth.test.ts b/packages/opencode/test/plugin/copilot-auth.test.ts new file mode 100644 index 00000000000..a7db2246416 --- /dev/null +++ b/packages/opencode/test/plugin/copilot-auth.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test" +import { CopilotAuthPlugin } from "../../src/plugin/copilot" +import type { PluginInput } from "@opencode-ai/plugin" + +const CLIENT_ID = "Ov23li8tweQw6odWQebz" +const USAGE_CLIENT_ID = "Iv1.b507a08c87ecfe98" + +function createInput(): PluginInput { + return { + client: { + session: { + get: async () => ({ data: { parentID: null } }), + }, + } as unknown as PluginInput["client"], + project: {} as PluginInput["project"], + directory: "/", + worktree: "/", + serverUrl: new URL("http://localhost"), + $: Bun.$, + } +} + +function mockCopilotDeviceFlow(options: { usageToken?: string | null }) { + const originalFetch = globalThis.fetch + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString() + const body = init?.body ? JSON.parse(init.body.toString()) : {} + + if (url.endsWith("/login/device/code")) { + if (body.client_id === CLIENT_ID) { + return new Response( + JSON.stringify({ + verification_uri: "https://github.com/login/device", + user_code: "MAIN-CODE", + device_code: "main-device", + interval: 1, + expires_in: 600, + }), + { status: 200 }, + ) + } + if (body.client_id === USAGE_CLIENT_ID) { + return new Response( + JSON.stringify({ + verification_uri: "https://github.com/login/device", + user_code: "USAGE-CODE", + device_code: "usage-device", + interval: 1, + expires_in: 600, + }), + { status: 200 }, + ) + } + } + + if (url.endsWith("/login/oauth/access_token")) { + if (body.client_id === CLIENT_ID) { + return new Response(JSON.stringify({ access_token: "device-token" }), { status: 200 }) + } + if (body.client_id === USAGE_CLIENT_ID) { + if (options.usageToken) { + return new Response(JSON.stringify({ access_token: options.usageToken }), { status: 200 }) + } + return new Response(JSON.stringify({ error: "expired_token" }), { status: 200 }) + } + } + + if (url.endsWith("/copilot_internal/v2/token")) { + return new Response(JSON.stringify({ token: "service-token" }), { status: 200 }) + } + + return new Response("", { status: 404 }) + }) as typeof fetch + + return () => { + globalThis.fetch = originalFetch + } +} + +describe("Copilot auth", () => { + test("returns usage token when device flow completes", async () => { + const restore = mockCopilotDeviceFlow({ usageToken: "usage-token" }) + try { + const hooks = await CopilotAuthPlugin(createInput()) + const method = hooks.auth?.methods[0] + const authorize = method?.authorize + if (!authorize) throw new Error("Missing authorize handler") + + const authorizeResult = await authorize({}) + if (!("callback" in authorizeResult)) throw new Error("Expected oauth authorize result") + if (authorizeResult.method !== "auto") throw new Error("Expected auto OAuth flow") + expect(authorizeResult.instructions).toContain("To enable usage tracking") + + const result = await authorizeResult.callback() + if (result.type !== "success") throw new Error("Expected success") + if (!("access" in result)) throw new Error("Expected access token") + + expect(result.access).toBe("service-token") + expect(result.usage).toBe("usage-token") + } finally { + restore() + } + }) + + test("succeeds without usage token when device flow expires", async () => { + const restore = mockCopilotDeviceFlow({ usageToken: null }) + try { + const hooks = await CopilotAuthPlugin(createInput()) + const method = hooks.auth?.methods[0] + const authorize = method?.authorize + if (!authorize) throw new Error("Missing authorize handler") + + const authorizeResult = await authorize({}) + if (!("callback" in authorizeResult)) throw new Error("Expected oauth authorize result") + if (authorizeResult.method !== "auto") throw new Error("Expected auto OAuth flow") + expect(authorizeResult.instructions).toContain("To enable usage tracking") + + const result = await authorizeResult.callback() + if (result.type !== "success") throw new Error("Expected success") + if (!("access" in result)) throw new Error("Expected access token") + + expect(result.access).toBe("service-token") + expect(result.usage).toBeUndefined() + } finally { + restore() + } + }) +}) diff --git a/packages/opencode/test/server/usage.test.ts b/packages/opencode/test/server/usage.test.ts new file mode 100644 index 00000000000..263ee74a508 --- /dev/null +++ b/packages/opencode/test/server/usage.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from "bun:test" +import { Usage, type Snapshot } from "../../src/usage" +import { Auth } from "../../src/auth" +import { Log } from "../../src/util/log" +import { Storage } from "../../src/storage/storage" +import { Server } from "../../src/server/server" +import { Instance } from "../../src/project/instance" + +Log.init({ print: false }) + +const app = Server.App() + +const openaiUsageResponse = { + plan_type: "plus", + rate_limit: { + allowed: true, + limit_reached: false, + primary_window: { + used_percent: 10, + limit_window_seconds: 5 * 60 * 60, + reset_after_seconds: 60, + reset_at: 1_700_000_000, + }, + secondary_window: { + used_percent: 25, + limit_window_seconds: 7 * 24 * 60 * 60, + reset_after_seconds: 120, + reset_at: 1_700_604_800, + }, + }, + credits: { + has_credits: true, + unlimited: false, + balance: "12.34", + }, +} + +function cachedOpenaiSnapshot(): Snapshot { + return { + primary: { usedPercent: 10, windowMinutes: 300, resetsAt: 1_700_000_000 }, + secondary: null, + tertiary: null, + credits: null, + planType: "plus", + updatedAt: Date.now(), + } +} + +async function withInstance(run: () => Promise) { + const originalProvide = Instance.provide + Instance.provide = async (input) => input.fn() + try { + await run() + } finally { + Instance.provide = originalProvide + } +} + +describe("/usage", () => { + test("returns openai usage with org header", async () => { + const originalFetch = globalThis.fetch + const accountId = "acct_123" + const seen = { accountId: "" } + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString() + if (url === "https://chatgpt.com/backend-api/wham/usage") { + const headers = new Headers(init?.headers ?? {}) + seen.accountId = headers.get("ChatGPT-Account-Id") ?? "" + return Promise.resolve(new Response(JSON.stringify(openaiUsageResponse), { status: 200 })) + } + return Promise.resolve(new Response("", { status: 404 })) + }) as typeof fetch + + try { + const result = await Usage.fetchChatgptUsage("codex-token", accountId) + const snapshot = result.snapshot + expect(snapshot?.primary?.usedPercent).toBe(10) + expect(snapshot?.secondary?.usedPercent).toBe(25) + expect(seen.accountId).toBe(accountId) + } finally { + globalThis.fetch = originalFetch + } + }) + + test("refresh=false uses cache without calling fetch", async () => { + const originalFetch = globalThis.fetch + const originalAuthAll = Auth.all + const calls = { count: 0 } + + Auth.all = (async () => ({ + openai: { + type: "oauth" as const, + access: "codex-token", + refresh: "codex-refresh", + expires: 0, + }, + })) as typeof Auth.all + + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = input.toString() + if (url === "https://chatgpt.com/backend-api/wham/usage") { + calls.count += 1 + } + return Promise.resolve(new Response("", { status: 404 })) + }) as typeof fetch + + try { + await withInstance(async () => { + await Usage.clearUsage("openai") + await Storage.write(["usage", "openai"], cachedOpenaiSnapshot()) + + const response = await app.request("/usage?provider=openai&refresh=false") + expect(response.status).toBe(200) + const body = (await response.json()) as { entries: Array<{ snapshot: { planType: string | null } }> } + expect(body.entries.length).toBe(1) + expect(body.entries[0].snapshot.planType).toBe("plus") + expect(calls.count).toBe(0) + }) + } finally { + globalThis.fetch = originalFetch + Auth.all = originalAuthAll + } + }) + + test("copilot uses copilot_internal usage with reset date", async () => { + const originalFetch = globalThis.fetch + const originalAuthAll = Auth.all + const resetDate = "2026-02-01T00:00:00Z" + const resetAt = Math.floor(new Date(resetDate).getTime() / 1000) + const seen = { auth: "" } + + Auth.all = (async () => ({ + "github-copilot": { + type: "oauth" as const, + access: "copilot-token", + refresh: "copilot-token", + usage: "copilot-usage-token", + expires: 0, + }, + })) as typeof Auth.all + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString() + if (url === "https://api.github.com/copilot_internal/user") { + const headers = new Headers(init?.headers ?? {}) + seen.auth = headers.get("Authorization") ?? "" + return Promise.resolve( + new Response( + JSON.stringify({ + quota_snapshots: { + premium_interactions: { + entitlement: 100, + remaining: 40, + percent_remaining: 40, + quota_id: "premium", + }, + chat: { + entitlement: 200, + remaining: 50, + percent_remaining: 25, + quota_id: "chat", + }, + }, + copilot_plan: "copilot_for_individual", + quota_reset_date: resetDate, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response("", { status: 404 })) + }) as typeof fetch + + try { + await withInstance(async () => { + await Usage.clearUsage("github-copilot") + const response = await app.request("/usage?provider=github-copilot") + expect(response.status).toBe(200) + const body = (await response.json()) as { + entries: Array<{ snapshot: Snapshot }> + } + expect(body.entries.length).toBe(1) + const snapshot = body.entries[0].snapshot + expect(snapshot.primary?.usedPercent).toBe(60) + expect(snapshot.secondary?.usedPercent).toBe(75) + expect(snapshot.primary?.resetsAt).toBe(resetAt) + expect(snapshot.secondary?.resetsAt).toBe(resetAt) + expect(snapshot.credits?.balance).toBe("40") + expect(seen.auth).toBe("token copilot-usage-token") + }) + } finally { + globalThis.fetch = originalFetch + Auth.all = originalAuthAll + } + }) + + test("copilot requires usage token", async () => { + const originalAuthAll = Auth.all + + Auth.all = (async () => ({ + "github-copilot": { + type: "oauth" as const, + access: "copilot-token", + refresh: "copilot-token", + expires: 0, + }, + })) as typeof Auth.all + + try { + await withInstance(async () => { + await Usage.clearUsage("github-copilot") + const response = await app.request("/usage?provider=github-copilot") + expect(response.status).toBe(200) + const body = (await response.json()) as { entries: Array; error?: string } + expect(body.entries.length).toBe(0) + expect(body.error).toContain("Copilot usage requires a GitHub OAuth device token") + }) + } finally { + Auth.all = originalAuthAll + } + }) + + test("fetch failure returns cached snapshot with error line", async () => { + const originalFetch = globalThis.fetch + const originalAuthAll = Auth.all + + Auth.all = (async () => ({ + openai: { + type: "oauth" as const, + access: "codex-token", + refresh: "codex-refresh", + expires: 0, + }, + })) as typeof Auth.all + + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = input.toString() + if (url === "https://chatgpt.com/backend-api/wham/usage") { + return Promise.resolve(new Response("", { status: 500 })) + } + return Promise.resolve(new Response("", { status: 404 })) + }) as typeof fetch + + try { + await withInstance(async () => { + await Usage.clearUsage("openai") + await Storage.write(["usage", "openai"], cachedOpenaiSnapshot()) + + const response = await app.request("/usage?provider=openai&refresh=true") + expect(response.status).toBe(200) + const body = (await response.json()) as { + entries: Array + error?: string + errors?: Array<{ provider: string; message: string }> + } + expect(body.entries.length).toBe(1) + expect(body.error).toContain("Showing cached results") + expect(body.errors?.[0].provider).toBe("openai") + }) + } finally { + globalThis.fetch = originalFetch + Auth.all = originalAuthAll + } + }) + + test("copilot fallback does not overwrite cache", async () => { + const originalAuthAll = Auth.all + const originalFetch = globalThis.fetch + + Auth.all = (async () => ({ + "github-copilot": { + type: "oauth" as const, + access: "sku=copilot_for_individual;cq=80", + refresh: "copilot-token", + usage: "copilot-usage-token", + expires: 0, + }, + })) as typeof Auth.all + + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = input.toString() + if (url === "https://api.github.com/copilot_internal/user") { + return Promise.resolve(new Response("", { status: 500 })) + } + return Promise.resolve(new Response("", { status: 404 })) + }) as typeof fetch + + try { + await withInstance(async () => { + await Usage.updateUsage("github-copilot", { + primary: { usedPercent: 10, windowMinutes: 60, resetsAt: 1 }, + }) + + const response = await app.request("/usage?provider=github-copilot&refresh=true") + expect(response.status).toBe(200) + const body = (await response.json()) as { + entries: Array<{ snapshot: Snapshot }> + errors?: Array<{ provider: string; message: string }> + } + expect(body.entries[0].snapshot.primary?.usedPercent).toBe(10) + expect(body.errors?.[0].message).toContain("Copilot usage request failed") + + const stored = await Usage.getUsage("github-copilot") + expect(stored?.primary?.usedPercent).toBe(10) + }) + } finally { + globalThis.fetch = originalFetch + Auth.all = originalAuthAll + } + }) + + test("updateUsage overwrites null fields", async () => { + await Usage.clearUsage("openai") + await Usage.updateUsage("openai", { + primary: { usedPercent: 10, windowMinutes: 60, resetsAt: 123 }, + secondary: { usedPercent: 20, windowMinutes: 120, resetsAt: 456 }, + credits: { hasCredits: true, unlimited: false, balance: "50" }, + planType: "plus", + }) + + const updated = await Usage.updateUsage("openai", { + secondary: null, + credits: null, + }) + + expect(updated.secondary).toBeNull() + expect(updated.credits).toBeNull() + const stored = await Usage.getUsage("openai") + expect(stored?.secondary).toBeNull() + expect(stored?.credits).toBeNull() + }) + + test("claude extra usage maps to credits", async () => { + const originalFetch = globalThis.fetch + const auth = { + type: "oauth" as const, + access: "claude-token", + refresh: "claude-refresh", + expires: 0, + } + + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = input.toString() + if (url === "https://api.anthropic.com/api/oauth/usage") { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0, resets_at: null }, + seven_day: { utilization: 50, resets_at: null }, + extra_usage: { + is_enabled: true, + monthly_limit: 2000, + used_credits: 1900, + utilization: 95, + }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response("", { status: 404 })) + }) as typeof fetch + + try { + const result = await Usage.fetchClaudeUsage("anthropic", auth) + const snapshot = result.snapshot + expect(snapshot?.primary?.usedPercent).toBe(0) + expect(snapshot?.secondary?.usedPercent).toBe(50) + expect(snapshot?.credits?.balance).toBe("100") + expect(snapshot?.credits?.hasCredits).toBe(true) + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 4cc84a5f325..b4954756d8e 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -114,7 +114,9 @@ export type AuthOuathResult = { url: string; instructions: string } & ( refresh: string access: string expires: number + usage?: string accountId?: string + enterpriseUrl?: string } | { key: string } )) @@ -134,7 +136,9 @@ export type AuthOuathResult = { url: string; instructions: string } & ( refresh: string access: string expires: number + usage?: string accountId?: string + enterpriseUrl?: string } | { key: string } )) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b757b753507..6f218b61d96 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -162,6 +162,7 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + UsageGetResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -2030,6 +2031,40 @@ export class Question extends HeyApiClient { } } +export class Usage extends HeyApiClient { + /** + * Get usage + * + * Fetch usage limits for authenticated providers. + */ + public get( + parameters?: { + directory?: string + provider?: string + refresh?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "provider" }, + { in: "query", key: "refresh" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/usage", + ...options, + ...params, + }) + } +} + export class Oauth extends HeyApiClient { /** * OAuth authorize @@ -3246,6 +3281,11 @@ export class OpencodeClient extends HeyApiClient { return (this._question ??= new Question({ client: this.client })) } + private _usage?: Usage + get usage(): Usage { + return (this._usage ??= new Usage({ client: this.client })) + } + private _provider?: Provider get provider(): Provider { return (this._provider ??= new Provider({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8050b47d61d..706106aaf09 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -884,6 +884,51 @@ export type EventWorktreeFailed = { } } +export type EventUsageUpdated = { + type: "usage.updated" + properties: { + provider: string + snapshot: { + primary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + secondary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + tertiary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: + | "guest" + | "free" + | "go" + | "plus" + | "pro" + | "free_workspace" + | "team" + | "business" + | "education" + | "quorum" + | "k12" + | "enterprise" + | "edu" + | null + updatedAt: number + } + } +} + export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -927,6 +972,7 @@ export type Event = | EventPtyDeleted | EventWorktreeReady | EventWorktreeFailed + | EventUsageUpdated export type GlobalEvent = { directory: string @@ -1636,6 +1682,10 @@ export type Config = { * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column */ diff_style?: "auto" | "stacked" + /** + * Show usage for the current provider or all providers + */ + show_usage_scope?: "current" | "all" } server?: ServerConfig /** @@ -1831,6 +1881,7 @@ export type OAuth = { type: "oauth" refresh: string access: string + usage?: string expires: number accountId?: string enterpriseUrl?: string @@ -3937,6 +3988,74 @@ export type QuestionRejectResponses = { export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] +export type UsageGetData = { + body?: never + path?: never + query?: { + directory?: string + provider?: string + refresh?: boolean + } + url: "/usage" +} + +export type UsageGetResponses = { + /** + * Usage response + */ + 200: { + entries: Array<{ + provider: string + displayName: string + snapshot: { + primary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + secondary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + tertiary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: + | "guest" + | "free" + | "go" + | "plus" + | "pro" + | "free_workspace" + | "team" + | "business" + | "education" + | "quorum" + | "k12" + | "enterprise" + | "edu" + | null + updatedAt: number + } + }> + error?: string + errors?: Array<{ + provider: string + message: string + }> + } +} + +export type UsageGetResponse = UsageGetResponses[keyof UsageGetResponses] + export type ProviderListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3c70324649f..cf33267cd22 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3595,6 +3595,261 @@ ] } }, + "/usage": { + "get": { + "operationId": "usage.get", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "provider", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "refresh", + "schema": { + "type": "boolean" + } + } + ], + "summary": "Get usage", + "description": "Fetch usage limits for authenticated providers.", + "responses": { + "200": { + "description": "Usage response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "snapshot": { + "type": "object", + "properties": { + "primary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "tertiary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "credits": { + "anyOf": [ + { + "type": "object", + "properties": { + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + }, + "balance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["hasCredits", "unlimited", "balance"] + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "guest", + "free", + "go", + "plus", + "pro", + "free_workspace", + "team", + "business", + "education", + "quorum", + "k12", + "enterprise", + "edu" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "number" + } + }, + "required": ["primary", "secondary", "tertiary", "credits", "planType", "updatedAt"] + } + }, + "required": ["provider", "displayName", "snapshot"] + } + }, + "error": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["provider", "message"] + } + } + }, + "required": ["entries"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.usage.get({\n ...\n})" + } + ] + } + }, "/provider": { "get": { "operationId": "provider.list", @@ -8273,6 +8528,196 @@ }, "required": ["type", "properties"] }, + "Event.usage.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "usage.updated" + }, + "properties": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "snapshot": { + "type": "object", + "properties": { + "primary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "tertiary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "credits": { + "anyOf": [ + { + "type": "object", + "properties": { + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + }, + "balance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["hasCredits", "unlimited", "balance"] + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "guest", + "free", + "go", + "plus", + "pro", + "free_workspace", + "team", + "business", + "education", + "quorum", + "k12", + "enterprise", + "edu" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "number" + } + }, + "required": ["primary", "secondary", "tertiary", "credits", "planType", "updatedAt"] + } + }, + "required": ["provider", "snapshot"] + } + }, + "required": ["type", "properties"] + }, "Event": { "anyOf": [ { @@ -8400,6 +8845,9 @@ }, { "$ref": "#/components/schemas/Event.worktree.failed" + }, + { + "$ref": "#/components/schemas/Event.usage.updated" } ] }, @@ -9498,6 +9946,11 @@ "description": "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", "type": "string", "enum": ["auto", "stacked"] + }, + "show_usage_scope": { + "description": "Show usage for the current provider or all providers", + "type": "string", + "enum": ["current", "all"] } } }, @@ -9924,6 +10377,9 @@ "access": { "type": "string" }, + "usage": { + "type": "string" + }, "expires": { "type": "number" },