feat(usage): show provider quota in session status (#584)

## Summary
- Add provider quota usage to an expanded-by-default subsection in the
session Status panel.
- Resolve the active provider and model automatically from the current
session.
- Display localized quota windows, reset times, values, and color-coded
progress bars inside the same bordered rounded container used by
neighboring subsections.

## Server implementation
- Add GET /api/usage/:providerId with optional modelId filtering.
- Add a 60-second in-memory cache and deduplicate concurrent provider
requests.
- Read credentials only on the server and never return secrets through
the API.
- Support Claude, Codex, GitHub Copilot, Google/Gemini/Antigravity,
Kimi, NanoGPT, OpenRouter, z.ai, Zhipu, MiniMax global/CN, Cursor,
Ollama Cloud, Wafer, and OpenCode Go.
- Use existing OpenCode and Antigravity access tokens for Google calls;
optional environment-provided OAuth client values are required only for
token refresh.

## UI behavior
- Keep Provider usage open by default while allowing it to collapse like
the other Status subsections.
- Show loading, unsupported, not configured, unavailable, and no-session
states.
- Localize all user-visible strings across the nine existing locales.

## Attribution
- Include the MIT notice for the OpenChamber usage-provider
implementation used as the reference.

## Validation
- Server typecheck
- UI typecheck
- 7 targeted usage tests
- Production UI build
- Server build and npm package-content verification
- Tauri release build without bundling
- git diff checks
<img width="484" height="444" alt="image"
src="https://github.com/user-attachments/assets/69afbaf8-4c9a-4cb3-b316-e5788e416141"
/>
This commit is contained in:
Pascal André 2026-07-15 11:25:00 +02:00 • committed by GitHub
parent e815eba872
commit 709557d12d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1650 additions and 1 deletions

28
THIRD_PARTY_NOTICES.md Normal file
View file

@ -0,0 +1,28 @@
# Third-Party Notices
## OpenChamber
The provider usage adapters are derived from OpenChamber:
https://github.com/openchamber/openchamber
MIT License
Copyright (c) 2025 Bohdan Triapitsyn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
### Provider Plan Usage
The Status panel automatically displays quota information for the provider used by the active session. CodeNomad reads existing OpenCode credentials and never returns provider secrets through its API.
Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables:
- Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`
- Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`
- Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN`
- Ollama Cloud: `OLLAMA_CLOUD_COOKIE`
- OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`

View file

@ -0,0 +1,26 @@
# Third-Party Notices
The provider usage adapters are derived from OpenChamber:
https://github.com/openchamber/openchamber
MIT License
Copyright (c) 2025 Bohdan Triapitsyn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -67,6 +67,26 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus
}
export interface ProviderUsageWindow {
usedPercent: number | null
remainingPercent: number | null
windowSeconds: number | null
resetAt: number | null
valueLabel?: string
}
export interface ProviderUsageResponse {
requestedProviderId: string
providerId: string | null
providerName: string
modelId?: string
supported: boolean
configured: boolean
ok: boolean
windows: Record<string, ProviderUsageWindow>
fetchedAt: number
}
export type WorktreeKind = "root" | "worktree"
export interface WorktreeDescriptor {

View file

@ -29,6 +29,7 @@ import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { registerPreviewRoutes } from "./routes/previews"
import { registerUsageRoutes } from "./routes/usage"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
@ -302,6 +303,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerPreviewRoutes(app, { previewManager: deps.previewManager })
registerUsageRoutes(app)
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger })
setupSideCarWebSocketProxy(app, {

View file

@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import { registerUsageRoutes } from "./usage"
test("returns a typed unsupported result without exposing server details", async () => {
const app = Fastify()
registerUsageRoutes(app)
try {
const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" })
assert.equal(response.statusCode, 200)
assert.deepEqual(response.json(), {
requestedProviderId: "unknown-provider",
providerId: null,
providerName: "unknown-provider",
modelId: "test-model",
supported: false,
configured: false,
ok: false,
windows: {},
fetchedAt: response.json().fetchedAt,
})
} finally {
await app.close()
}
})

View file

@ -0,0 +1,24 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import { getProviderUsage } from "../../usage/service"
const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) })
const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() })
export function registerUsageRoutes(app: FastifyInstance) {
app.get<{ Params: { providerId: string }; Querystring: { modelId?: string } }>(
"/api/usage/:providerId",
async (request, reply) => {
try {
const params = UsageParamsSchema.parse(request.params)
const query = UsageQuerySchema.parse(request.query ?? {})
return await getProviderUsage(params.providerId, { modelId: query.modelId })
} catch (error) {
request.log.error({ err: error }, "Failed to fetch provider usage")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" }
}
},
)
}

View file

@ -0,0 +1,182 @@
import type { UsageProvider } from "../types"
import {
fetchJson,
getCredential,
notConfigured,
resolveWindowLabel,
safeFetch,
toNumber,
toTimestamp,
toUsageWindow,
} from "../shared"
const kimiAliases = ["kimi-for-coding", "kimi"] as const
const kimi: UsageProvider = {
id: "kimi-for-coding",
name: "Kimi for Coding",
aliases: kimiAliases,
async fetchQuota() {
const key = getCredential(kimiAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://api.kimi.com/coding/v1/usages", {
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
const usage = payload?.usage
if (usage) {
const limit = toNumber(usage.limit)
const remaining = toNumber(usage.remaining)
windows.weekly = toUsageWindow({
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
resetAt: toTimestamp(usage.resetTime),
})
}
for (const item of Array.isArray(payload?.limits) ? payload.limits : []) {
const duration = toNumber(item?.window?.duration)
const unit = item?.window?.timeUnit
const multiplier = unit === "TIME_UNIT_MINUTE" ? 60 : unit === "TIME_UNIT_HOUR" ? 3600 : unit === "TIME_UNIT_DAY" ? 86_400 : null
const seconds = duration !== null && multiplier ? duration * multiplier : null
const limit = toNumber(item?.detail?.limit)
const remaining = toNumber(item?.detail?.remaining)
windows[resolveWindowLabel(seconds)] = toUsageWindow({
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
windowSeconds: seconds,
resetAt: toTimestamp(item?.detail?.resetTime),
})
}
return { windows }
})
},
}
const nanoAliases = ["nano-gpt", "nanogpt", "nano_gpt"] as const
const nanoGpt: UsageProvider = {
id: "nano-gpt",
name: "NanoGPT",
aliases: nanoAliases,
async fetchQuota() {
const key = getCredential(nanoAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://nano-gpt.com/api/subscription/v1/usage", {
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [label, source] of [["daily", payload?.daily], ["monthly", payload?.monthly]] as const) {
if (!source) continue
const fraction = toNumber(source.percentUsed)
const used = toNumber(source.used)
const limit = toNumber(source.limit ?? source.limits?.[label])
windows[label] = toUsageWindow({
usedPercent: fraction !== null ? fraction * 100 : used !== null && limit ? (used / limit) * 100 : null,
windowSeconds: label === "daily" ? 86_400 : null,
resetAt: toTimestamp(source.resetAt ?? payload?.period?.currentPeriodEnd),
})
}
return { windows }
})
},
}
const openRouterAliases = ["openrouter"] as const
const openRouter: UsageProvider = {
id: "openrouter",
name: "OpenRouter",
aliases: openRouterAliases,
async fetchQuota() {
const key = getCredential(openRouterAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://openrouter.ai/api/v1/credits", {
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
})
const total = toNumber(payload?.data?.total_credits)
const used = toNumber(payload?.data?.total_usage)
const remaining = total !== null && used !== null ? Math.max(0, total - used) : null
return {
windows: {
credits: toUsageWindow({
usedPercent: total && used !== null ? (used / total) * 100 : null,
valueLabel: remaining !== null && total !== null ? `$${remaining.toFixed(2)} / $${total.toFixed(2)}` : null,
}),
},
}
})
},
}
function createTokenLimitProvider(input: { id: string; name: string; aliases: readonly string[]; url: string }): UsageProvider {
return {
id: input.id,
name: input.name,
aliases: input.aliases,
async fetchQuota() {
const key = getCredential(input.aliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson(input.url, { headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" } })
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const limit of Array.isArray(payload?.data?.limits) ? payload.data.limits : []) {
if (limit?.type !== "TOKENS_LIMIT" && limit?.type !== "TIME_LIMIT") continue
const duration = toNumber(limit.number)
const seconds = limit.type === "TIME_LIMIT" ? 30 * 86_400 : limit.unit === 3 && duration ? duration * 3600 : null
const label = limit.type === "TIME_LIMIT" ? "mcp-tools" : resolveWindowLabel(seconds)
windows[label] = toUsageWindow({
usedPercent: toNumber(limit.percentage),
windowSeconds: seconds,
resetAt: toTimestamp(limit.nextResetTime),
})
}
return { windows }
})
},
}
}
const zai = createTokenLimitProvider({
id: "zai-coding-plan",
name: "z.ai",
aliases: ["zai-coding-plan", "zai", "z.ai"],
url: "https://api.z.ai/api/monitor/usage/quota/limit",
})
const zhipu = createTokenLimitProvider({
id: "zhipuai-coding-plan",
name: "Zhipu AI Coding Plan",
aliases: ["zhipuai-coding-plan", "zhipuai", "zhipu"],
url: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
})
const waferAliases = ["wafer", "wafer-ai", "wafer_ai", "wafer.ai"] as const
const wafer: UsageProvider = {
id: "wafer",
name: "Wafer.ai",
aliases: waferAliases,
async fetchQuota() {
const key = getCredential(waferAliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://pass.wafer.ai/v1/inference/quota", {
headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" },
})
const remaining = toNumber(payload?.remaining_included_requests)
const limit = toNumber(payload?.included_request_limit)
const startAt = toTimestamp(payload?.window_start)
const resetAt = toTimestamp(payload?.window_end)
const seconds = startAt !== null && resetAt !== null ? Math.round((resetAt - startAt) / 1000) : 5 * 3600
return {
windows: {
[resolveWindowLabel(seconds)]: toUsageWindow({
usedPercent: toNumber(payload?.current_period_used_percent),
windowSeconds: seconds,
resetAt,
valueLabel: remaining !== null && limit !== null ? `${remaining} / ${limit}` : null,
}),
},
}
})
},
}
export const apiKeyProviders: UsageProvider[] = [kimi, nanoGpt, openRouter, zai, zhipu, wafer]

View file

@ -0,0 +1,179 @@
import fs from "fs"
import os from "os"
import path from "path"
import type { UsageProvider } from "../types"
import { asObject, fetchJson, getAuthEntry, getString, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared"
const GOOGLE_ENDPOINTS = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
] as const
const DEFAULT_PROJECT_ID = "rising-fact-p41fc"
interface GoogleSource {
id: "gemini" | "antigravity"
accessToken?: string
refreshToken?: string
projectId?: string
expires?: number | null
}
function parseRefresh(value: unknown): { token?: string; projectId?: string } {
const raw = getString(value)
if (!raw) return {}
const [token, projectId, managedProjectId] = raw.split("|")
return { token: getString(token) ?? undefined, projectId: getString(projectId) ?? getString(managedProjectId) ?? undefined }
}
function readJson(filePath: string): any | null {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"))
} catch {
return null
}
}
function googleSources(): GoogleSource[] {
const sources: GoogleSource[] = []
const entry = asObject(getAuthEntry(["google", "google.oauth"]))
const oauth = asObject(entry?.oauth) ?? entry
if (oauth) {
const refresh = parseRefresh(oauth.refresh)
const accessToken = getString(oauth.access) ?? getString(oauth.token) ?? undefined
if (accessToken || refresh.token) {
sources.push({
id: "gemini",
accessToken,
refreshToken: refresh.token,
projectId: refresh.projectId,
expires: toTimestamp(oauth.expires),
})
}
}
const home = os.homedir()
for (const candidate of [
path.join(home, ".config", "opencode", "antigravity-accounts.json"),
path.join(home, ".local", "share", "opencode", "antigravity-accounts.json"),
]) {
const data = readJson(candidate)
const accounts = Array.isArray(data?.accounts) ? data.accounts : []
const account = accounts[data?.activeIndex ?? 0] ?? accounts[0]
const refresh = parseRefresh(account?.refreshToken)
const accessToken = getString(account?.accessToken) ?? getString(account?.access_token) ?? undefined
if (accessToken || refresh.token) {
sources.push({
id: "antigravity",
accessToken,
refreshToken: refresh.token,
projectId: getString(account?.projectId) ?? refresh.projectId,
expires: toTimestamp(account?.expiresAt ?? account?.expires),
})
break
}
}
return sources
}
async function refreshAccessToken(source: GoogleSource): Promise<string | null> {
if (!source.refreshToken) return null
const prefix = source.id === "gemini" ? "GOOGLE" : "ANTIGRAVITY"
const clientId = getString(process.env[`${prefix}_OAUTH_CLIENT_ID`])
const clientSecret = getString(process.env[`${prefix}_OAUTH_CLIENT_SECRET`])
if (!clientId || !clientSecret) return null
const payload = await fetchJson("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: source.refreshToken,
grant_type: "refresh_token",
}),
})
return getString(payload?.access_token)
}
function transformModel(sourceId: string, modelId: string, data: any) {
const fraction = toNumber(data?.quotaInfo?.remainingFraction ?? data?.remainingFraction)
const resetAt = toTimestamp(data?.quotaInfo?.resetTime ?? data?.resetTime)
const label = sourceId === "gemini" || (resetAt !== null && resetAt - Date.now() > 10 * 3600 * 1000) ? "daily" : "5h"
return {
[`${sourceId}/${modelId}`]: {
windows: {
[label]: toUsageWindow({
usedPercent: fraction === null ? null : 100 - fraction * 100,
windowSeconds: label === "daily" ? 86_400 : 5 * 3600,
resetAt,
}),
},
},
}
}
const google: UsageProvider = {
id: "google",
name: "Google",
aliases: ["google", "google.oauth", "gemini", "antigravity"],
async fetchQuota() {
const sources = googleSources()
if (sources.length === 0) return notConfigured(this.id, this.name)
const models: Record<string, { windows: Record<string, ReturnType<typeof toUsageWindow>> }> = {}
try {
for (const source of sources) {
const accessToken = source.accessToken && (!source.expires || source.expires > Date.now())
? source.accessToken
: await refreshAccessToken(source)
if (!accessToken) continue
const projectId = source.projectId ?? DEFAULT_PROJECT_ID
if (source.id === "gemini") {
try {
const quota = await fetchJson(`${GOOGLE_ENDPOINTS[2]}/v1internal:retrieveUserQuota`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ project: projectId }),
})
for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) {
const modelId = getString(bucket?.modelId)
if (modelId) Object.assign(models, transformModel(source.id, modelId, bucket))
}
} catch {
// The model endpoint below often remains available when quota buckets are not.
}
}
for (const endpoint of GOOGLE_ENDPOINTS) {
try {
const payload = await fetchJson(`${endpoint}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "antigravity/1.11.5 windows/amd64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
},
body: JSON.stringify({ project: projectId }),
})
for (const [modelId, data] of Object.entries(payload?.models ?? {})) {
Object.assign(models, transformModel(source.id, modelId, data))
}
break
} catch {
continue
}
}
}
if (Object.keys(models).length === 0) throw new Error("No model quota data available")
return result(this.id, this.name, { ok: true, configured: true, usage: { windows: {}, models } })
} catch (error) {
return result(this.id, this.name, {
ok: false,
configured: true,
error: error instanceof Error ? error.message : "Request failed",
})
}
},
}
export const googleProviders: UsageProvider[] = [google]

View file

@ -0,0 +1,116 @@
import type { UsageProvider } from "../types"
import { fetchJson, getCredential, notConfigured, result, toNumber, toTimestamp, toUsageWindow } from "../shared"
const INACTIVE_WINDOW_STATUS = 3
function pickChatModel(models: any[]): any | null {
return (
models.find((model) => /^minimax-m/i.test(String(model?.model_name ?? "")) && (toNumber(model?.current_interval_total_count) ?? 0) > 0) ??
models.find((model) => ["general", "chat", "text"].includes(String(model?.model_name ?? "").toLowerCase())) ??
models.find((model) => toNumber(model?.current_interval_remaining_percent) !== null) ??
models[0] ??
null
)
}
function isUsablePayload(payload: any): boolean {
if (payload?.base_resp && payload.base_resp.status_code !== 0) return false
return Array.isArray(payload?.model_remains) && payload.model_remains.length > 0
}
function usedPercent(model: any, prefix: "interval" | "weekly", tokenPlan: boolean): number | null {
const remainingPercent = toNumber(model?.[`current_${prefix}_remaining_percent`])
if (remainingPercent !== null) return 100 - remainingPercent
const total = toNumber(model?.[`current_${prefix}_total_count`])
const rawUsage = toNumber(model?.[`current_${prefix}_usage_count`])
if (!total || rawUsage === null) return null
const used = tokenPlan ? total - rawUsage : rawUsage
return (Math.max(0, used) / total) * 100
}
function windowSeconds(model: any, prefix: "interval" | "weekly"): number | null {
const startAt = toTimestamp(prefix === "interval" ? model?.start_time : model?.weekly_start_time)
const resetAt = toTimestamp(prefix === "interval" ? model?.end_time : model?.weekly_end_time)
if (startAt !== null && resetAt !== null && resetAt > startAt) return Math.floor((resetAt - startAt) / 1000)
const remainsMs = toNumber(prefix === "interval" ? model?.remains_time : model?.weekly_remains_time)
return remainsMs && remainsMs > 0 ? Math.floor(remainsMs / 1000) : null
}
function createMiniMaxProvider(input: {
id: string
name: string
aliases: readonly string[]
tokenPlanUrl: string
codingPlanUrl: string
}): UsageProvider {
return {
id: input.id,
name: input.name,
aliases: input.aliases,
async fetchQuota() {
const key = getCredential(input.aliases, ["key", "token"])
if (!key) return notConfigured(this.id, this.name)
try {
let tokenPlan = true
let payload: any = null
try {
const tokenPayload = await fetchJson(input.tokenPlanUrl, { headers: { Authorization: `Bearer ${key}` } })
if (isUsablePayload(tokenPayload)) payload = tokenPayload
} catch {
// Fall back to the legacy Coding Plan endpoint below.
}
if (!payload) {
tokenPlan = false
const codingPayload = await fetchJson(input.codingPlanUrl, { headers: { Authorization: `Bearer ${key}` } })
if (isUsablePayload(codingPayload)) payload = codingPayload
}
if (!payload) throw new Error("Provider returned no usable quota data")
const model = pickChatModel(Array.isArray(payload?.model_remains) ? payload.model_remains : [])
if (!model) throw new Error("No model quota data available")
const intervalResetAt = toTimestamp(model?.end_time)
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {
"5h": toUsageWindow({
usedPercent: usedPercent(model, "interval", tokenPlan),
windowSeconds: windowSeconds(model, "interval"),
resetAt: intervalResetAt,
}),
}
const weeklyStatus = toNumber(model?.current_weekly_status)
const hasWeekly = weeklyStatus !== INACTIVE_WINDOW_STATUS && (
toNumber(model?.current_weekly_remaining_percent) !== null || (toNumber(model?.current_weekly_total_count) ?? 0) > 0
)
if (hasWeekly) {
windows.weekly = toUsageWindow({
usedPercent: usedPercent(model, "weekly", tokenPlan),
windowSeconds: windowSeconds(model, "weekly"),
resetAt: toTimestamp(model?.weekly_end_time),
})
}
return result(this.id, this.name, { ok: true, configured: true, usage: { windows } })
} catch (error) {
return result(this.id, this.name, {
ok: false,
configured: true,
error: error instanceof Error ? error.message : "Request failed",
})
}
},
}
}
export const miniMaxProviders: UsageProvider[] = [
createMiniMaxProvider({
id: "minimax-coding-plan",
name: "MiniMax Coding Plan",
aliases: ["minimax-coding-plan", "minimax"],
tokenPlanUrl: "https://api.minimax.io/v1/token_plan/remains",
codingPlanUrl: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
}),
createMiniMaxProvider({
id: "minimax-cn-coding-plan",
name: "MiniMax Coding Plan CN",
aliases: ["minimax-cn-coding-plan", "minimaxi"],
tokenPlanUrl: "https://api.minimaxi.com/v1/token_plan/remains",
codingPlanUrl: "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains",
}),
]

View file

@ -0,0 +1,120 @@
import type { UsageProvider } from "../types"
import {
fetchJson,
getAuthEntry,
getCredential,
notConfigured,
resolveWindowLabel,
safeFetch,
toNumber,
toTimestamp,
toUsageWindow,
} from "../shared"
const claudeAliases = ["anthropic", "claude"] as const
const claude: UsageProvider = {
id: "claude",
name: "Claude",
aliases: claudeAliases,
async fetchQuota() {
const token = getCredential(claudeAliases, ["access", "token"])
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://api.anthropic.com/api/oauth/usage", {
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [key, source] of [
["5h", payload?.five_hour],
["7d", payload?.seven_day],
["7d-sonnet", payload?.seven_day_sonnet],
["7d-opus", payload?.seven_day_opus],
] as const) {
if (!source) continue
windows[key] = toUsageWindow({ usedPercent: toNumber(source.utilization), resetAt: toTimestamp(source.resets_at) })
}
return { windows }
})
},
}
const codexAliases = ["openai", "codex", "chatgpt"] as const
const codex: UsageProvider = {
id: "codex",
name: "Codex",
aliases: codexAliases,
async fetchQuota() {
const entry = getAuthEntry(codexAliases)
const token = entry && (typeof entry.access === "string" ? entry.access : typeof entry.token === "string" ? entry.token : null)
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const accountId = typeof entry?.accountId === "string" ? entry.accountId : null
const payload = await fetchJson("https://chatgpt.com/backend-api/wham/usage", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
},
})
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const source of [payload?.rate_limit?.primary_window, payload?.rate_limit?.secondary_window]) {
if (!source) continue
const seconds = toNumber(source.limit_window_seconds)
windows[resolveWindowLabel(seconds)] = toUsageWindow({
usedPercent: toNumber(source.used_percent),
windowSeconds: seconds,
resetAt: toTimestamp(source.reset_at),
})
}
const balance = toNumber(payload?.credits?.balance)
if (payload?.credits?.unlimited || balance !== null) {
windows.credits_balance = toUsageWindow({
usedPercent: null,
valueLabel: payload?.credits?.unlimited ? "∞" : `$${balance!.toFixed(2)}`,
})
}
return { windows }
})
},
}
const copilotAliases = ["github-copilot", "copilot"] as const
const copilot: UsageProvider = {
id: "github-copilot",
name: "GitHub Copilot",
aliases: copilotAliases,
async fetchQuota() {
const token = getCredential(copilotAliases, ["access", "token"])
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const payload = await fetchJson("https://api.github.com/copilot_internal/user", {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"Editor-Version": "vscode/1.96.2",
"X-Github-Api-Version": "2025-04-01",
},
})
const resetAt = toTimestamp(payload?.quota_reset_date)
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [key, snapshot] of Object.entries({
chat: payload?.quota_snapshots?.chat,
completions: payload?.quota_snapshots?.completions,
premium: payload?.quota_snapshots?.premium_interactions,
})) {
if (!snapshot) continue
const source = snapshot as Record<string, unknown>
const entitlement = toNumber(source.entitlement)
const remaining = toNumber(source.remaining)
windows[key] = toUsageWindow({
usedPercent: entitlement && remaining !== null ? 100 - (remaining / entitlement) * 100 : null,
resetAt,
valueLabel: entitlement !== null && remaining !== null ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)}` : null,
})
}
return { windows }
})
},
}
export const oauthProviders: UsageProvider[] = [claude, codex, copilot]

View file

@ -0,0 +1,145 @@
import type { UsageProvider } from "../types"
import { fetchJson, getString, notConfigured, safeFetch, toNumber, toTimestamp, toUsageWindow } from "../shared"
function decodeJwtExpiration(token: string): number | null {
try {
const payload = token.split(".")[1]
if (!payload) return null
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
return typeof parsed?.exp === "number" ? parsed.exp * 1000 : null
} catch {
return null
}
}
async function resolveCursorToken(): Promise<string | null> {
const accessToken = getString(process.env.CURSOR_ACCESS_TOKEN) ?? getString(process.env.CURSOR_TOKEN)
const refreshToken = getString(process.env.CURSOR_REFRESH_TOKEN)
const expiresAt = accessToken ? decodeJwtExpiration(accessToken) : null
if (accessToken && (!expiresAt || expiresAt > Date.now() + 5 * 60_000)) return accessToken
if (!refreshToken) return accessToken
const payload = await fetchJson("https://api2.cursor.sh/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "refresh_token", client_id: "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB", refresh_token: refreshToken }),
})
return getString(payload?.access_token)
}
async function cursorPost(url: string, token: string): Promise<any> {
return fetchJson(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
body: "{}",
})
}
const cursor: UsageProvider = {
id: "cursor",
name: "Cursor",
aliases: ["cursor"],
async fetchQuota() {
const token = await resolveCursorToken().catch(() => null)
if (!token) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const usage = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", token)
const plan = await cursorPost("https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo", token).catch(() => null)
const source = usage?.planUsage ?? {}
const limit = toNumber(source.limit)
const remaining = toNumber(source.remaining)
const explicit = toNumber(source.totalPercentUsed)
const resetAt = toTimestamp(usage?.billingCycleEnd ?? plan?.planInfo?.billingCycleEnd)
return {
windows: {
billing_cycle: toUsageWindow({
usedPercent: explicit ?? (limit && remaining !== null ? ((limit - remaining) / limit) * 100 : null),
resetAt,
valueLabel: toNumber(source.totalSpend) !== null ? `$${(toNumber(source.totalSpend)! / 100).toFixed(2)}` : null,
}),
},
}
})
},
}
function parseOllamaUsage(html: string) {
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [label, pattern] of [
["session", /Session\s+usage[^0-9]*([0-9.]+)%/i],
["weekly", /Weekly\s+usage[^0-9]*([0-9.]+)%/i],
] as const) {
const match = html.match(pattern)
if (match) windows[label] = toUsageWindow({ usedPercent: toNumber(match[1]) })
}
const premium = html.match(/Premium[^0-9]*([0-9]+)\s*\/\s*([0-9]+)/i)
if (premium) {
const used = toNumber(premium[1])
const total = toNumber(premium[2])
windows.premium = toUsageWindow({
usedPercent: total && used !== null ? (used / total) * 100 : null,
valueLabel: used !== null && total !== null ? `${used} / ${total}` : null,
})
}
return windows
}
const ollamaCloud: UsageProvider = {
id: "ollama-cloud",
name: "Ollama Cloud",
aliases: ["ollama-cloud", "ollamacloud"],
async fetchQuota() {
const cookie = getString(process.env.OLLAMA_CLOUD_COOKIE)
if (!cookie) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const response = await fetch("https://ollama.com/settings", {
headers: { Cookie: cookie, "User-Agent": "CodeNomad usage provider" },
redirect: "manual",
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
const windows = parseOllamaUsage(await response.text())
if (Object.keys(windows).length === 0) throw new Error("No usage data available")
return { windows }
})
},
}
function parseOpenCodeGoUsage(html: string, now = Date.now()) {
const normalized = html.replace(/&quot;|&#34;|\\u0022|\\"/g, '"')
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
for (const [label, field] of Object.entries({ "5h": "rollingUsage", weekly: "weeklyUsage", monthly: "monthlyUsage" })) {
const match = normalized.match(new RegExp(`["']?${field}["']?\\s*:\\s*(?:\\$R\\[\\d+\\]\\s*=\\s*)?\\{([^{}]*)\\}`, "s"))
if (!match) continue
const capture = (name: string) => toNumber(match[1]?.match(new RegExp(`["']?${name}["']?\\s*:\\s*["']?(-?\\d+(?:\\.\\d+)?)`))?.[1])
const usedPercent = capture("usagePercent")
const resetInSec = capture("resetInSec")
if (usedPercent !== null && resetInSec !== null) {
windows[label] = toUsageWindow({ usedPercent, resetAt: now + Math.max(0, resetInSec) * 1000 })
}
}
return windows
}
const openCodeGo: UsageProvider = {
id: "opencode-go",
name: "OpenCode Go",
aliases: ["opencode-go", "opencode"],
async fetchQuota() {
const workspaceId = getString(process.env.OPENCODE_GO_WORKSPACE_ID)
const authCookie = getString(process.env.OPENCODE_GO_AUTH_COOKIE)
if (!workspaceId || !authCookie) return notConfigured(this.id, this.name)
return safeFetch(this.id, this.name, async () => {
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`, {
headers: { Cookie: `auth=${authCookie.replace(/^auth=/, "")}`, "User-Agent": "CodeNomad usage provider" },
redirect: "manual",
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
const windows = parseOpenCodeGoUsage(await response.text())
if (Object.keys(windows).length === 0) throw new Error("No usage data available")
return { windows }
})
},
}
export const specialProviders: UsageProvider[] = [cursor, ollamaCloud, openCodeGo]

View file

@ -0,0 +1,67 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getProviderUsage, resolveUsageProvider, selectModelWindows } from "./service"
import type { ProviderResult } from "./types"
const providerIds = [
"claude",
"codex",
"github-copilot",
"google",
"kimi-for-coding",
"nano-gpt",
"openrouter",
"zai-coding-plan",
"zhipuai-coding-plan",
"minimax-coding-plan",
"minimax-cn-coding-plan",
"ollama-cloud",
"wafer",
"opencode-go",
"cursor",
]
test("registers every supported usage provider", () => {
for (const providerId of providerIds) {
assert.equal(resolveUsageProvider(providerId)?.id, providerId)
}
})
test("maps OpenCode provider aliases to their usage provider", () => {
assert.equal(resolveUsageProvider("anthropic")?.id, "claude")
assert.equal(resolveUsageProvider("openai")?.id, "codex")
assert.equal(resolveUsageProvider("copilot")?.id, "github-copilot")
assert.equal(resolveUsageProvider("gemini")?.id, "google")
assert.equal(resolveUsageProvider("opencode")?.id, "opencode-go")
})
test("selects model-specific windows by normalized model id", () => {
const result: ProviderResult = {
providerId: "google",
providerName: "Google",
ok: true,
configured: true,
fetchedAt: 1,
usage: {
windows: {},
models: {
"gemini/gemini-2.5-flash": {
windows: {
daily: { usedPercent: 25, remainingPercent: 75, windowSeconds: 86_400, resetAt: null },
},
},
},
},
}
assert.equal(selectModelWindows(result, "models/gemini-2.5-flash").daily?.usedPercent, 25)
})
test("returns a stable unsupported response without making a provider request", async () => {
const response = await getProviderUsage("unknown-provider", { modelId: "model" })
assert.equal(response.supported, false)
assert.equal(response.configured, false)
assert.equal(response.providerId, null)
assert.deepEqual(response.windows, {})
})

View file

@ -0,0 +1,91 @@
import type { ProviderUsageResponse, ProviderUsageWindow } from "../api-types"
import { apiKeyProviders } from "./providers/api-key"
import { googleProviders } from "./providers/google"
import { miniMaxProviders } from "./providers/minimax"
import { oauthProviders } from "./providers/oauth"
import { specialProviders } from "./providers/special"
import type { ProviderResult, UsageProvider } from "./types"
const CACHE_TTL_MS = 60_000
const providers = [...oauthProviders, ...apiKeyProviders, ...miniMaxProviders, ...googleProviders, ...specialProviders]
const registry = new Map<string, UsageProvider>()
for (const provider of providers) {
registry.set(provider.id, provider)
for (const alias of provider.aliases) registry.set(alias.toLowerCase(), provider)
}
const cache = new Map<string, { result: ProviderResult; expiresAt: number }>()
const pending = new Map<string, Promise<ProviderResult>>()
export function resolveUsageProvider(providerId: string): UsageProvider | null {
return registry.get(providerId.trim().toLowerCase()) ?? null
}
function normalizeModelId(value: string): string {
return value.toLowerCase().replace(/^models\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
}
export function selectModelWindows(result: ProviderResult, modelId?: string): Record<string, ProviderUsageWindow> {
const usage = result.usage
if (!usage) return {}
if (!modelId || !usage.models) return usage.windows
const target = normalizeModelId(modelId)
const entries = Object.entries(usage.models)
const exact = entries.find(([name]) => normalizeModelId(name.split("/").pop() ?? name) === target)
if (exact) return exact[1].windows
const partial = entries.find(([name]) => {
const candidate = normalizeModelId(name.split("/").pop() ?? name)
return candidate.includes(target) || target.includes(candidate)
})
return partial?.[1].windows ?? usage.windows
}
async function fetchProvider(provider: UsageProvider): Promise<ProviderResult> {
const cached = cache.get(provider.id)
if (cached && cached.expiresAt > Date.now()) return cached.result
const inFlight = pending.get(provider.id)
if (inFlight) return inFlight
const request = provider.fetchQuota().then((result) => {
cache.set(provider.id, { result, expiresAt: Date.now() + CACHE_TTL_MS })
return result
}).finally(() => pending.delete(provider.id))
pending.set(provider.id, request)
return request
}
export async function getProviderUsage(
requestedProviderId: string,
options: { modelId?: string } = {},
): Promise<ProviderUsageResponse> {
const provider = resolveUsageProvider(requestedProviderId)
if (!provider) {
return {
requestedProviderId,
providerId: null,
providerName: requestedProviderId,
modelId: options.modelId,
supported: false,
configured: false,
ok: false,
windows: {},
fetchedAt: Date.now(),
}
}
const result = await fetchProvider(provider)
return {
requestedProviderId,
providerId: provider.id,
providerName: result.providerName,
modelId: options.modelId,
supported: true,
configured: result.configured,
ok: result.ok,
windows: selectModelWindows(result, options.modelId),
fetchedAt: result.fetchedAt,
}
}
export function clearProviderUsageCache(): void {
cache.clear()
}

View file

@ -0,0 +1,34 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import test from "node:test"
import { getCredential, readOpenCodeAuth, toTimestamp, toUsageWindow } from "./shared"
test("reads the explicit OpenCode auth file without exposing credentials through the API", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-usage-"))
const authFile = path.join(directory, "auth.json")
const previous = process.env.OPENCODE_AUTH_FILE
fs.writeFileSync(authFile, JSON.stringify({ anthropic: { type: "oauth", access: "secret-token" } }))
process.env.OPENCODE_AUTH_FILE = authFile
try {
assert.equal((readOpenCodeAuth().anthropic as Record<string, unknown>).access, "secret-token")
assert.equal(getCredential(["anthropic"], ["access"]), "secret-token")
} finally {
if (previous === undefined) delete process.env.OPENCODE_AUTH_FILE
else process.env.OPENCODE_AUTH_FILE = previous
fs.rmSync(directory, { recursive: true, force: true })
}
})
test("normalizes percentages and timestamps", () => {
assert.deepEqual(toUsageWindow({ usedPercent: 120, resetAt: null }), {
usedPercent: 100,
remainingPercent: 0,
windowSeconds: null,
resetAt: null,
})
assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000)
})

View file

@ -0,0 +1,148 @@
import fs from "fs"
import os from "os"
import path from "path"
import type { ProviderUsageWindow } from "../api-types"
import type { AuthEntry, AuthFile, ProviderResult, ProviderUsage } from "./types"
const REQUEST_TIMEOUT_MS = 15_000
function authFileCandidates(): string[] {
const home = os.homedir()
const candidates = [
process.env.OPENCODE_AUTH_FILE,
process.env.OPENCODE_DATA_DIR ? path.join(process.env.OPENCODE_DATA_DIR, "auth.json") : undefined,
process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, "opencode", "auth.json") : undefined,
path.join(home, ".local", "share", "opencode", "auth.json"),
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "opencode", "Data", "auth.json") : undefined,
process.env.APPDATA ? path.join(process.env.APPDATA, "opencode", "auth.json") : undefined,
path.join(home, "Library", "Application Support", "opencode", "auth.json"),
]
return Array.from(new Set(candidates.filter((candidate): candidate is string => Boolean(candidate))))
}
export function readOpenCodeAuth(): AuthFile {
for (const candidate of authFileCandidates()) {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(candidate, "utf8"))
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as AuthFile
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue
}
}
return {}
}
export function getAuthEntry(aliases: readonly string[]): AuthEntry | null {
const auth = readOpenCodeAuth()
for (const alias of aliases) {
const value = auth[alias]
if (typeof value === "string" && value.trim()) return { token: value.trim() }
if (value && typeof value === "object" && !Array.isArray(value)) return value as AuthEntry
}
return null
}
export function getString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null
}
export function getCredential(aliases: readonly string[], fields: readonly string[]): string | null {
const entry = getAuthEntry(aliases)
if (!entry) return null
for (const field of fields) {
const value = getString(entry[field])
if (value) return value
}
return null
}
export function asObject(value: unknown): Record<string, any> | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : null
}
export function toNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string" && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
export function toTimestamp(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value < 1_000_000_000_000 ? value * 1000 : value
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
return null
}
export function toUsageWindow(input: {
usedPercent: number | null
windowSeconds?: number | null
resetAt?: number | null
valueLabel?: string | null
}): ProviderUsageWindow {
const usedPercent = input.usedPercent === null ? null : Math.max(0, Math.min(100, input.usedPercent))
return {
usedPercent,
remainingPercent: usedPercent === null ? null : 100 - usedPercent,
windowSeconds: input.windowSeconds ?? null,
resetAt: input.resetAt ?? null,
...(input.valueLabel ? { valueLabel: input.valueLabel } : {}),
}
}
export function resolveWindowLabel(windowSeconds: number | null): string {
if (!windowSeconds) return "usage"
if (windowSeconds % 86_400 === 0) {
const days = windowSeconds / 86_400
return days === 7 ? "weekly" : `${days}d`
}
if (windowSeconds % 3600 === 0) return `${windowSeconds / 3600}h`
return `${windowSeconds}s`
}
export function result(
providerId: string,
providerName: string,
input: { ok: boolean; configured: boolean; usage?: ProviderUsage; error?: string },
): ProviderResult {
return {
providerId,
providerName,
ok: input.ok,
configured: input.configured,
usage: input.usage ?? null,
fetchedAt: Date.now(),
...(input.error ? { error: input.error } : {}),
}
}
export function notConfigured(providerId: string, providerName: string): ProviderResult {
return result(providerId, providerName, { ok: false, configured: false, error: "Not configured" })
}
export async function fetchJson(url: string, init: RequestInit = {}): Promise<any> {
const response = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`)
return response.json()
}
export async function safeFetch(
providerId: string,
providerName: string,
operation: () => Promise<ProviderUsage>,
): Promise<ProviderResult> {
try {
return result(providerId, providerName, { ok: true, configured: true, usage: await operation() })
} catch (error) {
return result(providerId, providerName, {
ok: false,
configured: true,
error: error instanceof Error ? error.message : "Request failed",
})
}
}

View file

@ -0,0 +1,26 @@
import type { ProviderUsageWindow } from "../api-types"
export interface ProviderUsage {
windows: Record<string, ProviderUsageWindow>
models?: Record<string, { windows: Record<string, ProviderUsageWindow> }>
}
export interface ProviderResult {
providerId: string
providerName: string
ok: boolean
configured: boolean
usage: ProviderUsage | null
fetchedAt: number
error?: string
}
export interface UsageProvider {
id: string
name: string
aliases: readonly string[]
fetchQuota: () => Promise<ProviderResult>
}
export type AuthEntry = Record<string, unknown>
export type AuthFile = Record<string, unknown>

View file

@ -98,7 +98,7 @@ interface RightPanelProps {
const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("git-changes"))
const defaultStatusSectionIds = ["yolo-mode", "plan", "background-processes", "mcp", "lsp", "plugins"]
const defaultStatusSectionIds = ["provider-usage", "yolo-mode", "plan", "background-processes", "mcp", "lsp", "plugins"]
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(defaultStatusSectionIds)
const [browserPath, setBrowserPath] = createSignal(".")

View file

@ -11,6 +11,7 @@ import type { BackgroundProcess } from "../../../../../../../server/src/api-type
import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel"
import ProviderUsagePanel from "../../../../session/provider-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status"
import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances"
@ -172,7 +173,25 @@ const StatusTab: Component<StatusTabProps> = (props) => {
)
}
const renderProviderUsage = () => {
const session = props.activeSession()
if (!session) {
return <div class="text-xs text-tertiary">{props.t("providerUsage.noSession")}</div>
}
return (
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
<ProviderUsagePanel providerId={session.model.providerId} modelId={session.model.modelId} />
</div>
)
}
const statusSections = [
{
id: "provider-usage",
labelKey: "providerUsage.title",
tooltipKey: "providerUsage.tooltip",
render: renderProviderUsage,
},
{
id: "yolo-mode",
labelKey: "instanceShell.rightPanel.sections.yoloMode",

View file

@ -0,0 +1,131 @@
import { For, Show, createMemo, createResource, onCleanup, type Component } from "solid-js"
import type { ProviderUsageWindow } from "../../../../server/src/api-types"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
interface ProviderUsagePanelProps {
providerId: string
modelId: string
}
const REFRESH_INTERVAL_MS = 60_000
const ProviderUsagePanel: Component<ProviderUsagePanelProps> = (props) => {
const { t } = useI18n()
const source = createMemo(() => {
const providerId = props.providerId.trim()
if (!providerId) return null
return { providerId, modelId: props.modelId.trim() }
})
const [usage, { refetch }] = createResource(source, ({ providerId, modelId }) => serverApi.fetchProviderUsage(providerId, modelId))
const refreshTimer = setInterval(() => {
if (source()) void refetch()
}, REFRESH_INTERVAL_MS)
onCleanup(() => clearInterval(refreshTimer))
const entries = createMemo(() => Object.entries(usage()?.windows ?? {}))
const windowLabel = (label: string) => {
const key = ({
"5h": "fiveHours",
"7d": "sevenDays",
"7d-sonnet": "sevenDaysSonnet",
"7d-opus": "sevenDaysOpus",
weekly: "weekly",
daily: "daily",
monthly: "monthly",
credits: "credits",
credits_balance: "creditsBalance",
billing_cycle: "billingCycle",
session: "session",
premium: "premium",
chat: "chat",
completions: "completions",
"mcp-tools": "mcpTools",
} as Record<string, string>)[label]
return key ? t(`providerUsage.windows.${key}`) : label
}
const displayValue = (window: ProviderUsageWindow) => {
if (window.valueLabel) return window.valueLabel
if (window.usedPercent === null) return t("providerUsage.unavailableValue")
return t("providerUsage.usedPercent", { percent: Math.round(window.usedPercent) })
}
const resetLabel = (resetAt: number | null) => {
if (!resetAt) return null
const date = new Date(resetAt)
if (!Number.isFinite(date.getTime())) return null
return t("providerUsage.resets", { time: date.toLocaleString([], { dateStyle: "short", timeStyle: "short" }) })
}
const barColor = (percent: number | null) => {
if (percent !== null && percent >= 80) return "var(--status-error)"
if (percent !== null && percent >= 50) return "var(--status-warning)"
return "var(--status-success)"
}
return (
<div>
<div class="mb-2 flex justify-end text-xs">
<Show when={usage()?.providerName} fallback={<span class="text-secondary">{props.providerId}</span>}>
{(name) => <span class="truncate text-secondary">{name()}</span>}
</Show>
</div>
<Show when={!usage.loading} fallback={<div class="text-xs text-tertiary">{t("providerUsage.loading")}</div>}>
<Show when={usage()} fallback={<div class="text-xs text-tertiary">{t("providerUsage.unavailable")}</div>}>
{(data) => (
<Show
when={data().supported && data().configured && data().ok && entries().length > 0}
fallback={
<div class="text-xs text-tertiary">
{t(
!data().supported
? "providerUsage.unsupported"
: !data().configured
? "providerUsage.notConfigured"
: "providerUsage.unavailable",
)}
</div>
}
>
<div class="space-y-2.5">
<For each={entries()}>
{([label, window]) => (
<div>
<div class="mb-1 flex items-start justify-between gap-2 text-xs">
<span class="font-medium text-primary">{windowLabel(label)}</span>
<div class="text-right">
<div class="font-semibold text-primary">{displayValue(window)}</div>
<Show when={resetLabel(window.resetAt)}>{(reset) => <div class="text-[10px] text-tertiary">{reset()}</div>}</Show>
</div>
</div>
<Show when={window.usedPercent !== null}>
<div class="h-2 overflow-hidden border border-base" style={{ "background-color": "var(--surface-base)" }}>
<div
class="h-full transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, window.usedPercent ?? 0))}%`, "background-color": barColor(window.usedPercent) }}
role="progressbar"
aria-label={t("providerUsage.progressLabel", { window: windowLabel(label) })}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(window.usedPercent ?? 0)}
/>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
)}
</Show>
</Show>
</div>
)
}
export default ProviderUsagePanel

View file

@ -16,6 +16,7 @@ import type {
SpeechTranscriptionResponse,
SideCar,
PreviewSession,
ProviderUsageResponse,
ServerMeta,
RemoteProxySessionCreateRequest,
RemoteProxySessionCreateResponse,
@ -194,6 +195,13 @@ export const serverApi = {
return request<WorkspaceDescriptor[]>("/api/workspaces")
},
fetchProviderUsage(providerId: string, modelId?: string): Promise<ProviderUsageResponse> {
const params = new URLSearchParams()
if (modelId) params.set("modelId", modelId)
const query = params.toString()
return request<ProviderUsageResponse>(`/api/usage/${encodeURIComponent(providerId)}${query ? `?${query}` : ""}`)
},
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
},

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Verfügbar",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "Tarifnutzung",
"providerUsage.tooltip": "Nutzung und Rücksetzungszeiträume des in der aktiven Sitzung ausgewählten Anbieters.",
"providerUsage.noSession": "Wählen Sie eine Sitzung aus, um die Tarifnutzung anzuzeigen.",
"providerUsage.loading": "Nutzung wird geladen...",
"providerUsage.unsupported": "Für diesen Anbieter sind keine Nutzungsdaten verfügbar.",
"providerUsage.notConfigured": "Keine kompatiblen Abonnement-Zugangsdaten gefunden.",
"providerUsage.unavailable": "Nutzungsdaten sind vorübergehend nicht verfügbar.",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent} % verwendet",
"providerUsage.resets": "Zurücksetzung {time}",
"providerUsage.progressLabel": "Tarifnutzung {window}",
"providerUsage.windows.fiveHours": "5 Stunden",
"providerUsage.windows.sevenDays": "7 Tage",
"providerUsage.windows.sevenDaysSonnet": "7 Tage · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 Tage · Opus",
"providerUsage.windows.weekly": "Wöchentlich",
"providerUsage.windows.daily": "Täglich",
"providerUsage.windows.monthly": "Monatlich",
"providerUsage.windows.credits": "Guthaben",
"providerUsage.windows.creditsBalance": "Guthabenstand",
"providerUsage.windows.billingCycle": "Abrechnungszeitraum",
"providerUsage.windows.session": "Sitzung",
"providerUsage.windows.premium": "Premium-Anfragen",
"providerUsage.windows.chat": "Chat",
"providerUsage.windows.completions": "Vervollständigungen",
"providerUsage.windows.mcpTools": "MCP-Werkzeuge",
"settings.title": "Einstellungen",
"settings.navigationAriaLabel": "Einstellungsbereiche",
"settings.close": "Einstellungen schließen",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Avail",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "Plan usage",
"providerUsage.tooltip": "Usage and reset windows for the provider selected by the active session.",
"providerUsage.noSession": "Select a session to view plan usage.",
"providerUsage.loading": "Loading usage...",
"providerUsage.unsupported": "Usage is not available for this provider.",
"providerUsage.notConfigured": "No compatible subscription credential was found.",
"providerUsage.unavailable": "Usage is temporarily unavailable.",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent}% used",
"providerUsage.resets": "Resets {time}",
"providerUsage.progressLabel": "{window} plan usage",
"providerUsage.windows.fiveHours": "5 hours",
"providerUsage.windows.sevenDays": "7 days",
"providerUsage.windows.sevenDaysSonnet": "7 days · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 days · Opus",
"providerUsage.windows.weekly": "Weekly",
"providerUsage.windows.daily": "Daily",
"providerUsage.windows.monthly": "Monthly",
"providerUsage.windows.credits": "Credits",
"providerUsage.windows.creditsBalance": "Credit balance",
"providerUsage.windows.billingCycle": "Billing cycle",
"providerUsage.windows.session": "Session",
"providerUsage.windows.premium": "Premium requests",
"providerUsage.windows.chat": "Chat",
"providerUsage.windows.completions": "Completions",
"providerUsage.windows.mcpTools": "MCP tools",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Disp.",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "Uso del plan",
"providerUsage.tooltip": "Uso y ventanas de restablecimiento del proveedor seleccionado en la sesión activa.",
"providerUsage.noSession": "Selecciona una sesión para ver el uso del plan.",
"providerUsage.loading": "Cargando uso...",
"providerUsage.unsupported": "El uso no está disponible para este proveedor.",
"providerUsage.notConfigured": "No se encontraron credenciales de suscripción compatibles.",
"providerUsage.unavailable": "El uso no está disponible temporalmente.",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent} % usado",
"providerUsage.resets": "Se restablece {time}",
"providerUsage.progressLabel": "Uso del plan {window}",
"providerUsage.windows.fiveHours": "5 horas",
"providerUsage.windows.sevenDays": "7 días",
"providerUsage.windows.sevenDaysSonnet": "7 días · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 días · Opus",
"providerUsage.windows.weekly": "Semanal",
"providerUsage.windows.daily": "Diario",
"providerUsage.windows.monthly": "Mensual",
"providerUsage.windows.credits": "Créditos",
"providerUsage.windows.creditsBalance": "Saldo de créditos",
"providerUsage.windows.billingCycle": "Ciclo de facturación",
"providerUsage.windows.session": "Sesión",
"providerUsage.windows.premium": "Solicitudes premium",
"providerUsage.windows.chat": "Chat",
"providerUsage.windows.completions": "Completados",
"providerUsage.windows.mcpTools": "Herramientas MCP",
"settings.title": "Configuración",
"settings.navigationAriaLabel": "Secciones de configuración",
"settings.close": "Cerrar configuración",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Dispo",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "Utilisation du forfait",
"providerUsage.tooltip": "Utilisation et fenêtres de réinitialisation du fournisseur sélectionné par la session active.",
"providerUsage.noSession": "Sélectionnez une session pour afficher l'utilisation du forfait.",
"providerUsage.loading": "Chargement de l'utilisation...",
"providerUsage.unsupported": "L'utilisation n'est pas disponible pour ce fournisseur.",
"providerUsage.notConfigured": "Aucun identifiant d'abonnement compatible n'a été trouvé.",
"providerUsage.unavailable": "L'utilisation est temporairement indisponible.",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent} % utilisé",
"providerUsage.resets": "Réinitialisation {time}",
"providerUsage.progressLabel": "Utilisation du forfait {window}",
"providerUsage.windows.fiveHours": "5 heures",
"providerUsage.windows.sevenDays": "7 jours",
"providerUsage.windows.sevenDaysSonnet": "7 jours · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 jours · Opus",
"providerUsage.windows.weekly": "Hebdomadaire",
"providerUsage.windows.daily": "Quotidien",
"providerUsage.windows.monthly": "Mensuel",
"providerUsage.windows.credits": "Crédits",
"providerUsage.windows.creditsBalance": "Solde de crédits",
"providerUsage.windows.billingCycle": "Cycle de facturation",
"providerUsage.windows.session": "Session",
"providerUsage.windows.premium": "Requêtes premium",
"providerUsage.windows.chat": "Chat",
"providerUsage.windows.completions": "Complétions",
"providerUsage.windows.mcpTools": "Outils MCP",
"settings.title": "Paramètres",
"settings.navigationAriaLabel": "Sections des paramètres",
"settings.close": "Fermer les paramètres",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "זמין",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "שימוש בתוכנית",
"providerUsage.tooltip": "שימוש וחלונות איפוס עבור הספק שנבחר בהפעלה הפעילה.",
"providerUsage.noSession": "יש לבחור הפעלה כדי להציג את השימוש בתוכנית.",
"providerUsage.loading": "טוען נתוני שימוש...",
"providerUsage.unsupported": "נתוני שימוש אינם זמינים לספק זה.",
"providerUsage.notConfigured": "לא נמצאו פרטי מנוי תואמים.",
"providerUsage.unavailable": "נתוני השימוש אינם זמינים כרגע.",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent}% בשימוש",
"providerUsage.resets": "איפוס {time}",
"providerUsage.progressLabel": "שימוש בתוכנית {window}",
"providerUsage.windows.fiveHours": "5 שעות",
"providerUsage.windows.sevenDays": "7 ימים",
"providerUsage.windows.sevenDaysSonnet": "7 ימים · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 ימים · Opus",
"providerUsage.windows.weekly": "שבועי",
"providerUsage.windows.daily": "יומי",
"providerUsage.windows.monthly": "חודשי",
"providerUsage.windows.credits": "זיכויים",
"providerUsage.windows.creditsBalance": "יתרת זיכויים",
"providerUsage.windows.billingCycle": "מחזור חיוב",
"providerUsage.windows.session": "הפעלה",
"providerUsage.windows.premium": "בקשות פרימיום",
"providerUsage.windows.chat": "צ׳אט",
"providerUsage.windows.completions": "השלמות",
"providerUsage.windows.mcpTools": "כלי MCP",
"settings.title": "הגדרות",
"settings.navigationAriaLabel": "קטגוריות הגדרות",
"settings.close": "סגור הגדרות",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "残り",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "プラン使用量",
"providerUsage.tooltip": "アクティブなセッションで選択されたプロバイダーの使用量とリセット期間です。",
"providerUsage.noSession": "プラン使用量を表示するセッションを選択してください。",
"providerUsage.loading": "使用量を読み込み中...",
"providerUsage.unsupported": "このプロバイダーの使用量は利用できません。",
"providerUsage.notConfigured": "対応するサブスクリプション認証情報が見つかりません。",
"providerUsage.unavailable": "使用量は一時的に利用できません。",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent}% 使用",
"providerUsage.resets": "リセット {time}",
"providerUsage.progressLabel": "{window} のプラン使用量",
"providerUsage.windows.fiveHours": "5時間",
"providerUsage.windows.sevenDays": "7日間",
"providerUsage.windows.sevenDaysSonnet": "7日間 · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7日間 · Opus",
"providerUsage.windows.weekly": "週間",
"providerUsage.windows.daily": "日次",
"providerUsage.windows.monthly": "月間",
"providerUsage.windows.credits": "クレジット",
"providerUsage.windows.creditsBalance": "クレジット残高",
"providerUsage.windows.billingCycle": "請求期間",
"providerUsage.windows.session": "セッション",
"providerUsage.windows.premium": "プレミアムリクエスト",
"providerUsage.windows.chat": "チャット",
"providerUsage.windows.completions": "補完",
"providerUsage.windows.mcpTools": "MCPツール",
"settings.title": "設定",
"settings.navigationAriaLabel": "設定セクション",
"settings.close": "設定を閉じる",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "उपलब्ध",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "योजना प्रयोग",
"providerUsage.tooltip": "सक्रिय सत्रले चयन गरेको प्रदायकको प्रयोग र रिसेट अवधि।",
"providerUsage.noSession": "योजना प्रयोग हेर्न सत्र चयन गर्नुहोस्।",
"providerUsage.loading": "प्रयोग विवरण लोड हुँदैछ...",
"providerUsage.unsupported": "यो प्रदायकका लागि प्रयोग विवरण उपलब्ध छैन।",
"providerUsage.notConfigured": "मिल्दो सदस्यता प्रमाण फेला परेन।",
"providerUsage.unavailable": "प्रयोग विवरण अस्थायी रूपमा उपलब्ध छैन।",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "{percent}% प्रयोग भयो",
"providerUsage.resets": "रिसेट {time}",
"providerUsage.progressLabel": "{window} योजना प्रयोग",
"providerUsage.windows.fiveHours": "५ घण्टा",
"providerUsage.windows.sevenDays": "७ दिन",
"providerUsage.windows.sevenDaysSonnet": "७ दिन · Sonnet",
"providerUsage.windows.sevenDaysOpus": "७ दिन · Opus",
"providerUsage.windows.weekly": "साप्ताहिक",
"providerUsage.windows.daily": "दैनिक",
"providerUsage.windows.monthly": "मासिक",
"providerUsage.windows.credits": "क्रेडिट",
"providerUsage.windows.creditsBalance": "क्रेडिट बाँकी",
"providerUsage.windows.billingCycle": "बिलिङ चक्र",
"providerUsage.windows.session": "सत्र",
"providerUsage.windows.premium": "प्रिमियम अनुरोध",
"providerUsage.windows.chat": "च्याट",
"providerUsage.windows.completions": "पूर्णता",
"providerUsage.windows.mcpTools": "MCP उपकरण",
"settings.title": "सेटिङहरू",
"settings.navigationAriaLabel": "सेटिङ खण्डहरू",
"settings.close": "सेटिङहरू बन्द गर्नुहोस्",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "Доступно",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "Использование тарифа",
"providerUsage.tooltip": "Использование и периоды сброса провайдера, выбранного в активной сессии.",
"providerUsage.noSession": "Выберите сессию для просмотра использования тарифа.",
"providerUsage.loading": "Загрузка данных...",
"providerUsage.unsupported": "Данные об использовании недоступны для этого провайдера.",
"providerUsage.notConfigured": "Совместимые данные подписки не найдены.",
"providerUsage.unavailable": "Данные об использовании временно недоступны.",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "Использовано {percent}%",
"providerUsage.resets": "Сброс {time}",
"providerUsage.progressLabel": "Использование тарифа {window}",
"providerUsage.windows.fiveHours": "5 часов",
"providerUsage.windows.sevenDays": "7 дней",
"providerUsage.windows.sevenDaysSonnet": "7 дней · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 дней · Opus",
"providerUsage.windows.weekly": "За неделю",
"providerUsage.windows.daily": "За день",
"providerUsage.windows.monthly": "За месяц",
"providerUsage.windows.credits": "Кредиты",
"providerUsage.windows.creditsBalance": "Баланс кредитов",
"providerUsage.windows.billingCycle": "Расчётный период",
"providerUsage.windows.session": "Сессия",
"providerUsage.windows.premium": "Премиум-запросы",
"providerUsage.windows.chat": "Чат",
"providerUsage.windows.completions": "Дополнения",
"providerUsage.windows.mcpTools": "Инструменты MCP",
"settings.title": "Настройки",
"settings.navigationAriaLabel": "Разделы настроек",
"settings.close": "Закрыть настройки",

View file

@ -64,6 +64,33 @@ export const settingsMessages = {
"contextUsagePanel.labels.available": "可用",
"contextUsagePanel.unavailable": "--",
"providerUsage.title": "套餐用量",
"providerUsage.tooltip": "活动会话所选提供商的用量和重置周期。",
"providerUsage.noSession": "请选择一个会话以查看套餐用量。",
"providerUsage.loading": "正在加载用量...",
"providerUsage.unsupported": "此提供商不支持用量查询。",
"providerUsage.notConfigured": "未找到兼容的订阅凭据。",
"providerUsage.unavailable": "用量暂时不可用。",
"providerUsage.unavailableValue": "--",
"providerUsage.usedPercent": "已使用 {percent}%",
"providerUsage.resets": "重置时间 {time}",
"providerUsage.progressLabel": "{window} 套餐用量",
"providerUsage.windows.fiveHours": "5 小时",
"providerUsage.windows.sevenDays": "7 天",
"providerUsage.windows.sevenDaysSonnet": "7 天 · Sonnet",
"providerUsage.windows.sevenDaysOpus": "7 天 · Opus",
"providerUsage.windows.weekly": "每周",
"providerUsage.windows.daily": "每日",
"providerUsage.windows.monthly": "每月",
"providerUsage.windows.credits": "额度",
"providerUsage.windows.creditsBalance": "额度余额",
"providerUsage.windows.billingCycle": "计费周期",
"providerUsage.windows.session": "会话",
"providerUsage.windows.premium": "高级请求",
"providerUsage.windows.chat": "聊天",
"providerUsage.windows.completions": "补全",
"providerUsage.windows.mcpTools": "MCP 工具",
"settings.title": "设置",
"settings.navigationAriaLabel": "设置分区",
"settings.close": "关闭设置",