Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -48,18 +49,34 @@ 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
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
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()

Expand Down Expand Up @@ -96,6 +113,17 @@ export function Sidebar(props: { sessionID: string }) {
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
<text fg={theme.textMuted}>{cost()} spent</text>
</box>
<Show when={planUsage()?.planUsage}>
{(data) => (
<box>
<text fg={theme.text}>
<b>Plan Usage</b>
</text>
<text fg={theme.textMuted}>{getPercentage(data())}% used</text>
<text fg={theme.textMuted}>{formatResetTime(data().resetAt)}</text>
</box>
)}
</Show>
<Show when={mcpEntries().length > 0}>
<box>
<box
Expand Down
106 changes: 106 additions & 0 deletions packages/opencode/src/provider/plan-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Log } from "@/util/log"

const log = Log.create({ service: "plan-usage" })

/**
* Generic plan usage interface that all providers should implement.
* Each provider converts their specific format to this common representation.
*/
export interface PlanUsage {
used: number
total: number
resetAt?: Date
percentage?: number // Provider-supplied percentage if more accurate than derived
}

export function getPercentage(planUsage: PlanUsage): number {
if (planUsage.percentage !== undefined) return planUsage.percentage
return planUsage.total > 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<PlanUsageData>

const HANDLERS: Record<string, PlanUsageHandler> = {}

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<string, CacheEntry>()
const CACHE_TTL_MS = 60 * 1000

export async function fetchPlanUsage(
providerID: string,
providers: Array<Record<string, unknown>>,
timeout = 5000,
): Promise<PlanUsageData> {
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<Record<string, unknown>>,
timeout = 5000,
): Promise<PlanUsageData> {
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<Record<string, unknown>>) {
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<string, { api?: { url?: string } }> | undefined
const firstModel = models ? Object.values(models)[0] : undefined
const baseURL = firstModel?.api?.url ?? null

return { token, baseURL }
}
115 changes: 115 additions & 0 deletions packages/opencode/src/provider/plan-usage/zai.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, timeout: number): Promise<Response> {
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<string, unknown>

// Check for { data: { limits: [...] } }
if ("data" in candidate && typeof candidate.data === "object" && candidate.data !== null) {
const data = candidate.data as Record<string, unknown>
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)