Skip to content

Commit 709557d

Browse files
authored
feat(usage): show provider quota in session status (NeuralNomadsAI#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" />
1 parent e815eba commit 709557d

30 files changed

Lines changed: 1650 additions & 1 deletion

THIRD_PARTY_NOTICES.md

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

packages/server/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
217217

218218
- **Config**: `~/.config/codenomad/config.json`
219219
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
220+
221+
### Provider Plan Usage
222+
223+
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.
224+
225+
Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables:
226+
227+
- Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`
228+
- Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`
229+
- Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN`
230+
- Ollama Cloud: `OLLAMA_CLOUD_COOKIE`
231+
- OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Third-Party Notices
2+
3+
The provider usage adapters are derived from OpenChamber:
4+
https://github.com/openchamber/openchamber
5+
6+
MIT License
7+
8+
Copyright (c) 2025 Bohdan Triapitsyn
9+
10+
Permission is hereby granted, free of charge, to any person obtaining a copy
11+
of this software and associated documentation files (the "Software"), to deal
12+
in the Software without restriction, including without limitation the rights
13+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14+
copies of the Software, and to permit persons to whom the Software is
15+
furnished to do so, subject to the following conditions:
16+
17+
The above copyright notice and this permission notice shall be included in all
18+
copies or substantial portions of the Software.
19+
20+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26+
SOFTWARE.

packages/server/src/api-types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ export interface WorkspaceDeleteResponse {
6767
status: WorkspaceStatus
6868
}
6969

70+
export interface ProviderUsageWindow {
71+
usedPercent: number | null
72+
remainingPercent: number | null
73+
windowSeconds: number | null
74+
resetAt: number | null
75+
valueLabel?: string
76+
}
77+
78+
export interface ProviderUsageResponse {
79+
requestedProviderId: string
80+
providerId: string | null
81+
providerName: string
82+
modelId?: string
83+
supported: boolean
84+
configured: boolean
85+
ok: boolean
86+
windows: Record<string, ProviderUsageWindow>
87+
fetchedAt: number
88+
}
89+
7090
export type WorktreeKind = "root" | "worktree"
7191

7292
export interface WorktreeDescriptor {

packages/server/src/server/http-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { registerRemoteServerRoutes } from "./routes/remote-servers"
2929
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
3030
import { registerSideCarRoutes } from "./routes/sidecars"
3131
import { registerPreviewRoutes } from "./routes/previews"
32+
import { registerUsageRoutes } from "./routes/usage"
3233
import { ServerMeta } from "../api-types"
3334
import { InstanceStore } from "../storage/instance-store"
3435
import { BackgroundProcessManager } from "../background-processes/manager"
@@ -302,6 +303,7 @@ export function createHttpServer(deps: HttpServerDeps) {
302303
registerSpeechRoutes(app, { speechService: deps.speechService })
303304
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
304305
registerPreviewRoutes(app, { previewManager: deps.previewManager })
306+
registerUsageRoutes(app)
305307
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
306308
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger })
307309
setupSideCarWebSocketProxy(app, {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import Fastify from "fastify"
4+
5+
import { registerUsageRoutes } from "./usage"
6+
7+
test("returns a typed unsupported result without exposing server details", async () => {
8+
const app = Fastify()
9+
registerUsageRoutes(app)
10+
11+
try {
12+
const response = await app.inject({ method: "GET", url: "/api/usage/unknown-provider?modelId=test-model" })
13+
assert.equal(response.statusCode, 200)
14+
assert.deepEqual(response.json(), {
15+
requestedProviderId: "unknown-provider",
16+
providerId: null,
17+
providerName: "unknown-provider",
18+
modelId: "test-model",
19+
supported: false,
20+
configured: false,
21+
ok: false,
22+
windows: {},
23+
fetchedAt: response.json().fetchedAt,
24+
})
25+
} finally {
26+
await app.close()
27+
}
28+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { FastifyInstance } from "fastify"
2+
import { z } from "zod"
3+
4+
import { getProviderUsage } from "../../usage/service"
5+
6+
const UsageParamsSchema = z.object({ providerId: z.string().trim().min(1) })
7+
const UsageQuerySchema = z.object({ modelId: z.string().trim().optional() })
8+
9+
export function registerUsageRoutes(app: FastifyInstance) {
10+
app.get<{ Params: { providerId: string }; Querystring: { modelId?: string } }>(
11+
"/api/usage/:providerId",
12+
async (request, reply) => {
13+
try {
14+
const params = UsageParamsSchema.parse(request.params)
15+
const query = UsageQuerySchema.parse(request.query ?? {})
16+
return await getProviderUsage(params.providerId, { modelId: query.modelId })
17+
} catch (error) {
18+
request.log.error({ err: error }, "Failed to fetch provider usage")
19+
reply.code(400)
20+
return { error: error instanceof Error ? error.message : "Failed to fetch provider usage" }
21+
}
22+
},
23+
)
24+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import type { UsageProvider } from "../types"
2+
import {
3+
fetchJson,
4+
getCredential,
5+
notConfigured,
6+
resolveWindowLabel,
7+
safeFetch,
8+
toNumber,
9+
toTimestamp,
10+
toUsageWindow,
11+
} from "../shared"
12+
13+
const kimiAliases = ["kimi-for-coding", "kimi"] as const
14+
const kimi: UsageProvider = {
15+
id: "kimi-for-coding",
16+
name: "Kimi for Coding",
17+
aliases: kimiAliases,
18+
async fetchQuota() {
19+
const key = getCredential(kimiAliases, ["key", "token"])
20+
if (!key) return notConfigured(this.id, this.name)
21+
return safeFetch(this.id, this.name, async () => {
22+
const payload = await fetchJson("https://api.kimi.com/coding/v1/usages", {
23+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
24+
})
25+
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
26+
const usage = payload?.usage
27+
if (usage) {
28+
const limit = toNumber(usage.limit)
29+
const remaining = toNumber(usage.remaining)
30+
windows.weekly = toUsageWindow({
31+
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
32+
resetAt: toTimestamp(usage.resetTime),
33+
})
34+
}
35+
for (const item of Array.isArray(payload?.limits) ? payload.limits : []) {
36+
const duration = toNumber(item?.window?.duration)
37+
const unit = item?.window?.timeUnit
38+
const multiplier = unit === "TIME_UNIT_MINUTE" ? 60 : unit === "TIME_UNIT_HOUR" ? 3600 : unit === "TIME_UNIT_DAY" ? 86_400 : null
39+
const seconds = duration !== null && multiplier ? duration * multiplier : null
40+
const limit = toNumber(item?.detail?.limit)
41+
const remaining = toNumber(item?.detail?.remaining)
42+
windows[resolveWindowLabel(seconds)] = toUsageWindow({
43+
usedPercent: limit && remaining !== null ? 100 - (remaining / limit) * 100 : null,
44+
windowSeconds: seconds,
45+
resetAt: toTimestamp(item?.detail?.resetTime),
46+
})
47+
}
48+
return { windows }
49+
})
50+
},
51+
}
52+
53+
const nanoAliases = ["nano-gpt", "nanogpt", "nano_gpt"] as const
54+
const nanoGpt: UsageProvider = {
55+
id: "nano-gpt",
56+
name: "NanoGPT",
57+
aliases: nanoAliases,
58+
async fetchQuota() {
59+
const key = getCredential(nanoAliases, ["key", "token"])
60+
if (!key) return notConfigured(this.id, this.name)
61+
return safeFetch(this.id, this.name, async () => {
62+
const payload = await fetchJson("https://nano-gpt.com/api/subscription/v1/usage", {
63+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
64+
})
65+
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
66+
for (const [label, source] of [["daily", payload?.daily], ["monthly", payload?.monthly]] as const) {
67+
if (!source) continue
68+
const fraction = toNumber(source.percentUsed)
69+
const used = toNumber(source.used)
70+
const limit = toNumber(source.limit ?? source.limits?.[label])
71+
windows[label] = toUsageWindow({
72+
usedPercent: fraction !== null ? fraction * 100 : used !== null && limit ? (used / limit) * 100 : null,
73+
windowSeconds: label === "daily" ? 86_400 : null,
74+
resetAt: toTimestamp(source.resetAt ?? payload?.period?.currentPeriodEnd),
75+
})
76+
}
77+
return { windows }
78+
})
79+
},
80+
}
81+
82+
const openRouterAliases = ["openrouter"] as const
83+
const openRouter: UsageProvider = {
84+
id: "openrouter",
85+
name: "OpenRouter",
86+
aliases: openRouterAliases,
87+
async fetchQuota() {
88+
const key = getCredential(openRouterAliases, ["key", "token"])
89+
if (!key) return notConfigured(this.id, this.name)
90+
return safeFetch(this.id, this.name, async () => {
91+
const payload = await fetchJson("https://openrouter.ai/api/v1/credits", {
92+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
93+
})
94+
const total = toNumber(payload?.data?.total_credits)
95+
const used = toNumber(payload?.data?.total_usage)
96+
const remaining = total !== null && used !== null ? Math.max(0, total - used) : null
97+
return {
98+
windows: {
99+
credits: toUsageWindow({
100+
usedPercent: total && used !== null ? (used / total) * 100 : null,
101+
valueLabel: remaining !== null && total !== null ? `$${remaining.toFixed(2)} / $${total.toFixed(2)}` : null,
102+
}),
103+
},
104+
}
105+
})
106+
},
107+
}
108+
109+
function createTokenLimitProvider(input: { id: string; name: string; aliases: readonly string[]; url: string }): UsageProvider {
110+
return {
111+
id: input.id,
112+
name: input.name,
113+
aliases: input.aliases,
114+
async fetchQuota() {
115+
const key = getCredential(input.aliases, ["key", "token"])
116+
if (!key) return notConfigured(this.id, this.name)
117+
return safeFetch(this.id, this.name, async () => {
118+
const payload = await fetchJson(input.url, { headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" } })
119+
const windows: Record<string, ReturnType<typeof toUsageWindow>> = {}
120+
for (const limit of Array.isArray(payload?.data?.limits) ? payload.data.limits : []) {
121+
if (limit?.type !== "TOKENS_LIMIT" && limit?.type !== "TIME_LIMIT") continue
122+
const duration = toNumber(limit.number)
123+
const seconds = limit.type === "TIME_LIMIT" ? 30 * 86_400 : limit.unit === 3 && duration ? duration * 3600 : null
124+
const label = limit.type === "TIME_LIMIT" ? "mcp-tools" : resolveWindowLabel(seconds)
125+
windows[label] = toUsageWindow({
126+
usedPercent: toNumber(limit.percentage),
127+
windowSeconds: seconds,
128+
resetAt: toTimestamp(limit.nextResetTime),
129+
})
130+
}
131+
return { windows }
132+
})
133+
},
134+
}
135+
}
136+
137+
const zai = createTokenLimitProvider({
138+
id: "zai-coding-plan",
139+
name: "z.ai",
140+
aliases: ["zai-coding-plan", "zai", "z.ai"],
141+
url: "https://api.z.ai/api/monitor/usage/quota/limit",
142+
})
143+
144+
const zhipu = createTokenLimitProvider({
145+
id: "zhipuai-coding-plan",
146+
name: "Zhipu AI Coding Plan",
147+
aliases: ["zhipuai-coding-plan", "zhipuai", "zhipu"],
148+
url: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
149+
})
150+
151+
const waferAliases = ["wafer", "wafer-ai", "wafer_ai", "wafer.ai"] as const
152+
const wafer: UsageProvider = {
153+
id: "wafer",
154+
name: "Wafer.ai",
155+
aliases: waferAliases,
156+
async fetchQuota() {
157+
const key = getCredential(waferAliases, ["key", "token"])
158+
if (!key) return notConfigured(this.id, this.name)
159+
return safeFetch(this.id, this.name, async () => {
160+
const payload = await fetchJson("https://pass.wafer.ai/v1/inference/quota", {
161+
headers: { Authorization: `Bearer ${key}`, "Accept-Encoding": "identity" },
162+
})
163+
const remaining = toNumber(payload?.remaining_included_requests)
164+
const limit = toNumber(payload?.included_request_limit)
165+
const startAt = toTimestamp(payload?.window_start)
166+
const resetAt = toTimestamp(payload?.window_end)
167+
const seconds = startAt !== null && resetAt !== null ? Math.round((resetAt - startAt) / 1000) : 5 * 3600
168+
return {
169+
windows: {
170+
[resolveWindowLabel(seconds)]: toUsageWindow({
171+
usedPercent: toNumber(payload?.current_period_used_percent),
172+
windowSeconds: seconds,
173+
resetAt,
174+
valueLabel: remaining !== null && limit !== null ? `${remaining} / ${limit}` : null,
175+
}),
176+
},
177+
}
178+
})
179+
},
180+
}
181+
182+
export const apiKeyProviders: UsageProvider[] = [kimi, nanoGpt, openRouter, zai, zhipu, wafer]

0 commit comments

Comments
 (0)