mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
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:
parent
e815eba872
commit
709557d12d
30 changed files with 1650 additions and 1 deletions
28
THIRD_PARTY_NOTICES.md
Normal file
28
THIRD_PARTY_NOTICES.md
Normal 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.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
26
packages/server/THIRD_PARTY_NOTICES.md
Normal file
26
packages/server/THIRD_PARTY_NOTICES.md
Normal 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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
28
packages/server/src/server/routes/usage.test.ts
Normal file
28
packages/server/src/server/routes/usage.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
24
packages/server/src/server/routes/usage.ts
Normal file
24
packages/server/src/server/routes/usage.ts
Normal 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" }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
182
packages/server/src/usage/providers/api-key.ts
Normal file
182
packages/server/src/usage/providers/api-key.ts
Normal 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]
|
||||
179
packages/server/src/usage/providers/google.ts
Normal file
179
packages/server/src/usage/providers/google.ts
Normal 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]
|
||||
116
packages/server/src/usage/providers/minimax.ts
Normal file
116
packages/server/src/usage/providers/minimax.ts
Normal 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",
|
||||
}),
|
||||
]
|
||||
120
packages/server/src/usage/providers/oauth.ts
Normal file
120
packages/server/src/usage/providers/oauth.ts
Normal 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]
|
||||
145
packages/server/src/usage/providers/special.ts
Normal file
145
packages/server/src/usage/providers/special.ts
Normal 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(/"|"|\\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]
|
||||
67
packages/server/src/usage/service.test.ts
Normal file
67
packages/server/src/usage/service.test.ts
Normal 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, {})
|
||||
})
|
||||
91
packages/server/src/usage/service.ts
Normal file
91
packages/server/src/usage/service.ts
Normal 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()
|
||||
}
|
||||
34
packages/server/src/usage/shared.test.ts
Normal file
34
packages/server/src/usage/shared.test.ts
Normal 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)
|
||||
})
|
||||
148
packages/server/src/usage/shared.ts
Normal file
148
packages/server/src/usage/shared.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
26
packages/server/src/usage/types.ts
Normal file
26
packages/server/src/usage/types.ts
Normal 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>
|
||||
|
|
@ -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(".")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
131
packages/ui/src/components/session/provider-usage-panel.tsx
Normal file
131
packages/ui/src/components/session/provider-usage-panel.tsx
Normal 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
|
||||
|
|
@ -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`)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "סגור הגדרות",
|
||||
|
|
|
|||
|
|
@ -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": "設定を閉じる",
|
||||
|
|
|
|||
|
|
@ -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": "सेटिङहरू बन्द गर्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -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": "Закрыть настройки",
|
||||
|
|
|
|||
|
|
@ -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": "关闭设置",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue