diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf..62d3eeacc16 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -13,6 +13,7 @@ import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" +import { DialogUsage } from "@tui/component/dialog-usage" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" @@ -447,6 +448,17 @@ function App() { }, category: "System", }, + { + title: "View usage", + value: "usage.view", + category: "System", + slash: { + name: "usage", + }, + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "Switch theme", value: "theme.switch", 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..089ab2d203d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx @@ -0,0 +1,347 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useTheme } from "../context/theme" +import { useLocal } from "../context/local" +import { useDialog } from "../ui/dialog" +import { Show, createSignal, onMount, createMemo } from "solid-js" +import { Usage, type ProviderUsage, type RateWindow } from "@/usage" +import { Link } from "../ui/link" +import { + requestCopilotDeviceCode, + pollCopilotAccessToken, + saveCopilotUsageToken, +} from "@/usage/providers/copilot-auth" + +// Map OpenCode provider IDs to usage provider IDs +const PROVIDER_MAP: Record = { + "github-copilot": "github-copilot", + "github-copilot-enterprise": "github-copilot", + openai: "openai", + anthropic: "anthropic", + google: "antigravity", + "google-vertex": "antigravity", + opencode: "opencode", +} + +// Providers that are pay-per-use with no rate limits +const UNLIMITED_PROVIDERS: Record = { + opencode: "OpenCode Zen", +} + +export function DialogUsage() { + const { theme } = useTheme() + const local = useLocal() + const dialog = useDialog() + const [loading, setLoading] = createSignal(true) + const [providers, setProviders] = createSignal([]) + const [error, setError] = createSignal(null) + const [showRemaining, setShowRemaining] = createSignal(false) + + const currentProviderID = createMemo(() => { + const model = local.model.current() + if (!model) return null + return PROVIDER_MAP[model.providerID] ?? model.providerID + }) + + const currentProvider = createMemo((): ProviderUsage | null => { + const id = currentProviderID() + if (!id) return null + + // First check if we have usage data for this provider + const found = providers().find((p) => p.providerId === id) + if (found) return found + + // If not found but it's an unlimited provider, create a synthetic entry + if (UNLIMITED_PROVIDERS[id]) { + return { + providerId: id, + providerLabel: UNLIMITED_PROVIDERS[id], + status: "unlimited", + } + } + + return null + }) + + const refetchUsage = async () => { + setLoading(true) + setError(null) + try { + const snapshot = await Usage.fetch() + setProviders(snapshot.providers) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + } + + useKeyboard((evt) => { + if (evt.name === "tab") { + evt.preventDefault() + setShowRemaining((prev) => !prev) + } + }) + + onMount(refetchUsage) + + return ( + + + + Usage + + + tab toggle view | esc + + + + + Loading... + + + + Error: {error()} + + + + + + + + + Usage not available for current provider + + + + ) +} + +function CurrentProviderSection(props: { + provider: ProviderUsage + showRemaining: boolean + onAuthComplete: () => Promise +}) { + const { theme } = useTheme() + const p = () => props.provider + + const isCopilotReauth = createMemo(() => p().providerId === "github-copilot" && p().error === "copilot_reauth_required") + + return ( + + + + {p().providerLabel} + + + ({p().plan}) + + + + + + + + + {p().error} + + + + {p().error ?? "Usage tracking not supported"} + + + + + + Unlimited + + No rate limits - pay per use + + + + + + + + + + + + + + + {p().accountEmail} + + + + ) +} + +function CopilotSetupPrompt(props: { onAuthComplete: () => Promise }) { + const { theme } = useTheme() + const dialog = useDialog() + const [setting, setSetting] = createSignal(false) + const [authData, setAuthData] = createSignal<{ url: string; code: string } | null>(null) + const [authError, setAuthError] = createSignal(null) + + useKeyboard((evt) => { + if (setting()) return + if (evt.name === "y") { + evt.preventDefault() + evt.stopPropagation() + startCopilotAuth() + } + if (evt.name === "n") { + evt.preventDefault() + evt.stopPropagation() + dialog.clear() + } + }) + + async function startCopilotAuth() { + setSetting(true) + setAuthError(null) + try { + // Request device code using Copilot-specific client ID + const deviceCode = await requestCopilotDeviceCode() + + setAuthData({ + url: deviceCode.verificationUri, + code: deviceCode.userCode, + }) + + // Poll for access token + const tokenResponse = await pollCopilotAccessToken({ + deviceCode: deviceCode.deviceCode, + interval: deviceCode.interval, + expiresIn: deviceCode.expiresIn, + }) + + // Save the token for future usage fetches + await saveCopilotUsageToken({ + accessToken: tokenResponse.access_token, + scope: tokenResponse.scope, + createdAt: new Date().toISOString(), + }) + + // Refetch usage to show the new data + await props.onAuthComplete() + } catch (e) { + setAuthError(e instanceof Error ? e.message : String(e)) + setSetting(false) + setAuthData(null) + } + } + + return ( + + + + Usage tracking requires GitHub authentication with Copilot permissions. + + + Would you like to set up Copilot usage tracking now? + + + + y + yes + + + n + no + + + + + + Starting GitHub authentication... + + + + Error: {authError()} + + + + + Login with GitHub + + + + Enter code: {authData()!.code} + + Waiting for authorization... + + + ) +} + +function LargeRateBar(props: { window: RateWindow; showRemaining: boolean }) { + const { theme } = useTheme() + const w = () => props.window + + const barWidth = 50 + + // When showing remaining, we show remaining % as filled (green = more remaining = good) + // When showing used, we show used % as filled (green = less used = good) + const displayPercent = createMemo(() => { + const used = Math.min(100, Math.max(0, w().usedPercent)) + return props.showRemaining ? 100 - used : used + }) + + const filledWidth = createMemo(() => Math.round((displayPercent() / 100) * barWidth)) + const emptyWidth = createMemo(() => barWidth - filledWidth()) + + const displayPct = createMemo(() => Math.round(displayPercent())) + const label = createMemo(() => (props.showRemaining ? "remaining" : "used")) + + const barColor = createMemo(() => { + const pct = w().usedPercent + // Color is always based on usage (not remaining) + if (pct >= 90) return theme.error + if (pct >= 75) return theme.warning + return theme.success + }) + + const resetText = createMemo(() => { + if (!w().resetsAt) return null + const resetDate = new Date(w().resetsAt!) + if (Number.isNaN(resetDate.getTime())) return null + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + if (diffMs <= 0) return "Resets soon" + + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 60) return `Resets in ${diffMins}m` + if (diffHours < 24) return `Resets in ${diffHours}h ${diffMins % 60}m` + if (diffDays === 1) return `Resets tomorrow` + return `Resets in ${diffDays} days` + }) + + return ( + + + {w().label} + + {displayPct()}% {label()} + + + + + {"█".repeat(filledWidth())} + {"░".repeat(emptyWidth())} + + + + {resetText()} + + + ) +} 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 ebc7514d723..ad20ba3fe87 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,6 +1,7 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, createEffect, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" +import { Usage, type ProviderUsage, type RateWindow } from "@/usage" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" import path from "path" @@ -10,11 +11,29 @@ import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" +import { useLocal } from "../../context/local" import { TodoItem } from "../../component/todo-item" +// Map OpenCode provider IDs to usage provider IDs +const PROVIDER_MAP: Record = { + "github-copilot": "github-copilot", + "github-copilot-enterprise": "github-copilot", + openai: "openai", + anthropic: "anthropic", + google: "antigravity", + "google-vertex": "antigravity", + opencode: "opencode", +} + +// Providers that are pay-per-use with no rate limits +const UNLIMITED_PROVIDERS: Record = { + opencode: "OpenCode Zen", +} + export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() + const local = useLocal() const session = createMemo(() => sync.session.get(props.sessionID)!) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) @@ -27,6 +46,110 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { lsp: true, }) + // Usage tracking + const [usageProviders, setUsageProviders] = createSignal([]) + const [usageLoading, setUsageLoading] = createSignal(true) + + // Get current provider from the selected model (not last assistant message) + const currentProviderID = createMemo(() => { + const model = local.model.current() + if (!model) return null + return PROVIDER_MAP[model.providerID] ?? model.providerID + }) + + const currentUsage = createMemo((): ProviderUsage | null => { + const id = currentProviderID() + if (!id) return null + + // If it's an unlimited provider, create a synthetic entry + if (UNLIMITED_PROVIDERS[id]) { + return { + providerId: id, + providerLabel: UNLIMITED_PROVIDERS[id], + status: "unlimited", + } + } + + // Return usage data for this provider (including error states) + const found = usageProviders().find((p) => p.providerId === id) + return found ?? null + }) + + const fetchUsage = async () => { + const providerID = currentProviderID() + if (!providerID) { + setUsageProviders([]) + setUsageLoading(false) + return + } + + if (usageHidden()) return + + if (UNLIMITED_PROVIDERS[providerID]) { + setUsageProviders([]) + setUsageLoading(false) + return + } + + setUsageLoading(true) + try { + const snapshot = await Usage.fetch({ providers: [providerID] }) + setUsageProviders(snapshot.providers) + } catch { + // Silently fail - usage section will just not show + } finally { + setUsageLoading(false) + } + } + + // Refetch usage when an assistant turn completes + // Track the last completed assistant message ID + const lastCompletedAssistantId = createMemo(() => { + const assistantMsgs = messages().filter((x) => x.role === "assistant" && x.time.completed) + if (assistantMsgs.length === 0) return null + return assistantMsgs[assistantMsgs.length - 1]?.id + }) + + let prevCompletedId: string | null = null + createEffect(() => { + const currentId = lastCompletedAssistantId() + if (currentId && currentId !== prevCompletedId) { + prevCompletedId = currentId + // Debounce slightly to avoid rapid refetches + setTimeout(fetchUsage, 100) + } + }) + + // Refetch usage when the selected provider changes + let prevProviderID: string | null = null + createEffect(() => { + const providerID = currentProviderID() + if (!providerID) { + prevProviderID = null + setUsageProviders([]) + setUsageLoading(false) + return + } + if (providerID !== prevProviderID) { + prevProviderID = providerID + fetchUsage() + } + }) + + // Refetch usage when the section is shown again + let prevHidden: boolean | null = null + createEffect(() => { + const hidden = usageHidden() + if (prevHidden === null) { + prevHidden = hidden + return + } + if (prevHidden && !hidden) { + fetchUsage() + } + prevHidden = hidden + }) + // Sort MCP servers alphabetically for consistent display order const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) @@ -67,6 +190,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) + const usageHidden = createMemo(() => kv.get("hidden_sidebar_usage", false)) return ( @@ -96,7 +220,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.tokens ?? 0} tokens {context()?.percentage ?? 0}% used {cost()} spent + + kv.set("hidden_sidebar_usage", false)}> + Show usage + + + + kv.set("hidden_sidebar_usage", true)} /> + 0}> ) } + +function SidebarUsageSection(props: { usage: ProviderUsage; onHide: () => void }) { + const { theme } = useTheme() + const u = () => props.usage + + // Compact progress bar for sidebar (narrower than dialog) + const barWidth = 12 + + const formatResetTime = (resetsAt: string) => { + const resetDate = new Date(resetsAt) + if (Number.isNaN(resetDate.getTime())) return null + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + if (diffMs <= 0) return "soon" + + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 60) return `${diffMins}m` + if (diffHours < 24) return `${diffHours}h ${diffMins % 60}m` + if (diffDays === 1) return "tomorrow" + return `${diffDays}d` + } + + const formatSidebarLabel = (label: string) => label.replace(/\s*\([^)]*\)\s*$/, "") + + const renderCompactBar = (w: RateWindow) => { + const usedPct = Math.min(100, Math.max(0, w.usedPercent)) + const filledWidth = Math.round((usedPct / 100) * barWidth) + const emptyWidth = barWidth - filledWidth + + const barColor = usedPct >= 90 ? theme.error : usedPct >= 75 ? theme.warning : theme.success + + const resetText = w.resetsAt ? formatResetTime(w.resetsAt) : null + const labelText = Locale.truncate(formatSidebarLabel(w.label), 12) + const label = labelText.padEnd(12) + + return ( + + + {label} + + + {"█".repeat(filledWidth)} + {"░".repeat(emptyWidth)} + + + {Math.round(usedPct)}%{resetText ? ` (${resetText})` : ""} + + + ) + } + + return ( + + + + Usage + + + ✕ + + + + + Unlimited + + + {renderCompactBar(u().primary!)} + {renderCompactBar(u().secondary!)} + + + {u().error ?? "Unable to fetch"} + + + Not available + + + + ) +} diff --git a/packages/opencode/src/usage/index.ts b/packages/opencode/src/usage/index.ts new file mode 100644 index 00000000000..3f787f5db88 --- /dev/null +++ b/packages/opencode/src/usage/index.ts @@ -0,0 +1,87 @@ +import { Auth } from "@/auth" +import type { ProviderUsage, UsageSnapshot } from "./types" +import { fetchOpenAIUsage } from "./providers/openai" +import { fetchAnthropicUsage } from "./providers/anthropic" +import { fetchCopilotUsage } from "./providers/copilot" +import { fetchAntigravityUsage } from "./providers/antigravity" + +export type { ProviderUsage, RateWindow, UsageSnapshot } from "./types" + +export interface UsageFetchOptions { + providers?: string[] +} + +// Providers that use API keys and have no rate limits (pay-per-use) +const UNLIMITED_PROVIDERS: Record = { + opencode: "OpenCode Zen", +} + +export namespace Usage { + export async function fetch(options: UsageFetchOptions = {}): Promise { + const authMap = await Auth.all() + const providers: ProviderUsage[] = [] + const filter = options.providers && options.providers.length > 0 ? new Set(options.providers) : null + const shouldInclude = (id: string) => !filter || filter.has(id) + + const fetchers: Array<{ id: string; fn: () => Promise }> = [] + + // Check for unlimited providers first + for (const [providerId, label] of Object.entries(UNLIMITED_PROVIDERS)) { + if (!shouldInclude(providerId)) continue + if (authMap[providerId]) { + providers.push({ + providerId, + providerLabel: label, + status: "unlimited", + }) + } + } + + if (shouldInclude("openai") && authMap["openai"]) { + fetchers.push({ id: "openai", fn: () => fetchOpenAIUsage(authMap["openai"]!) }) + } + + if (shouldInclude("anthropic") && authMap["anthropic"]) { + fetchers.push({ id: "anthropic", fn: () => fetchAnthropicUsage(authMap["anthropic"]!) }) + } + + if (shouldInclude("github-copilot")) { + // Always try Copilot - it has its own token storage for usage + const copilotAuth = authMap["github-copilot-enterprise"] ?? authMap["github-copilot"] ?? null + fetchers.push({ id: "github-copilot", fn: () => fetchCopilotUsage(copilotAuth) }) + } + + if (shouldInclude("antigravity")) { + fetchers.push({ id: "antigravity", fn: () => fetchAntigravityUsage() }) + } + + const results = await Promise.allSettled(fetchers.map((f) => f.fn())) + + for (let i = 0; i < fetchers.length; i++) { + const result = results[i] + const fetcher = fetchers[i] + if (result.status === "fulfilled" && result.value) { + providers.push(result.value) + } else if (result.status === "rejected") { + providers.push({ + providerId: fetcher.id, + providerLabel: getLabelForProvider(fetcher.id), + status: "error", + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }) + } + } + + return { providers, fetchedAt: new Date().toISOString() } + } +} + +function getLabelForProvider(id: string): string { + const labels: Record = { + openai: "OpenAI/Codex", + anthropic: "Anthropic/Claude", + "github-copilot": "GitHub Copilot", + antigravity: "Antigravity", + } + return labels[id] ?? id +} diff --git a/packages/opencode/src/usage/providers/anthropic.ts b/packages/opencode/src/usage/providers/anthropic.ts new file mode 100644 index 00000000000..2708ebd5e8b --- /dev/null +++ b/packages/opencode/src/usage/providers/anthropic.ts @@ -0,0 +1,85 @@ +import type { Auth } from "@/auth" +import type { ProviderUsage, RateWindow } from "../types" + +const USAGE_URL = "https://api.anthropic.com/api/oauth/usage" +const BETA_HEADER = "oauth-2025-04-20" + +interface OAuthUsageResponse { + five_hour?: OAuthUsageWindow + seven_day?: OAuthUsageWindow + seven_day_oauth_apps?: OAuthUsageWindow + seven_day_opus?: OAuthUsageWindow + seven_day_sonnet?: OAuthUsageWindow +} + +interface OAuthUsageWindow { + utilization?: number + resets_at?: string +} + +export async function fetchAnthropicUsage(auth: Auth.Info): Promise { + if (auth.type !== "oauth") { + return { providerId: "anthropic", providerLabel: "Anthropic/Claude", status: "unsupported", error: "Requires OAuth" } + } + + if (typeof auth.expires === "number" && auth.expires > 0 && Date.now() >= auth.expires) { + return { providerId: "anthropic", providerLabel: "Anthropic/Claude", status: "error", error: "Token expired. Run /connect to refresh." } + } + + const response = await fetch(USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.access}`, + Accept: "application/json", + "Content-Type": "application/json", + "anthropic-beta": BETA_HEADER, + "User-Agent": "opencode", + }, + }) + + if (response.status === 401 || response.status === 403) { + return { + providerId: "anthropic", + providerLabel: "Anthropic/Claude", + status: "error", + error: "Token expired or invalid. Run /connect to refresh.", + } + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`Anthropic usage request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as OAuthUsageResponse + + // Determine tertiary label based on which model limit is present + let tertiaryWindow: OAuthUsageWindow | undefined + let tertiaryLabel = "Weekly (model)" + if (data.seven_day_opus) { + tertiaryWindow = data.seven_day_opus + tertiaryLabel = "Weekly (Opus)" + } else if (data.seven_day_sonnet) { + tertiaryWindow = data.seven_day_sonnet + tertiaryLabel = "Weekly (Sonnet)" + } + + return { + providerId: "anthropic", + providerLabel: "Anthropic/Claude", + status: "ok", + primary: toRateWindow(data.five_hour, "5-hour window"), + secondary: toRateWindow(data.seven_day ?? data.seven_day_oauth_apps, "7-day window"), + tertiary: toRateWindow(tertiaryWindow, tertiaryLabel), + } +} + +function toRateWindow(window: OAuthUsageWindow | undefined, label: string): RateWindow | undefined { + if (!window) return undefined + const utilization = typeof window.utilization === "number" ? window.utilization : 0 + return { + label, + usedPercent: Math.max(0, Math.min(100, utilization * 100)), + resetsAt: window.resets_at || undefined, + } +} diff --git a/packages/opencode/src/usage/providers/antigravity.ts b/packages/opencode/src/usage/providers/antigravity.ts new file mode 100644 index 00000000000..607993a76fb --- /dev/null +++ b/packages/opencode/src/usage/providers/antigravity.ts @@ -0,0 +1,223 @@ +import { Global } from "@/global" +import path from "path" +import type { ProviderUsage, RateWindow } from "../types" + +const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" +const ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com" +const ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com" +const ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com" +const LOAD_ENDPOINTS = [ENDPOINT_PROD, ENDPOINT_DAILY, ENDPOINT_AUTOPUSH] +const FETCH_MODELS_URL = `${ENDPOINT_PROD}/v1internal:fetchAvailableModels` + +const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +const CLIENT_SECRET = "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" +const LOAD_USER_AGENT = "antigravity/windows/amd64" +const QUOTA_USER_AGENT = "antigravity/1.11.3 Darwin/arm64" +const CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}' +const X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" +const FALLBACK_PROJECT = "rising-fact-p41fc" + +interface AntigravityAccount { + email?: string + refreshToken: string + projectId?: string + managedProjectId?: string +} + +interface AccountsFile { + version: number + accounts: AntigravityAccount[] + activeIndex?: number +} + +interface LoadCodeAssistResponse { + cloudaicompanionProject?: string | { id?: string } + currentTier?: { id?: string } + paidTier?: { id?: string } +} + +interface FetchModelsResponse { + models?: Record +} + +interface ModelQuota { + modelId: string + percentRemaining: number // 0-100, where 0 = fully used, 100 = fresh + resetTime?: string +} + +export async function fetchAntigravityUsage(): Promise { + const accountsFile = await loadAccountsFile() + if (!accountsFile?.accounts?.length) return null + + const account = accountsFile.accounts[Math.max(0, Math.min(accountsFile.activeIndex ?? 0, accountsFile.accounts.length - 1))] + if (!account) return null + + try { + const refreshParts = parseRefreshToken(account.refreshToken) + const accessToken = await refreshAccessToken(refreshParts.refreshToken) + const fallbackProjectId = account.managedProjectId ?? refreshParts.managedProjectId ?? account.projectId ?? refreshParts.projectId ?? FALLBACK_PROJECT + const { projectId, subscriptionTier } = await loadCodeAssist(accessToken, fallbackProjectId) + const quotaResponse = await fetchAvailableModels(accessToken, projectId ?? fallbackProjectId) + + const quotas = extractModelQuotas(quotaResponse.models ?? {}) + + // Find Gemini and Claude quotas + const geminiQuota = resolveModelQuota(quotas, "gemini") + const claudeQuota = resolveModelQuota(quotas, "claude") + + return { + providerId: "antigravity", + providerLabel: "Antigravity", + status: "ok", + primary: geminiQuota ? toRateWindow(geminiQuota) : undefined, + secondary: claudeQuota ? toRateWindow(claudeQuota) : undefined, + accountEmail: account.email, + plan: subscriptionTier, + } + } catch (error) { + return { + providerId: "antigravity", + providerLabel: "Antigravity", + status: "error", + error: error instanceof Error ? error.message : String(error), + } + } +} + +async function loadAccountsFile(): Promise { + const filePath = path.join(Global.Path.config, "antigravity-accounts.json") + const file = Bun.file(filePath) + if (!(await file.exists())) return null + return file.json().catch(() => null) +} + +function parseRefreshToken(raw: string): { refreshToken: string; projectId?: string; managedProjectId?: string } { + const [refreshToken = "", projectId, managedProjectId] = (raw ?? "").split("|") + return { refreshToken, projectId: projectId || undefined, managedProjectId: managedProjectId || undefined } +} + +async function refreshAccessToken(refreshToken: string): Promise { + if (!refreshToken) throw new Error("Antigravity refresh token missing") + const response = await fetch(OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }), + }) + if (!response.ok) throw new Error(`Token refresh failed (${response.status})`) + const payload = (await response.json()) as { access_token?: string } + if (!payload.access_token) throw new Error("No access token returned") + return payload.access_token +} + +async function loadCodeAssist(accessToken: string, projectId: string): Promise<{ projectId?: string; subscriptionTier?: string }> { + const metadata = { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", duetProject: projectId } + for (const endpoint of LOAD_ENDPOINTS) { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": LOAD_USER_AGENT, "X-Goog-Api-Client": X_GOOG_API_CLIENT, "Client-Metadata": CLIENT_METADATA }, + body: JSON.stringify({ metadata }), + }) + if (!response.ok) continue + const data = (await response.json()) as LoadCodeAssistResponse + const proj = typeof data.cloudaicompanionProject === "string" ? data.cloudaicompanionProject : data.cloudaicompanionProject?.id + return { projectId: proj, subscriptionTier: data.paidTier?.id ?? data.currentTier?.id } + } + return {} +} + +async function fetchAvailableModels(accessToken: string, projectId: string): Promise { + const response = await fetch(FETCH_MODELS_URL, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": QUOTA_USER_AGENT, "X-Goog-Api-Client": X_GOOG_API_CLIENT, "Client-Metadata": CLIENT_METADATA }, + body: JSON.stringify({ project: projectId }), + }) + if (!response.ok) throw new Error(`Quota request failed (${response.status})`) + return (await response.json()) as FetchModelsResponse +} + +function extractModelQuotas(models: Record): ModelQuota[] { + const quotas: ModelQuota[] = [] + for (const [name, info] of Object.entries(models)) { + // Only include models with quotaInfo (gemini or claude) + if (!info.quotaInfo) continue + if (!name.includes("gemini") && !name.includes("claude")) continue + + const fraction = info.quotaInfo.remainingFraction + // If remainingFraction is null/undefined, it means 0% remaining (fully used) + // This matches Antigravity-Manager behavior: .unwrap_or(0) + const percentRemaining = typeof fraction === "number" && !Number.isNaN(fraction) ? fraction * 100 : 0 + + quotas.push({ + modelId: name, + percentRemaining, + resetTime: info.quotaInfo.resetTime, + }) + } + return quotas +} + +function resolveModelQuota(quotas: ModelQuota[], type: "gemini" | "claude"): { label: string; quota: ModelQuota } | null { + const matches = quotas.filter((q) => q.modelId.includes(type)) + if (!matches.length) return null + + let quota: ModelQuota + + if (type === "gemini") { + const preferred = matches.find((q) => q.modelId.includes("gemini-3-pro")) + quota = preferred ?? matches.reduce((a, b) => (b.percentRemaining < a.percentRemaining ? b : a)) + } else { + // Pick the model with lowest remaining (highest usage) - most relevant limit + quota = matches.reduce((a, b) => (b.percentRemaining < a.percentRemaining ? b : a)) + } + + // Generate friendly label + const label = formatModelLabel(quota.modelId) + + return { label, quota } +} + +function formatModelLabel(modelId: string): string { + // Map model IDs to friendly names + const id = modelId.toLowerCase() + + if (id.includes("claude-opus-4-5") || id.includes("claude-opus-4.5")) return "Claude Opus 4.5" + if (id.includes("claude-opus-4")) return "Claude Opus 4" + if (id.includes("claude-sonnet-4-5") || id.includes("claude-sonnet-4.5")) return "Claude Sonnet 4.5" + if (id.includes("claude-sonnet-4")) return "Claude Sonnet 4" + if (id.includes("claude-opus")) return "Claude Opus" + if (id.includes("claude-sonnet")) return "Claude Sonnet" + if (id.includes("claude")) return "Claude" + + if (id.includes("gemini-3-pro")) return "Gemini 3 Pro" + if (id.includes("gemini-3-flash")) return "Gemini 3 Flash" + if (id.includes("gemini-2.5-pro")) return "Gemini 2.5 Pro" + if (id.includes("gemini-2.5-flash")) return "Gemini 2.5 Flash" + if (id.includes("gemini")) return "Gemini" + + return modelId +} + +function toRateWindow(match: { label: string; quota: ModelQuota }): RateWindow { + const { label, quota } = match + const usedPercent = Math.max(0, 100 - quota.percentRemaining) + + // Build label with window info + const windowLabel = buildWindowLabel(label, quota.resetTime) + + return { + label: windowLabel, + usedPercent, + resetsAt: quota.resetTime ? new Date(quota.resetTime).toISOString() : undefined, + } +} + +function buildWindowLabel(modelLabel: string, resetsAt?: string): string { + if (!resetsAt) return modelLabel + const resetDate = new Date(resetsAt) + if (Number.isNaN(resetDate.getTime())) return modelLabel + const diffHours = (resetDate.getTime() - Date.now()) / (1000 * 60 * 60) + if (diffHours <= 0) return modelLabel + const windowType = diffHours <= 6 ? "5h window" : diffHours <= 26 ? "daily" : diffHours <= 180 ? "weekly" : `${Math.ceil(diffHours / 24)}d window` + return `${modelLabel} (${windowType})` +} diff --git a/packages/opencode/src/usage/providers/copilot-auth.ts b/packages/opencode/src/usage/providers/copilot-auth.ts new file mode 100644 index 00000000000..52a9e2f9696 --- /dev/null +++ b/packages/opencode/src/usage/providers/copilot-auth.ts @@ -0,0 +1,149 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "@/global" + +// This client ID is specifically for Copilot and grants access to the usage API +// It's different from OpenCode's OAuth client ID +const COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98" +const COPILOT_SCOPE = "read:user" + +export interface CopilotUsageToken { + accessToken: string + scope?: string + createdAt: string +} + +export interface CopilotDeviceCode { + deviceCode: string + userCode: string + verificationUri: string + expiresIn: number + interval: number +} + +interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +interface AccessTokenResponse { + access_token: string + token_type: string + scope: string +} + +interface ErrorResponse { + error: string + error_description?: string +} + +function tokenFilePath(): string { + return path.join(Global.Path.data, "usage-copilot.json") +} + +export async function loadCopilotUsageToken(): Promise { + try { + const data = await fs.readFile(tokenFilePath(), "utf8") + const parsed = JSON.parse(data) as CopilotUsageToken + if (!parsed.accessToken) return null + return parsed + } catch { + return null + } +} + +export async function saveCopilotUsageToken(token: CopilotUsageToken): Promise { + const filePath = tokenFilePath() + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(token, null, 2), "utf8") +} + +export async function requestCopilotDeviceCode(): Promise { + const response = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formEncode({ + client_id: COPILOT_CLIENT_ID, + scope: COPILOT_SCOPE, + }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`Copilot device code request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as DeviceCodeResponse + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresIn: data.expires_in, + interval: data.interval, + } +} + +export async function pollCopilotAccessToken(options: { + deviceCode: string + interval: number + expiresIn: number + onPending?: () => void +}): Promise { + const deadline = Date.now() + options.expiresIn * 1000 + let intervalMs = Math.max(1, options.interval) * 1000 + + while (Date.now() < deadline) { + await Bun.sleep(intervalMs) + options.onPending?.() + + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formEncode({ + client_id: COPILOT_CLIENT_ID, + device_code: options.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }) + + const data = (await response.json()) as AccessTokenResponse | ErrorResponse + + if ("access_token" in data) { + return data + } + + if (data.error === "authorization_pending") { + continue + } + + if (data.error === "slow_down") { + intervalMs += 5000 + continue + } + + if (data.error === "expired_token") { + throw new Error("Copilot device code expired") + } + + throw new Error(data.error_description ?? data.error ?? "Copilot device flow failed") + } + + throw new Error("Copilot device flow timed out") +} + +function formEncode(params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + search.set(key, value) + } + return search.toString() +} diff --git a/packages/opencode/src/usage/providers/copilot.ts b/packages/opencode/src/usage/providers/copilot.ts new file mode 100644 index 00000000000..8e1d27084d9 --- /dev/null +++ b/packages/opencode/src/usage/providers/copilot.ts @@ -0,0 +1,118 @@ +import type { Auth } from "@/auth" +import type { ProviderUsage, RateWindow } from "../types" +import { loadCopilotUsageToken } from "./copilot-auth" + +const USAGE_URL = "https://api.github.com/copilot_internal/user" + +interface CopilotUsageResponse { + quota_snapshots: { + premium_interactions?: CopilotQuotaSnapshot + chat?: CopilotQuotaSnapshot + } + copilot_plan?: string + quota_reset_date?: string +} + +interface CopilotQuotaSnapshot { + entitlement: number + remaining: number + percent_remaining: number + quota_id: string +} + +async function tryFetchWithToken(token: string): Promise<{ ok: true; data: CopilotUsageResponse } | { ok: false; status: number }> { + const response = await fetch(USAGE_URL, { + method: "GET", + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "User-Agent": "GitHubCopilotChat/0.26.7", + "Editor-Version": "vscode/1.96.2", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + }) + + if (response.status === 401 || response.status === 403 || response.status === 404) { + return { ok: false, status: response.status } + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`Copilot usage request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as CopilotUsageResponse + return { ok: true, data } +} + +export async function fetchCopilotUsage(auth: Auth.Info | null): Promise { + // Collect all token candidates + const tokens: string[] = [] + + // 1. Try the usage-specific token first (stored by our device flow) + const usageToken = await loadCopilotUsageToken() + if (usageToken?.accessToken) { + tokens.push(usageToken.accessToken) + } + + // 2. Try tokens from OpenCode's auth system + if (auth?.type === "oauth") { + if (auth.access && !tokens.includes(auth.access)) { + tokens.push(auth.access) + } + if (auth.refresh && !tokens.includes(auth.refresh)) { + tokens.push(auth.refresh) + } + } + + if (tokens.length === 0) { + return { + providerId: "github-copilot", + providerLabel: "GitHub Copilot", + status: "error", + error: "copilot_reauth_required", + } + } + + // Try each token until one works + for (const token of tokens) { + const result = await tryFetchWithToken(token) + if (result.ok) { + const data = result.data + const resetAt = data.quota_reset_date ? new Date(data.quota_reset_date).toISOString() : undefined + return { + providerId: "github-copilot", + providerLabel: "GitHub Copilot", + status: "ok", + primary: toRateWindow(data.quota_snapshots?.premium_interactions, "Premium", resetAt), + secondary: toRateWindow(data.quota_snapshots?.chat, "Chat", resetAt), + plan: formatPlan(data.copilot_plan), + } + } + // If this token failed with auth error, try next one + } + + // All tokens failed + return { + providerId: "github-copilot", + providerLabel: "GitHub Copilot", + status: "error", + error: "copilot_reauth_required", + } +} + +function toRateWindow(snapshot: CopilotQuotaSnapshot | undefined, label: string, resetAt?: string): RateWindow | undefined { + if (!snapshot) return undefined + return { + label, + usedPercent: Math.max(0, 100 - snapshot.percent_remaining), + resetsAt: resetAt, + } +} + +function formatPlan(plan?: string): string | undefined { + if (!plan) return undefined + const trimmed = plan.trim() + return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : undefined +} diff --git a/packages/opencode/src/usage/providers/openai.ts b/packages/opencode/src/usage/providers/openai.ts new file mode 100644 index 00000000000..d7a9bd1b9bb --- /dev/null +++ b/packages/opencode/src/usage/providers/openai.ts @@ -0,0 +1,68 @@ +import type { Auth } from "@/auth" +import type { ProviderUsage, RateWindow } from "../types" + +const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" + +interface CodexUsageResponse { + plan_type?: string + rate_limit?: { + primary_window?: WindowSnapshot + secondary_window?: WindowSnapshot + } +} + +interface WindowSnapshot { + used_percent: number + reset_at: number + limit_window_seconds: number +} + +export async function fetchOpenAIUsage(auth: Auth.Info): Promise { + if (auth.type !== "oauth") { + return { providerId: "openai", providerLabel: "OpenAI/Codex", status: "unsupported", error: "Requires OAuth" } + } + + const response = await fetch(USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.access}`, + Accept: "application/json", + "User-Agent": "opencode", + ...(auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {}), + }, + }) + + if (response.status === 401 || response.status === 403) { + return { + providerId: "openai", + providerLabel: "OpenAI/Codex", + status: "error", + error: "Token expired or invalid. Run /connect to refresh.", + } + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`OpenAI usage request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as CodexUsageResponse + return { + providerId: "openai", + providerLabel: "OpenAI/Codex", + status: "ok", + primary: toRateWindow(data.rate_limit?.primary_window, "Current session"), + secondary: toRateWindow(data.rate_limit?.secondary_window, "Current week"), + plan: data.plan_type, + } +} + +function toRateWindow(snapshot: WindowSnapshot | undefined, label: string): RateWindow | undefined { + if (!snapshot) return undefined + return { + label, + usedPercent: snapshot.used_percent ?? 0, + windowMinutes: snapshot.limit_window_seconds ? snapshot.limit_window_seconds / 60 : undefined, + resetsAt: Number.isFinite(snapshot.reset_at) ? new Date(snapshot.reset_at * 1000).toISOString() : undefined, + } +} diff --git a/packages/opencode/src/usage/types.ts b/packages/opencode/src/usage/types.ts new file mode 100644 index 00000000000..2bcd9e20385 --- /dev/null +++ b/packages/opencode/src/usage/types.ts @@ -0,0 +1,23 @@ +export interface RateWindow { + label: string + usedPercent: number + windowMinutes?: number + resetsAt?: string +} + +export interface ProviderUsage { + providerId: string + providerLabel: string + status: "ok" | "error" | "unsupported" | "unlimited" + primary?: RateWindow + secondary?: RateWindow + tertiary?: RateWindow + error?: string + plan?: string + accountEmail?: string +} + +export interface UsageSnapshot { + providers: ProviderUsage[] + fetchedAt: string +} diff --git a/packages/web/src/assets/sidebar.png b/packages/web/src/assets/sidebar.png new file mode 100644 index 00000000000..9d5126a3a8c Binary files /dev/null and b/packages/web/src/assets/sidebar.png differ diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 085eb6169f8..5b760f92f8b 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -376,6 +376,18 @@ You can customize TUI behavior through your OpenCode config file. --- +## Sidebar + +The TUI includes a session sidebar that displays useful information about your current session. You can toggle the sidebar visibility using the keybind. + +![OpenCode TUI Sidebar](../../assets/sidebar.png) + +**Keybind:** `ctrl+x b` + +The sidebar shows session details and can be toggled on or off based on your preference. + +--- + ## Customization You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts.