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..078780dd4e4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx @@ -0,0 +1,170 @@ +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { For, Show } from "solid-js" + +type Theme = ReturnType["theme"] + +type UsageWindow = { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null +} + +export type UsageEntry = { + provider: string + displayName: string + snapshot: { + primary: UsageWindow | null + secondary: UsageWindow | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: string | null + updatedAt: number + } +} + +export function DialogUsage(props: { entries: UsageEntry[] }) { + const { theme } = useTheme() + + return ( + + + + Usage + + esc + + 0} fallback={No usage data available.}> + + {(entry, index) => { + const mergeReset = entry.provider === "copilot" + const resetAt = entry.snapshot.primary?.resetsAt ?? entry.snapshot.secondary?.resetsAt ?? null + return ( + + + + {entry.displayName} Usage ({formatPlanType(entry.snapshot.planType)} Plan) + + {"─".repeat(Math.max(24, entry.displayName.length + 20))} + + + {(window) => ( + + {renderWindow(getWindowLabel(entry.provider, "primary"), window(), theme, !mergeReset)} + + )} + + + {(window) => ( + + {renderWindow(getWindowLabel(entry.provider, "secondary"), window(), theme, !mergeReset)} + + )} + + + Resets {formatResetTime(resetAt!)} + + + {(credits) => {formatCreditsLabel(entry.provider, credits())}} + + + ) + }} + + + + ) +} + +function getWindowLabel(provider: string, windowType: "primary" | "secondary"): string { + if (provider === "copilot") { + return windowType === "primary" ? "Usage" : "Completions" + } + return windowType === "primary" ? "Hourly" : "Weekly" +} + +function renderWindow(label: string, window: UsageWindow, theme: Theme, showReset = true) { + const usedPercent = clampPercent(window.usedPercent) + const bar = renderProgressBar(usedPercent) + const windowLabel = formatWindowLabel(label, window.windowMinutes) + + return ( + + + {windowLabel} Limit: {bar} {usedPercent.toFixed(0)}% used + + + Resets {formatResetTime(window.resetsAt!)} + + + ) +} + +function formatWindowLabel(base: string, windowMinutes: number | null): string { + if (!windowMinutes) return base + const minutesPerHour = 60 + const minutesPerDay = 24 * minutesPerHour + if (windowMinutes <= minutesPerDay) { + const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour)) + if (hours === 1) return "Hourly" + return `${hours}h` + } + return base +} + +function formatResetTime(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` +} + +function renderProgressBar(usedPercent: number, width = 10): string { + const filled = Math.round((usedPercent / 100) * width) + const empty = width - filled + return `[${"█".repeat(filled)}${"░".repeat(empty)}]` +} + +function formatPlanType(planType: string | null): string { + if (!planType) return "Unknown" + 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(" ") +} + +function formatCreditsLabel( + provider: string, + credits: { hasCredits: boolean; unlimited: boolean; balance: string | null }, +): string { + if (provider === "copilot") { + if (credits.unlimited) return "Quota: Unlimited" + if (credits.balance) return `Quota: ${credits.balance}` + if (!credits.hasCredits) return "Quota: Exhausted" + return "Quota: Available" + } + return `Credits: ${formatCredits(credits)}` +} + +function formatCredits(credits: { hasCredits: boolean; unlimited: boolean; balance: string | null }): string { + if (!credits.hasCredits) return "None" + if (credits.unlimited) return "Unlimited" + if (credits.balance) return credits.balance + return "Available" +} + +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/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 601eb82bc48..cf092a92de2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -73,6 +73,7 @@ export function Autocomplete(props: { fileStyleId: number agentStyleId: number promptPartTypeId: () => number + onUsage: (command: string) => void }) { const sdk = useSDK() const sync = useSync() @@ -444,6 +445,11 @@ export function Autocomplete(props: { description: "show status", onSelect: () => command.trigger("opencode.status"), }, + { + display: "/usage", + description: "show usage limits", + onSelect: () => props.onUsage("/usage"), + }, { display: "/mcp", description: "toggle MCPs", 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 96b9e8ffd57..0fc88bb9fdb 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,7 @@ 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, type UsageEntry } from "../dialog-usage" import { useTextareaKeybindings } from "../textarea-keybindings" export type PromptProps = { @@ -74,6 +75,34 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() + function handleUsageCommand(commandText: string) { + const parts = commandText.trim().split(/\s+/) + const provider = parts.length > 1 && !parts[1].startsWith("-") ? parts[1] : undefined + const refresh = parts.some((part) => part === "--refresh" || part === "-r") + + type UsageResponse = { + entries: UsageEntry[] + error?: string + } + + sdk.client.usage + .get({ provider, refresh }) + .then((res) => { + const data = res.data as UsageResponse | undefined + if (!data) return + 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", @@ -484,7 +513,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") { @@ -528,7 +557,16 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() - if (store.mode === "shell") { + const isShell = store.mode === "shell" + const isUsage = inputText.startsWith("/usage") + const isCommand = + inputText.startsWith("/") && + iife(() => { + const command = inputText.split(" ")[0].slice(1) + return sync.data.command.some((x) => x.name === command) + }) + + if (isShell) { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, @@ -539,15 +577,23 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - iife(() => { - const command = inputText.split(" ")[0].slice(1) - console.log(command) - return sync.data.command.some((x) => x.name === command) + } + + if (isUsage) { + handleUsageCommand(inputText) + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], }) - ) { - let [command, ...args] = inputText.split(" ") + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + input.clear() + return + } + + if (isCommand) { + const [command, ...args] = inputText.split(" ") sdk.client.session.command({ sessionID, command: command.slice(1), @@ -563,29 +609,30 @@ 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(() => {}) } + + if (!isShell && !isUsage && !isCommand) { + 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, + })), + ], + }) + } + history.append({ ...store.prompt, mode: currentMode, @@ -741,6 +788,7 @@ export function Prompt(props: PromptProps) { fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} + onUsage={handleUsageCommand} /> (anchor = r)} visible={props.visible !== false}> { + if (response.status !== 429) return + const body = await response + .clone() + .json() + .catch(() => null) + if (!body) return + const parsed = usageLimitErrorSchema.safeParse(body) + if (!parsed.success) return + const planType = parsePlanType(parsed.data.error.plan_type) + await Usage.updateUsage("codex", { + planType, + primary: { + usedPercent: 100, + windowMinutes: null, + resetsAt: parsed.data.error.resets_at, + }, + }) +} + +function showUsageToast(snapshot: Snapshot): void { + const parts: string[] = [] + if (snapshot.primary) { + parts.push(`Hourly: ${snapshot.primary.usedPercent.toFixed(0)}% used`) + } + if (snapshot.secondary) { + parts.push(`Weekly: ${snapshot.secondary.usedPercent.toFixed(0)}% used`) + } + if (parts.length === 0) return + const warning = Usage.getWarning(snapshot) + const variant = warning ? "warning" : "info" + const planLabel = snapshot.planType ? ` (${formatPlanType(snapshot.planType)})` : "" + Bus.publish(TuiEvent.ToastShow, { + title: `OpenAI Usage${planLabel}`, + message: parts.join(" • "), + variant, + duration: 5000, + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error) + log.debug("failed to show usage toast", { error: message }) + }) +} + +function formatPlanType(planType: PlanType): string { + const head = planType.slice(0, 1).toUpperCase() + return head + planType.slice(1) +} + +function parsePlanType(value: string | undefined): PlanType | null { + if (!value) return null + const parsed = Usage.planTypeSchema.safeParse(value) + if (!parsed.success) return null + return parsed.data +} + export async function CodexAuthPlugin(input: PluginInput): Promise { + if (!hasSubscribedToSession) { + Bus.subscribe(Session.Event.Created, () => { + hasShownUsageToast = false + }) + hasSubscribedToSession = true + } + return { auth: { provider: "openai", @@ -448,6 +527,17 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { return fetch(url, { ...init, headers, + }).then(async (response) => { + const usageSnapshot = Usage.parseRateLimitHeaders(response.headers) + if (usageSnapshot) { + const updated = await Usage.updateUsage("codex", usageSnapshot) + if (!hasShownUsageToast && response.ok) { + hasShownUsageToast = true + showUsageToast(updated) + } + } + await handleUsageLimit(response) + return response }) }, } diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 17ce9debc7d..c68ff313f45 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Installation } from "@/installation" import { iife } from "@/util/iife" +import { Usage } from "../usage" const CLIENT_ID = "Ov23li8tweQw6odWQebz" @@ -94,10 +95,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { delete headers["x-api-key"] delete headers["authorization"] - return fetch(request, { + const response = await fetch(request, { ...init, headers, }) + + if (response.headers.has("x-ratelimit-remaining-tokens")) { + const snapshot = Usage.parseCopilotRateLimitHeaders(response.headers) + if (snapshot) { + Usage.updateUsage("copilot", snapshot).catch(() => {}) + } + } + + return response }, } }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7015c818822..01e2ec28ec4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -27,6 +27,8 @@ import { Vcs } from "../project/vcs" import { Agent } from "../agent/agent" import { Auth } from "../auth" import { Flag } from "../flag/flag" +import { Usage } from "../usage" +import type { Snapshot as UsageSnapshot } from "../usage" import { Command } from "../command" import { ProviderAuth } from "../provider/auth" import { Global } from "../global" @@ -74,7 +76,129 @@ export namespace Server { Disposed: BusEvent.define("global.disposed", z.object({})), } + const usageResponseSchema = z.object({ + entries: z.array( + z.object({ + provider: z.string(), + displayName: z.string(), + snapshot: Usage.snapshotSchema, + }), + ), + error: z.string().optional(), + }) + + const UsageRoute = 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: z.coerce.boolean().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 providers = resolved ? [resolved] : await Usage.getAuthenticatedProviders() + if (providers.length === 0) { + return c.json({ + entries: [], + error: "No OAuth providers with usage tracking are authenticated. Run: opencode auth add codex", + }) + } + + const entries: Array<{ provider: string; displayName: string; snapshot: UsageSnapshot }> = [] + const errors: string[] = [] + + for (const provider of providers) { + const info = Usage.getProviderInfo(provider) + if (!info) { + errors.push(`Provider "${provider}" does not support usage tracking.`) + continue + } + + const authEntry = await Usage.getProviderAuth(provider) + if (!authEntry) { + errors.push(`Not authenticated with ${info.displayName}. Run: opencode auth add ${info.authKeys[0]}`) + continue + } + if (info.requiresOAuth && authEntry.auth.type !== "oauth") { + errors.push(`Not authenticated with ${info.displayName} OAuth. Run: opencode auth add ${info.authKeys[0]}`) + continue + } + + const accessToken = authEntry.auth.type === "oauth" ? authEntry.auth.access : null + if (!accessToken) { + errors.push(`Missing OAuth access token for ${info.displayName}.`) + continue + } + + const cached = await Usage.getUsage(provider) + const stale = !cached || Date.now() - cached.updatedAt > 5 * 60 * 1000 + const snapshot = await (async () => { + if (!refresh && !stale) return cached + + // Provider-specific fetch logic + let fetched: UsageSnapshot | null = null + + if (provider === "copilot") { + const refreshToken = authEntry.auth.type === "oauth" ? authEntry.auth.refresh : null + if (refreshToken) { + fetched = await Usage.fetchCopilotUsage({ access: accessToken, refresh: refreshToken }) + } + } else { + fetched = await Usage.fetchFromEndpoint(accessToken) + } + + if (!fetched) return cached + return Usage.updateUsage(provider, fetched) + })() + + if (!snapshot) { + errors.push(`Unable to fetch usage data for ${info.displayName}.`) + continue + } + + entries.push({ + provider, + displayName: info.displayName, + snapshot, + }) + } + + return c.json({ + entries, + ...(errors.length > 0 ? { error: errors.join("\n") } : {}), + }) + }, + ) + const app = new Hono() + export const App: () => Hono = lazy( () => // TODO: Break server.ts into smaller route files to fix type inference @@ -1731,6 +1855,7 @@ export namespace Server { return c.json(commands) }, ) + .route("/usage", UsageRoute) .get( "/config/providers", describeRoute({ diff --git a/packages/opencode/src/usage/index.ts b/packages/opencode/src/usage/index.ts new file mode 100644 index 00000000000..3cafdc0309c --- /dev/null +++ b/packages/opencode/src/usage/index.ts @@ -0,0 +1,532 @@ +import z from "zod" +import { Auth } from "../auth/index.js" +import { Bus } from "../bus/index.js" +import { BusEvent } from "../bus/bus-event.js" +import { Storage } from "../storage/storage.js" +import { Log } from "../util/log.js" + +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(), + 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, + }), + ), +} + +type UsageProviderInfo = { + authKeys: string[] + displayName: string + requiresOAuth: boolean +} + +const usageProviders: Record = { + codex: { + authKeys: ["openai", "codex"], + displayName: "OpenAI", + requiresOAuth: true, + }, + copilot: { + authKeys: ["github-copilot", "github-copilot-enterprise"], + displayName: "GitHub Copilot", + requiresOAuth: true, + }, +} + +const providerAliases: Record = { + openai: "codex", + gpt: "codex", + chatgpt: "codex", + "chatgpt-pro": "codex", + "chatgpt-plus": "codex", + codex: "codex", + copilot: "copilot", + github: "copilot", + gh: "copilot", +} + +const usageEndpoint = "https://chatgpt.com/backend-api/wham/usage" + +export const warningThresholds = [75, 90, 95] as const + +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 snapshot: Snapshot = { + primary: update.primary ?? existing?.primary ?? null, + secondary: update.secondary ?? existing?.secondary ?? null, + credits: update.credits ?? existing?.credits ?? null, + planType: update.planType ?? existing?.planType ?? null, + updatedAt: Date.now(), + } + await Storage.write(storageKey(provider), snapshot) + await Bus.publish(UsageEvent.Updated, { provider, snapshot }).catch(() => {}) + 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() + return providerAliases[normalized] ?? null +} + +export function listSupportedProviders(): string[] { + return Object.keys(usageProviders) +} + +export function getProviderInfo(provider: string): UsageProviderInfo | null { + return usageProviders[provider] ?? null +} + +export async function getAuthenticatedProviders(): Promise { + const auth = await Auth.all() + const providers = Object.keys(usageProviders) + const result: string[] = [] + + for (const provider of providers) { + const info = usageProviders[provider] + const matched = info.authKeys.some((key) => { + const providerAuth = auth[key] + if (!providerAuth) return false + if (info.requiresOAuth && providerAuth.type !== "oauth") return false + return true + }) + if (matched) result.push(provider) + } + + return result +} + +export async function getProviderAuth(provider: string): Promise<{ key: string; auth: Auth.Info } | null> { + const info = usageProviders[provider] + if (!info) return null + const auth = await Auth.all() + + for (const key of info.authKeys) { + const providerAuth = auth[key] + if (!providerAuth) continue + if (info.requiresOAuth && providerAuth.type !== "oauth") continue + return { key, auth: providerAuth } + } + + return null +} + +export function parseRateLimitHeaders(headers: Headers): Snapshot | null { + const primary = parseWindow(headers, "primary") + const secondary = parseWindow(headers, "secondary") + const credits = parseCredits(headers) + if (!primary && !secondary && !credits) return null + + return { + primary, + secondary, + credits, + planType: null, + updatedAt: Date.now(), + } +} + +export async function fetchFromEndpoint(accessToken: string): Promise { + const response = await fetch(usageEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + log.warn("usage fetch failed", { status: response.status }) + return null + } + + const body = await response.json().catch(() => null) + if (!body) return null + + const parsed = usageResponseSchema.safeParse(body) + if (!parsed.success) { + log.warn("usage fetch parse failed", { issues: parsed.error.issues.length }) + return null + } + + const rateLimit = parsed.data.rate_limit + const primary = toRateLimitWindow(rateLimit.primary_window) + const secondary = toRateLimitWindow(rateLimit.secondary_window) + const credits = toCreditsSnapshot(parsed.data.credits) + const planType = toPlanType(parsed.data.plan_type) + + return { + primary, + secondary, + credits, + planType, + updatedAt: Date.now(), + } +} + +export function formatResetTime(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 formatWindowDuration(windowMinutes: number): string { + const minutesPerHour = 60 + const minutesPerDay = 24 * minutesPerHour + if (windowMinutes <= minutesPerDay) { + const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour)) + if (hours === 1) return "Hourly" + return `${hours}h` + } + return "Weekly" +} + +export function getWarning(snapshot: Snapshot): string | null { + const windows = [snapshot.primary, snapshot.secondary] + for (const window of windows) { + if (!window) continue + for (const threshold of warningThresholds) { + if (window.usedPercent < threshold) continue + const remaining = 100 - window.usedPercent + const duration = formatWindowDuration(window.windowMinutes ?? 60).toLowerCase() + return `Less than ${remaining.toFixed(0)}% of your ${duration} limit remaining.` + } + } + return null +} + +function storageKey(provider: string): string[] { + return ["usage", provider] +} + +function parseWindow(headers: Headers, prefix: "primary" | "secondary"): RateLimitWindow | null { + const usedPercent = parseNumberHeader(headers, `x-codex-${prefix}-used-percent`) + if (usedPercent === null) return null + + return { + usedPercent, + windowMinutes: parseIntegerHeader(headers, `x-codex-${prefix}-window-minutes`), + resetsAt: parseIntegerHeader(headers, `x-codex-${prefix}-reset-at`), + } +} + +function parseCredits(headers: Headers): CreditsSnapshot | null { + const hasCredits = parseBooleanHeader(headers, "x-codex-credits-has-credits") + if (hasCredits === null) return null + + return { + hasCredits, + unlimited: parseBooleanHeader(headers, "x-codex-credits-unlimited") ?? false, + balance: headers.get("x-codex-credits-balance"), + } +} + +function parseNumberHeader(headers: Headers, name: string): number | null { + const value = headers.get(name) + if (!value) return null + const parsed = Number.parseFloat(value) + if (Number.isNaN(parsed)) return null + return parsed +} + +function parseIntegerHeader(headers: Headers, name: string): number | null { + const value = headers.get(name) + if (!value) return null + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) return null + return parsed +} + +function parseBooleanHeader(headers: Headers, name: string): boolean | null { + const value = headers.get(name) + if (!value) return null + const normalized = value.toLowerCase() + if (normalized === "true" || normalized === "1") return true + if (normalized === "false" || normalized === "0") return false + return null +} + +type UsageResponseWindow = { + used_percent: number + limit_window_seconds: number + reset_after_seconds: number + reset_at: number +} + +type UsageResponse = { + plan_type: string | null + rate_limit: { + allowed: boolean + limit_reached: boolean + primary_window: UsageResponseWindow | null + secondary_window: UsageResponseWindow | null + } + credits: { + has_credits: boolean + unlimited: boolean + balance: string | null + } | null +} + +const usageResponseWindowSchema = z.object({ + used_percent: z.number(), + limit_window_seconds: z.number(), + reset_after_seconds: z.number(), + reset_at: z.number(), +}) + +const usageResponseSchema = z.object({ + plan_type: z.string().nullable(), + rate_limit: z.object({ + allowed: z.boolean(), + limit_reached: z.boolean(), + primary_window: usageResponseWindowSchema.nullable(), + secondary_window: usageResponseWindowSchema.nullable(), + }), + credits: z + .object({ + has_credits: z.boolean(), + unlimited: z.boolean(), + balance: z.string().nullable(), + }) + .nullable(), +}) satisfies z.ZodType + +function toRateLimitWindow(window: UsageResponseWindow | 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 toCreditsSnapshot(credits: UsageResponse["credits"]): CreditsSnapshot | null { + if (!credits) return null + return { + hasCredits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance, + } +} + +function toPlanType(value: UsageResponse["plan_type"]): PlanType | null { + if (!value) return null + const parsed = planTypeSchema.safeParse(value) + if (!parsed.success) return null + return parsed.data +} + +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", +} + +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) +} + +export function parseCopilotRateLimitHeaders(headers: Headers): Snapshot | null { + const remainingTokens = parseIntegerHeader(headers, "x-ratelimit-remaining-tokens") + const remainingRequests = parseIntegerHeader(headers, "x-ratelimit-remaining-requests") + + if (remainingTokens === null && remainingRequests === null) return null + + const estimatedTokenLimit = 10_000_000 + const estimatedRequestLimit = 200_000 + + const primary: RateLimitWindow | null = + remainingTokens !== null + ? { + usedPercent: Math.max( + 0, + Math.min(100, ((estimatedTokenLimit - remainingTokens) / estimatedTokenLimit) * 100), + ), + windowMinutes: 60, + resetsAt: null, + } + : null + + const secondary: RateLimitWindow | null = + remainingRequests !== null + ? { + usedPercent: Math.max( + 0, + Math.min(100, ((estimatedRequestLimit - remainingRequests) / estimatedRequestLimit) * 100), + ), + windowMinutes: null, + resetsAt: null, + } + : null + + return { + primary, + secondary, + credits: null, + planType: null, + updatedAt: Date.now(), + } +} + +type CopilotAuthInfo = { + access: string + refresh: string +} + +export async function fetchCopilotUsage(auth: CopilotAuthInfo): Promise { + const tokenMetadata = parseCopilotAccessToken(auth.access) + const planType = copilotSkuToPlan(tokenMetadata.sku) + + return { + primary: null, + secondary: null, + credits: tokenMetadata.quotaLimit + ? { + hasCredits: true, + unlimited: false, + balance: String(tokenMetadata.quotaLimit), + } + : null, + 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, + warningThresholds, + getUsage, + updateUsage, + clearUsage, + resolveProvider, + listSupportedProviders, + getProviderInfo, + getAuthenticatedProviders, + getProviderAuth, + parseRateLimitHeaders, + fetchFromEndpoint, + formatResetTime, + formatWindowDuration, + getWarning, + parseCopilotAccessToken, + parseCopilotRateLimitHeaders, + copilotSkuToPlan, + fetchCopilotUsage, +} as const diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 697dac7eefe..a0854988780 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -157,6 +157,7 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + UsageGetResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -1898,6 +1899,40 @@ export class Command 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 @@ -3012,6 +3047,8 @@ export class OpencodeClient extends HeyApiClient { command = new Command({ client: this.client }) + usage = new Usage({ client: this.client }) + provider = new Provider({ client: this.client }) find = new Find({ 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 7fad08e71cf..fc81014bf12 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -796,6 +796,46 @@ export type EventVcsBranchUpdated = { } } +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 + 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 Pty = { id: string title: string @@ -883,6 +923,7 @@ export type Event = | EventSessionError | EventFileWatcherUpdated | EventVcsBranchUpdated + | EventUsageUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -3762,6 +3803,65 @@ export type CommandListResponses = { export type CommandListResponse = CommandListResponses[keyof CommandListResponses] +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 + 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 + } +} + +export type UsageGetResponse = UsageGetResponses[keyof UsageGetResponses] + export type ConfigProvidersData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fd46f7c7860..fdac829382a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3382,6 +3382,210 @@ ] } }, + "/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" + } + ] + }, + "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", "credits", "planType", "updatedAt"] + } + }, + "required": ["provider", "displayName", "snapshot"] + } + }, + "error": { + "type": "string" + } + }, + "required": ["entries"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.usage.get({\n ...\n})" + } + ] + } + }, "/config/providers": { "get": { "operationId": "config.providers", @@ -7798,6 +8002,160 @@ }, "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" + } + ] + }, + "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", "credits", "planType", "updatedAt"] + } + }, + "required": ["provider", "snapshot"] + } + }, + "required": ["type", "properties"] + }, "Pty": { "type": "object", "properties": { @@ -8040,6 +8398,9 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.usage.updated" + }, { "$ref": "#/components/schemas/Event.pty.created" },