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 a9ed042d1bb..caa7dd1a59e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,8 +1,9 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, For, Show, Switch, Match, createResource, createEffect } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" +import { fetchPlanUsageWithCache, formatResetTime, getPercentage } from "@/provider/plan-usage" import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Global } from "@/global" @@ -48,8 +49,12 @@ export function Sidebar(props: { sessionID: string }) { }).format(total) }) + const lastAssistantMessage = createMemo( + () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage | undefined, + ) + const context = createMemo(() => { - const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage + const last = lastAssistantMessage() if (!last) return const total = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write @@ -57,9 +62,21 @@ export function Sidebar(props: { sessionID: string }) { return { tokens: total.toLocaleString(), percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, + providerID: last.providerID, } }) + const [planUsage, { refetch }] = createResource( + () => context()?.providerID, + (providerID) => fetchPlanUsageWithCache(providerID, sync.data.provider), + ) + + // Refetch plan usage when messages are added (user or assistant) + createEffect(() => { + messages().length + refetch() + }) + const directory = useDirectory() const kv = useKV() @@ -96,6 +113,17 @@ export function Sidebar(props: { sessionID: string }) { {context()?.percentage ?? 0}% used {cost()} spent + + {(data) => ( + + + Plan Usage + + {getPercentage(data())}% used + {formatResetTime(data().resetAt)} + + )} + 0}> 0 ? Math.round((planUsage.used / planUsage.total) * 100) : 0 +} + +export interface PlanUsageData { + planUsage?: PlanUsage + error?: string +} + +export type PlanUsageHandler = (config: { token: string; baseURL: string; timeout: number }) => Promise + +const HANDLERS: Record = {} + +export function registerProvider(id: string, handler: PlanUsageHandler) { + HANDLERS[id] = handler +} + +// Load built-in providers +import("./plan-usage/zai") + +type CacheEntry = { data: PlanUsageData; time: number } +const cache = new Map() +const CACHE_TTL_MS = 60 * 1000 + +export async function fetchPlanUsage( + providerID: string, + providers: Array>, + timeout = 5000, +): Promise { + const config = await getProviderConfig(providerID, providers) + if (!config.token) { + log.debug("No API key configured for provider", { providerID }) + return { error: `No API key configured for provider: ${providerID}` } + } + if (!config.baseURL) { + log.debug("No base URL configured for provider", { providerID }) + return { error: `No base URL configured for provider: ${providerID}` } + } + + const handler = HANDLERS[providerID] + if (!handler) { + log.debug("No plan usage handler for provider", { providerID }) + return { error: `No plan usage handler for provider: ${providerID}` } + } + + return handler({ token: config.token, baseURL: config.baseURL, timeout }) +} + +export async function fetchPlanUsageWithCache( + providerID: string, + providers: Array>, + timeout = 5000, +): Promise { + const key = `planUsage:${providerID}` + const cached = cache.get(key) + if (cached && Date.now() - cached.time < CACHE_TTL_MS) return cached.data + + const data = await fetchPlanUsage(providerID, providers, timeout) + cache.set(key, { data, time: Date.now() }) + return data +} + +export function formatResetTime(date?: Date): string | null { + if (!date) return null + + const now = Date.now() + const remaining = Math.max(0, date.getTime() - now) / 1000 + const h = Math.floor(remaining / 3600) + const m = Math.floor((remaining % 3600) / 60) + const s = Math.floor(remaining % 60) + + if (h > 24) return `Resets in ${Math.floor(h / 24)}d ${h % 24}h` + if (h > 0) return `Resets in ${h}h ${m}m` + if (m > 0) return `Resets in ${m}m` + if (s > 0) return `Resets in ${s}s` + return "Resets soon" +} + +async function getProviderConfig(providerID: string, providers: Array>) { + const { Auth } = await import("@/auth") + const auth = await Auth.get(providerID) + const token = auth?.type === "api" ? auth.key : null + + const provider = providers.find((p) => p.id === providerID) + const models = provider?.models as Record | undefined + const firstModel = models ? Object.values(models)[0] : undefined + const baseURL = firstModel?.api?.url ?? null + + return { token, baseURL } +} diff --git a/packages/opencode/src/provider/plan-usage/zai.ts b/packages/opencode/src/provider/plan-usage/zai.ts new file mode 100644 index 00000000000..23727849eb7 --- /dev/null +++ b/packages/opencode/src/provider/plan-usage/zai.ts @@ -0,0 +1,115 @@ +import { Log } from "@/util/log" +import { registerProvider, type PlanUsageHandler } from "../plan-usage" + +const log = Log.create({ service: "zai-plan-usage" }) + +// z.ai specific types +interface ZaiPlanUsageLimit { + type: string + unit: number + number: number + usage: number + currentValue: number + remaining: number + percentage: number + nextResetTime?: number +} + +interface ZaiPlanUsageResponse { + data?: { limits?: ZaiPlanUsageLimit[] } + limits?: ZaiPlanUsageLimit[] +} + +function selectLimit(limits: ZaiPlanUsageLimit[]): ZaiPlanUsageLimit | null { + return limits.find((l) => l.type === "TOKENS_LIMIT") ?? null +} + +function fetchWithTimeout(url: string, headers: Record, timeout: number): Promise { + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), timeout) + return fetch(url, { + method: "GET", + headers, + signal: controller.signal, + }).then((response) => { + clearTimeout(id) + return response + }) +} + +async function parsePlanUsageResponse( + response: Response, +): Promise<{ used: number; total: number; resetAt?: Date; percentage?: number } | { error: string }> { + if (!response.ok) { + const text = await response.text() + return { error: `HTTP ${response.status}: ${text}` } + } + + const json = (await response.json()) as unknown + const limits = extractLimits(json) + + if (!limits || !Array.isArray(limits) || limits.length === 0) { + return { error: "Invalid plan usage response format" } + } + + const limit = selectLimit(limits) + if (!limit) { + return { error: "No token limit found in response" } + } + + return { + used: limit.usage, + total: limit.number, + resetAt: limit.nextResetTime ? new Date(limit.nextResetTime) : undefined, + percentage: limit.percentage, + } +} + +function extractLimits(json: unknown): ZaiPlanUsageLimit[] | undefined { + if (typeof json !== "object" || json === null) return undefined + + const candidate = json as Record + + // Check for { data: { limits: [...] } } + if ("data" in candidate && typeof candidate.data === "object" && candidate.data !== null) { + const data = candidate.data as Record + if ("limits" in data && Array.isArray(data.limits)) { + return data.limits as ZaiPlanUsageLimit[] + } + } + + // Check for { limits: [...] } + if ("limits" in candidate && Array.isArray(candidate.limits)) { + return candidate.limits as ZaiPlanUsageLimit[] + } + + return undefined +} + +const zaiPlanUsageHandler: PlanUsageHandler = async ({ token, baseURL, timeout }) => { + if (!token) return { error: "No API key configured" } + if (!baseURL) return { error: "No base URL configured" } + + const url = new URL(baseURL) + const endpoint = `${url.origin}/api/monitor/usage/quota/limit` + + return fetchWithTimeout( + endpoint, + { + Authorization: token, + "Accept-Language": "en-US,en", + "Content-Type": "application/json", + }, + timeout, + ) + .then(parsePlanUsageResponse) + .then((result) => ("error" in result ? { error: result.error } : { planUsage: result })) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err) + log.debug("Failed to fetch plan usage", { provider: "zai-coding-plan", error: msg }) + return { error: msg } + }) +} + +// Register on import +registerProvider("zai-coding-plan", zaiPlanUsageHandler)