Compare commits

..

15 Commits

Author SHA1 Message Date
461773c677 fix(usage): resolve Z.ai window assignment using limit IDs
Z.ai assigns both token and request limits the same generic "quota"
window ID, so findWindowLimits fell through to array-order fallback.
This caused the two bars to swap depending on API response order.

Add limit-ID-based matching (zai:tokens → current, zai:requests → week)
as a secondary tier after window-ID matching.
2026-05-04 06:24:28 -04:00
cf7ad047d3 refactor: type ModuleContext and index.ts functions with SDK types 2026-05-03 21:28:15 -04:00
26ba42f2b9 docs: remove deleted usage/shared.ts from AGENTS.md layout 2026-05-03 21:25:43 -04:00
9951a11b29 fix: make overlay() ANSI-aware using visible character positions 2026-05-03 21:25:34 -04:00
f8fac5fd45 refactor: consolidate duplicate toNumber into utils.ts, remove usage/shared.ts 2026-05-03 21:25:12 -04:00
7906519eeb docs: update AGENTS.md with accurate file layout and current conventions 2026-05-03 21:22:26 -04:00
35f8276a16 fix: use same refresh interval for all providers
chore: update AGENTS.md to require explicit approval for usage/ and providers/ changes
2026-05-03 21:21:49 -04:00
9c88e0a003 perf: debounce rerender calls with queueMicrotask to coalesce rapid updates 2026-05-03 21:16:22 -04:00
576f31b13a fix: use floor for duration minutes to prevent 60m overflow 2026-05-03 21:16:04 -04:00
419c78c357 refactor: replace any types with proper SDK types in event handlers 2026-05-03 21:15:43 -04:00
c8c5de590d perf: cache ANSI prefix regex in truncate instead of re-creating per call 2026-05-03 21:14:07 -04:00
4e36542b8b fix: add explicit any types to resolve implicit any diagnostics 2026-05-03 21:13:10 -04:00
b035ab3d0c fix: keep old usage data visible during refresh to prevent pop-in 2026-05-03 21:11:57 -04:00
615762b0ac chore: use auth storage 2026-05-03 11:58:13 -04:00
4b06f45b2c chore: format usage 2026-05-03 11:56:46 -04:00
9 changed files with 984 additions and 823 deletions

View File

@@ -6,19 +6,21 @@
## Layout ## Layout
- `index.ts` — Extension entrypoint, footer registration, refresh timers, slash command, auth lookup. - `index.ts` — Extension entrypoint, footer registration, refresh timers, debounced rendering, slash command, auth lookup.
- `config.ts` — User-facing layout/config. Prefer changing this for layout tweaks. - `config.ts` — User-facing layout/config. Prefer changing this for layout tweaks.
- `types.ts` — Shared config/module/state types. - `types.ts` — Shared config/module/state types.
- `render.ts` — Width allocation, ANSI-aware truncation, dim text rendering. - `render.ts` — Width allocation, ANSI-aware truncation, dim text rendering.
- `utils.ts` — Shared utility helpers (`isRecord`, `toNumber`).
- `usage.ts` — Provider-agnostic usage type definitions (report, limit, credential, etc.).
- `modules/basic.ts` — Directory, context, model, thinking, and cost modules. - `modules/basic.ts` — Directory, context, model, thinking, and cost modules.
- `modules/usage.ts` — Usage bars/text modules and color gradient rendering. - `modules/usage.ts` — Usage bars/text modules and color gradient rendering.
- `usage/` — Provider usage fetchers adapted from `oh-my-pi`: https://github.com/can1357/oh-my-pi/tree/main/packages/ai/src/usage - `usage/` — Provider usage fetchers adapted from `oh-my-pi`: https://github.com/can1357/oh-my-pi/tree/main/packages/ai/src/usage
- `providers/openai-codex/constants.ts`, `usage.ts`, `utils.ts` — Local shims/support for copied usage code. - `providers/openai-codex/constants.ts` — OpenAI Codex base URL constant.
## Conventions ## Conventions
- Keep user layout changes in `config.ts` when possible. - Keep user layout changes in `config.ts` when possible.
- Preserve copied provider logic in `usage/` unless fixing integration issues. - **Do NOT modify files in `usage/` or `providers/` without explicit user approval.** These contain copied upstream provider logic. Always ask before changing them.
- Use composable module config instead of subtype strings. For usage, prefer: - Use composable module config instead of subtype strings. For usage, prefer:
```ts ```ts
@@ -26,14 +28,15 @@
{ type: "usage", window: "week", style: "text", parts: ["percent", "time"], separator: " | " } { type: "usage", window: "week", style: "text", parts: ["percent", "time"], separator: " | " }
``` ```
- Usage fallback behavior should be stable/no-pop-in: - Usage data is preserved during refresh (no clearing before fetch) to prevent pop-in:
- unknown/loading line bars render like 0% usage - Old data stays visible until new data arrives.
- unknown/loading percent renders `0%` - When no data exists yet: line bars render like 0% usage, percent renders `0%`, time renders `∞`.
- unknown/loading time renders `∞`
- Usage bar colors are a continuous green → yellow → red gradient derived from usage percentage, not hard thresholds. - Usage bar colors are a continuous green → yellow → red gradient derived from usage percentage, not hard thresholds.
- Rendering must remain ANSI-aware. If adding colors, update `visibleWidth`/`truncate` behavior as needed. - Rendering must remain ANSI-aware. If adding colors, update `visibleWidth`/`truncate` behavior as needed.
- Text modules and separators should remain dim; filled usage bars may use gradient color. - Text modules and separators should remain dim; filled usage bars may use gradient color.
- Thinking level comes from `pi.getThinkingLevel()` and `thinking_level_select`, not `ctx.thinkingLevel`. - Thinking level comes from `pi.getThinkingLevel()` and `thinking_level_select`, not `ctx.thinkingLevel`.
- `rerender()` is debounced via `queueMicrotask` — multiple rapid calls coalesce into a single render pass.
- All providers use the same refresh interval (`REFRESH_MS`).
## Testing ## Testing

141
index.ts
View File

@@ -1,7 +1,4 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { AuthStorage, type AuthCredential, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext, type ReadonlyFooterDataProvider, type SessionStartEvent, type SessionShutdownEvent, type AgentEndEvent } from "@mariozechner/pi-coding-agent";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { statusbarConfig } from "./config"; import { statusbarConfig } from "./config";
import { contextModule, costModule, directoryModule, modelModule, thinkingModule } from "./modules/basic"; import { contextModule, costModule, directoryModule, modelModule, thinkingModule } from "./modules/basic";
import { usageModule } from "./modules/usage"; import { usageModule } from "./modules/usage";
@@ -12,9 +9,7 @@ import { zaiUsageProvider } from "./usage/zai";
import type { ModuleContext, ModuleSpec, RenderedModule, StatusbarState } from "./types"; import type { ModuleContext, ModuleSpec, RenderedModule, StatusbarState } from "./types";
import type { Provider, UsageCredential, UsageProvider, UsageReport } from "./usage"; import type { Provider, UsageCredential, UsageProvider, UsageReport } from "./usage";
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
const REFRESH_MS = 60_000; const REFRESH_MS = 60_000;
const ANTHROPIC_REFRESH_MS = 15 * 60_000;
const usageProviders: Record<Provider, UsageProvider> = { const usageProviders: Record<Provider, UsageProvider> = {
"openai-codex": openaiCodexUsageProvider, "openai-codex": openaiCodexUsageProvider,
@@ -26,42 +21,55 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function activeProvider(ctx: any): Provider | undefined { function activeProvider(ctx: ExtensionContext): Provider | undefined {
const provider = ctx.model?.provider as string | undefined; const provider = ctx.model?.provider as string | undefined;
if (provider === "openai-codex" || provider === "zai" || provider === "anthropic") return provider; if (provider === "openai-codex" || provider === "zai" || provider === "anthropic") return provider;
return undefined; return undefined;
} }
function readPiCredential(provider: Provider): UsageCredential | undefined { function credentialString(raw: unknown, key: string): string | undefined {
try { return isRecord(raw) && typeof raw[key] === "string" ? raw[key] : undefined;
const auth = JSON.parse(readFileSync(AUTH_PATH, "utf8")); }
if (!isRecord(auth) || !isRecord(auth[provider])) return undefined;
const raw = auth[provider]; function buildUsageCredential(raw: AuthCredential | undefined, apiKey: string): UsageCredential {
if (raw.type === "oauth") { if (raw?.type === "oauth") {
return { return {
type: "oauth", type: "oauth",
accessToken: typeof raw.access === "string" ? raw.access : undefined, accessToken: apiKey,
refreshToken: typeof raw.refresh === "string" ? raw.refresh : undefined, refreshToken: raw.refresh,
expiresAt: typeof raw.expires === "number" ? raw.expires : undefined, expiresAt: raw.expires,
accountId: typeof raw.accountId === "string" ? raw.accountId : undefined, accountId: credentialString(raw, "accountId"),
email: typeof raw.email === "string" ? raw.email : undefined, email: credentialString(raw, "email"),
metadata: raw, metadata: raw,
}; };
} }
if (raw.type === "api_key") { return {
return { type: "api_key",
type: "api_key", apiKey,
apiKey: typeof raw.key === "string" ? raw.key : undefined, accountId: credentialString(raw, "accountId"),
accountId: typeof raw.accountId === "string" ? raw.accountId : undefined, email: credentialString(raw, "email"),
email: typeof raw.email === "string" ? raw.email : undefined, metadata: raw,
metadata: raw, };
}; }
}
} catch {}
return undefined; async function readPiCredential(authStorage: AuthStorage, provider: Provider): Promise<UsageCredential | undefined> {
authStorage.reload();
const apiKey = await authStorage.getApiKey(provider);
if (!apiKey) return undefined;
return buildUsageCredential(authStorage.get(provider), apiKey);
}
async function forceRefreshPiCredential(authStorage: AuthStorage, provider: Provider): Promise<UsageCredential> {
authStorage.reload();
const raw = authStorage.get(provider);
const oauthProvider = authStorage.getOAuthProviders().find(candidate => candidate.id === provider);
if (raw?.type !== "oauth" || !oauthProvider) throw new Error("login expired");
// Refresh Provider OAuth Token
const refreshed = await oauthProvider.refreshToken(raw);
authStorage.set(provider, { type: "oauth", ...refreshed });
return buildUsageCredential(authStorage.get(provider), oauthProvider.getApiKey(refreshed));
} }
function renderModule(moduleCtx: ModuleContext, spec: ModuleSpec): RenderedModule { function renderModule(moduleCtx: ModuleContext, spec: ModuleSpec): RenderedModule {
@@ -83,7 +91,7 @@ function renderModule(moduleCtx: ModuleContext, spec: ModuleSpec): RenderedModul
} }
} }
function renderFooter(ctx: any, footerData: any, state: StatusbarState, width: number, theme?: any): string[] { function renderFooter(ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider, state: StatusbarState, width: number, theme?: any): string[] {
const moduleCtx: ModuleContext = { ctx, footerData, state, config: statusbarConfig }; const moduleCtx: ModuleContext = { ctx, footerData, state, config: statusbarConfig };
return statusbarConfig.rows.map(row => renderRow({ return statusbarConfig.rows.map(row => renderRow({
left: (row.left ?? []).map(spec => renderModule(moduleCtx, spec)), left: (row.left ?? []).map(spec => renderModule(moduleCtx, spec)),
@@ -102,9 +110,10 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
let timer: ReturnType<typeof setInterval> | undefined; let timer: ReturnType<typeof setInterval> | undefined;
let inFlight: AbortController | undefined; let inFlight: AbortController | undefined;
let lastProvider: Provider | undefined; let lastProvider: Provider | undefined;
let latestCtx: any; let latestCtx: ExtensionContext | undefined;
let requestRender: (() => void) | undefined; let requestRender: (() => void) | undefined;
const statusbarState: StatusbarState = {}; const statusbarState: StatusbarState = {};
const authStorage = AuthStorage.create();
function updateThinkingLevel() { function updateThinkingLevel() {
try { try {
@@ -114,13 +123,22 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
} }
} }
function rerender(ctx: any) { let rerenderScheduled = false;
function rerender(ctx: ExtensionContext) {
latestCtx = ctx; latestCtx = ctx;
requestRender?.(); if (rerenderScheduled) return;
if (ctx.hasUI) ctx.ui.setStatus("pi-statusbar", undefined); rerenderScheduled = true;
// Debounce Render - Coalesce rapid rerender calls into a single pass.
queueMicrotask(() => {
rerenderScheduled = false;
requestRender?.();
const current = latestCtx;
if (current?.hasUI) current.ui.setStatus("pi-statusbar", undefined);
});
} }
function installFooter(ctx: any) { function installFooter(ctx: ExtensionContext) {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
latestCtx = ctx; latestCtx = ctx;
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => { ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
@@ -137,7 +155,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
}); });
} }
async function refresh(ctx: any, force = false) { async function refresh(ctx: ExtensionContext, force = false) {
latestCtx = ctx; latestCtx = ctx;
const provider = activeProvider(ctx); const provider = activeProvider(ctx);
lastProvider = provider; lastProvider = provider;
@@ -148,13 +166,13 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
return; return;
} }
const minRefreshMs = provider === "anthropic" ? ANTHROPIC_REFRESH_MS : REFRESH_MS; const minRefreshMs = REFRESH_MS;
if (!force && statusbarState.report?.provider === provider && Date.now() - statusbarState.report.fetchedAt < minRefreshMs) { if (!force && statusbarState.report?.provider === provider && Date.now() - statusbarState.report.fetchedAt < minRefreshMs) {
rerender(ctx); rerender(ctx);
return; return;
} }
const credential = readPiCredential(provider); const credential = await readPiCredential(authStorage, provider);
if (!credential) { if (!credential) {
statusbarState.report = { provider, fetchedAt: Date.now(), limits: [] }; statusbarState.report = { provider, fetchedAt: Date.now(), limits: [] };
statusbarState.error = "not logged in"; statusbarState.error = "not logged in";
@@ -163,25 +181,34 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
} }
inFlight?.abort(); inFlight?.abort();
inFlight = new AbortController(); const controller = new AbortController();
statusbarState.report = undefined; inFlight = controller;
statusbarState.error = undefined;
rerender(ctx);
try { try {
const report = await usageProviders[provider].fetchUsage({ let activeCredential = credential;
provider, const usageCtx = {
credential,
baseUrl: ctx.model?.baseUrl,
signal: inFlight.signal,
}, {
fetch: globalThis.fetch.bind(globalThis), fetch: globalThis.fetch.bind(globalThis),
logger: { logger: {
debug: () => undefined, debug: () => undefined,
warn: () => undefined, warn: () => undefined,
}, },
};
const fetchParams = () => ({
provider,
credential: activeCredential,
baseUrl: ctx.model?.baseUrl,
signal: controller.signal,
}); });
let report: UsageReport | null;
try {
report = await usageProviders[provider].fetchUsage(fetchParams(), usageCtx);
} catch (error) {
if (!(error instanceof Error) || error.message !== "unauthorized") throw error;
activeCredential = await forceRefreshPiCredential(authStorage, provider);
report = await usageProviders[provider].fetchUsage(fetchParams(), usageCtx);
}
statusbarState.report = report ?? { provider, fetchedAt: Date.now(), limits: [] }; statusbarState.report = report ?? { provider, fetchedAt: Date.now(), limits: [] };
statusbarState.error = report ? undefined : "unavailable"; statusbarState.error = report ? undefined : "unavailable";
} catch (error) { } catch (error) {
@@ -192,7 +219,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
if (lastProvider === provider) rerender(ctx); if (lastProvider === provider) rerender(ctx);
} }
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
updateThinkingLevel(); updateThinkingLevel();
installFooter(ctx); installFooter(ctx);
await refresh(ctx, true); await refresh(ctx, true);
@@ -201,7 +228,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
timer = setInterval(() => void refresh(latestCtx ?? ctx), REFRESH_MS); timer = setInterval(() => void refresh(latestCtx ?? ctx), REFRESH_MS);
}); });
pi.on("session_shutdown", async (_event, ctx) => { pi.on("session_shutdown", async (_event: SessionShutdownEvent, ctx: ExtensionContext) => {
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
timer = undefined; timer = undefined;
inFlight?.abort(); inFlight?.abort();
@@ -223,14 +250,14 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
rerender(ctx); rerender(ctx);
}); });
pi.on("agent_end", async (_event, ctx) => { pi.on("agent_end", async (_event: AgentEndEvent, ctx: ExtensionContext) => {
updateThinkingLevel(); updateThinkingLevel();
await refresh(ctx); await refresh(ctx);
}); });
pi.registerCommand("refresh-usage", { pi.registerCommand("refresh-usage", {
description: "Refresh account usage for the active provider", description: "Refresh account usage for the active provider",
handler: async (_args, ctx) => { handler: async (_args: string, ctx: ExtensionCommandContext) => {
await refresh(ctx, true); await refresh(ctx, true);
ctx.ui.notify(usageSummary(statusbarState.report, statusbarState.error), statusbarState.error ? "warning" : "info"); ctx.ui.notify(usageSummary(statusbarState.report, statusbarState.error), statusbarState.error ? "warning" : "info");
}, },

View File

@@ -14,7 +14,7 @@ function duration(valueMs: number | undefined): string | undefined {
const ms = Math.max(0, valueMs); const ms = Math.max(0, valueMs);
const days = Math.floor(ms / 86_400_000); const days = Math.floor(ms / 86_400_000);
const hours = Math.floor((ms % 86_400_000) / 3_600_000); const hours = Math.floor((ms % 86_400_000) / 3_600_000);
const minutes = Math.round((ms % 3_600_000) / 60_000); const minutes = Math.floor((ms % 3_600_000) / 60_000);
if (days > 0) return `${days}d${hours}h`; if (days > 0) return `${days}d${hours}h`;
if (hours > 0) return `${hours}h${minutes}m`; if (hours > 0) return `${hours}h${minutes}m`;
return `${minutes}m`; return `${minutes}m`;
@@ -60,8 +60,18 @@ function usageColor(fraction: number): { r: number; g: number; b: number } {
function findWindowLimits(report: UsageReport): UsageWindows { function findWindowLimits(report: UsageReport): UsageWindows {
const windowId = (limit: UsageLimit) => (limit.window?.id ?? limit.scope.windowId ?? "").toLowerCase(); const windowId = (limit: UsageLimit) => (limit.window?.id ?? limit.scope.windowId ?? "").toLowerCase();
const current = report.limits.find(limit => ["5h", "primary"].includes(windowId(limit))) ?? report.limits[0]; const limitId = (limit: UsageLimit) => (limit.id ?? "").toLowerCase();
const week = report.limits.find(limit => ["7d", "secondary"].includes(windowId(limit))) ?? report.limits.find(limit => limit !== current);
// Match by Window ID
const currentByWindow = report.limits.find(limit => ["5h", "primary"].includes(windowId(limit)));
const weekByWindow = report.limits.find(limit => ["7d", "secondary"].includes(windowId(limit)));
// Match by Limit ID (Z.ai Uses Generic "quota" Window IDs for Both Limits)
const currentByLimit = report.limits.find(limit => ["zai:tokens"].includes(limitId(limit)));
const weekByLimit = report.limits.find(limit => ["zai:requests"].includes(limitId(limit)));
const current = currentByWindow ?? currentByLimit ?? report.limits[0];
const week = weekByWindow ?? weekByLimit ?? report.limits.find(limit => limit !== current);
return { current, week }; return { current, week };
} }

View File

@@ -2,6 +2,7 @@ import type { ModuleSpec, RenderedModule } from "./types";
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const ANSI_RE = new RegExp("\\u001b\\[[0-9;]*m", "g"); const ANSI_RE = new RegExp("\\u001b\\[[0-9;]*m", "g");
const ANSI_PREFIX_RE = /^\u001b\[[0-9;]*m/;
export function visibleWidth(text: string): number { export function visibleWidth(text: string): number {
return text.replace(ANSI_RE, "").length; return text.replace(ANSI_RE, "").length;
@@ -16,8 +17,7 @@ export function truncate(text: string, width: number): string {
let count = 0; let count = 0;
for (let index = 0; index < text.length && count < width; index++) { for (let index = 0; index < text.length && count < width; index++) {
if (text[index] === "\x1b") { if (text[index] === "\x1b") {
// eslint-disable-next-line no-control-regex const match = text.slice(index).match(ANSI_PREFIX_RE);
const match = text.slice(index).match(new RegExp("^\\u001b\\[[0-9;]*m"));
if (match) { if (match) {
output += match[0]; output += match[0];
index += match[0].length - 1; index += match[0].length - 1;
@@ -34,10 +34,33 @@ export function joinText(parts: string[], separator: string): string {
return parts.filter(Boolean).join(` ${separator} `); return parts.filter(Boolean).join(` ${separator} `);
} }
// ANSI-Aware Slice - Extract a substring by visible character positions,
// preserving ANSI escape codes that precede visible characters.
function ansiSlice(text: string, start: number, end: number): string {
let output = "";
let visible = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "\x1b") {
const match = text.slice(i).match(ANSI_PREFIX_RE);
if (match) {
if (visible >= start && visible < end) output += match[0];
i += match[0].length - 1;
continue;
}
}
if (visible >= start && visible < end) output += text[i];
visible++;
if (visible >= end) break;
}
return output;
}
export function overlay(base: string, text: string): string { export function overlay(base: string, text: string): string {
if (visibleWidth(text) >= visibleWidth(base)) return truncate(text, visibleWidth(base)); const baseWidth = visibleWidth(base);
const start = Math.max(0, Math.floor((visibleWidth(base) - visibleWidth(text)) / 2)); const textWidth = visibleWidth(text);
return `${base.slice(0, start)}${text}${base.slice(start + visibleWidth(text))}`; if (textWidth >= baseWidth) return truncate(text, baseWidth);
const start = Math.max(0, Math.floor((baseWidth - textWidth) / 2));
return `${ansiSlice(base, 0, start)}${text}${ansiSlice(base, start + textWidth, baseWidth)}`;
} }
function dimText(text: string, theme?: any): string { function dimText(text: string, theme?: any): string {

View File

@@ -1,3 +1,4 @@
import type { ExtensionContext, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
import type { UsageLimit, UsageReport } from "./usage"; import type { UsageLimit, UsageReport } from "./usage";
export type ModuleName = "directory" | "context" | "model" | "thinking" | "cost" | "usage"; export type ModuleName = "directory" | "context" | "model" | "thinking" | "cost" | "usage";
@@ -40,8 +41,8 @@ export interface StatusbarState {
} }
export interface ModuleContext { export interface ModuleContext {
ctx: any; ctx: ExtensionContext;
footerData: any; footerData: ReadonlyFooterDataProvider;
state: StatusbarState; state: StatusbarState;
config: StatusbarConfig; config: StatusbarConfig;
} }

View File

@@ -1,13 +1,13 @@
import type { import type {
CredentialRankingStrategy, CredentialRankingStrategy,
UsageAmount, UsageAmount,
UsageFetchContext, UsageFetchContext,
UsageFetchParams, UsageFetchParams,
UsageLimit, UsageLimit,
UsageProvider, UsageProvider,
UsageReport, UsageReport,
UsageStatus, UsageStatus,
UsageWindow, UsageWindow,
} from "../usage"; } from "../usage";
import { isRecord, toNumber } from "../utils"; import { isRecord, toNumber } from "../utils";
@@ -18,320 +18,358 @@ const MAX_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 500; const BASE_RETRY_DELAY_MS = 500;
const CLAUDE_HEADERS = { const CLAUDE_HEADERS = {
accept: "application/json, text/plain, */*", accept: "application/json, text/plain, */*",
"accept-encoding": "gzip, compress, deflate, br", "accept-encoding": "gzip, compress, deflate, br",
"anthropic-beta": "anthropic-beta":
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
"content-type": "application/json", "content-type": "application/json",
"user-agent": "claude-cli/2.1.63 (external, cli)", "user-agent": "claude-cli/2.1.63 (external, cli)",
connection: "keep-alive", connection: "keep-alive",
} as const; } as const;
function normalizeClaudeBaseUrl(baseUrl?: string): string { function normalizeClaudeBaseUrl(baseUrl?: string): string {
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT; if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
const trimmed = baseUrl.trim().replace(/\/+$/, ""); const trimmed = baseUrl.trim().replace(/\/+$/, "");
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
if (lower.endsWith("/api/oauth")) return trimmed; if (lower.endsWith("/api/oauth")) return trimmed;
let url: URL; let url: URL;
try { try {
url = new URL(trimmed); url = new URL(trimmed);
} catch { } catch {
return DEFAULT_ENDPOINT; return DEFAULT_ENDPOINT;
} }
let path = url.pathname.replace(/\/+$/, ""); let path = url.pathname.replace(/\/+$/, "");
if (path === "/") path = ""; if (path === "/") path = "";
if (path.toLowerCase().endsWith("/v1")) { if (path.toLowerCase().endsWith("/v1")) {
path = path.slice(0, -3); path = path.slice(0, -3);
} }
if (!path) return `${url.origin}/api/oauth`; if (!path) return `${url.origin}/api/oauth`;
return `${url.origin}${path}/api/oauth`; return `${url.origin}${path}/api/oauth`;
} }
interface ClaudeUsageBucket { interface ClaudeUsageBucket {
utilization?: number; utilization?: number;
resets_at?: string; resets_at?: string;
} }
interface ParsedUsageBucket { interface ParsedUsageBucket {
utilization?: number; utilization?: number;
resetsAt?: number; resetsAt?: number;
} }
interface ClaudeUsageResponse { interface ClaudeUsageResponse {
five_hour?: ClaudeUsageBucket | null; five_hour?: ClaudeUsageBucket | null;
seven_day?: ClaudeUsageBucket | null; seven_day?: ClaudeUsageBucket | null;
seven_day_opus?: ClaudeUsageBucket | null; seven_day_opus?: ClaudeUsageBucket | null;
seven_day_sonnet?: ClaudeUsageBucket | null; seven_day_sonnet?: ClaudeUsageBucket | null;
} }
type ClaudeUsagePayload = { type ClaudeUsagePayload = {
payload: ClaudeUsageResponse; payload: ClaudeUsageResponse;
orgId?: string; orgId?: string;
}; };
function parseIsoTime(value: string | undefined): number | undefined { function parseIsoTime(value: string | undefined): number | undefined {
if (!value) return undefined; if (!value) return undefined;
const parsed = Date.parse(value); const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }
function parseBucket(bucket: unknown): ParsedUsageBucket | undefined { function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
if (!isRecord(bucket)) return undefined; if (!isRecord(bucket)) return undefined;
const utilization = toNumber(bucket.utilization); const utilization = toNumber(bucket.utilization);
const resetsAt = parseIsoTime(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined); const resetsAt = parseIsoTime(
if (utilization === undefined && resetsAt === undefined) { typeof bucket.resets_at === "string" ? bucket.resets_at : undefined,
return undefined; );
} if (utilization === undefined && resetsAt === undefined) {
return { utilization, resetsAt }; return undefined;
}
return { utilization, resetsAt };
} }
function getPayloadString(payload: Record<string, unknown>, key: string): string | undefined { function getPayloadString(
const value = payload[key]; payload: Record<string, unknown>,
return typeof value === "string" && value.trim() ? value.trim() : undefined; key: string,
): string | undefined {
const value = payload[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
} }
function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } { function extractUsageIdentity(
if (!isRecord(payload)) return { accountId: orgId }; payload: ClaudeUsageResponse,
const accountId = orgId?: string,
getPayloadString(payload, "account_id") ?? ): { accountId?: string; email?: string } {
getPayloadString(payload, "accountId") ?? if (!isRecord(payload)) return { accountId: orgId };
getPayloadString(payload, "user_id") ?? const accountId =
getPayloadString(payload, "userId") ?? getPayloadString(payload, "account_id") ??
getPayloadString(payload, "org_id") ?? getPayloadString(payload, "accountId") ??
getPayloadString(payload, "orgId") ?? getPayloadString(payload, "user_id") ??
orgId; getPayloadString(payload, "userId") ??
const email = getPayloadString(payload, "org_id") ??
getPayloadString(payload, "email") ?? getPayloadString(payload, "orgId") ??
getPayloadString(payload, "user_email") ?? orgId;
getPayloadString(payload, "userEmail"); const email =
return { accountId, email }; getPayloadString(payload, "email") ??
getPayloadString(payload, "user_email") ??
getPayloadString(payload, "userEmail");
return { accountId, email };
} }
function hasUsageData(payload: ClaudeUsageResponse): boolean { function hasUsageData(payload: ClaudeUsageResponse): boolean {
return Boolean(payload.five_hour || payload.seven_day || payload.seven_day_opus || payload.seven_day_sonnet); return Boolean(
payload.five_hour ||
payload.seven_day ||
payload.seven_day_opus ||
payload.seven_day_sonnet,
);
} }
async function fetchUsagePayload( async function fetchUsagePayload(
url: string, url: string,
headers: Record<string, string>, headers: Record<string, string>,
ctx: UsageFetchContext, ctx: UsageFetchContext,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ClaudeUsagePayload | null> { ): Promise<ClaudeUsagePayload | null> {
let lastPayload: ClaudeUsageResponse | null = null; let lastPayload: ClaudeUsageResponse | null = null;
let lastOrgId: string | undefined; let lastOrgId: string | undefined;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try { try {
const response = await ctx.fetch(url, { headers, signal }); const response = await ctx.fetch(url, { headers, signal });
if (!response.ok) { if (!response.ok) {
ctx.logger?.warn("Claude usage fetch failed", { status: response.status, statusText: response.statusText }); ctx.logger?.warn("Claude usage fetch failed", {
return null; status: response.status,
} statusText: response.statusText,
const payload = (await response.json()) as ClaudeUsageResponse; });
lastPayload = payload; return null;
const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined; }
lastOrgId = orgId ?? lastOrgId; const payload = (await response.json()) as ClaudeUsageResponse;
if (payload && isRecord(payload) && hasUsageData(payload)) { lastPayload = payload;
return { payload, orgId }; const orgId =
} response.headers.get("anthropic-organization-id")?.trim() || undefined;
} catch (error) { lastOrgId = orgId ?? lastOrgId;
ctx.logger?.warn("Claude usage fetch error", { error: String(error) }); if (payload && isRecord(payload) && hasUsageData(payload)) {
return null; return { payload, orgId };
} }
} catch (error) {
ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
return null;
}
if (attempt < MAX_RETRIES - 1) { if (attempt < MAX_RETRIES - 1) {
await new Promise(resolve => setTimeout(resolve, BASE_RETRY_DELAY_MS * 2 ** attempt)); await new Promise((resolve) =>
} setTimeout(resolve, BASE_RETRY_DELAY_MS * 2 ** attempt),
} );
}
}
return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null; return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
} }
interface ClaudeProfile { interface ClaudeProfile {
account?: { account?: {
uuid?: string; uuid?: string;
email?: string; email?: string;
}; };
} }
async function fetchProfile( async function fetchProfile(
baseUrl: string, baseUrl: string,
headers: Record<string, string>, headers: Record<string, string>,
ctx: UsageFetchContext, ctx: UsageFetchContext,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ClaudeProfile | null> { ): Promise<ClaudeProfile | null> {
const url = `${baseUrl}/profile`; const url = `${baseUrl}/profile`;
try { try {
const response = await ctx.fetch(url, { headers, signal }); const response = await ctx.fetch(url, { headers, signal });
if (!response.ok) return null; if (!response.ok) return null;
return (await response.json()) as ClaudeProfile; return (await response.json()) as ClaudeProfile;
} catch { } catch {
return null; return null;
} }
} }
async function resolveEmail( async function resolveEmail(
params: UsageFetchParams, params: UsageFetchParams,
ctx: UsageFetchContext, ctx: UsageFetchContext,
baseUrl: string, baseUrl: string,
headers: Record<string, string>, headers: Record<string, string>,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (params.credential.email) return params.credential.email; if (params.credential.email) return params.credential.email;
const profile = await fetchProfile(baseUrl, headers, ctx, params.signal); const profile = await fetchProfile(baseUrl, headers, ctx, params.signal);
return profile?.account?.email; return profile?.account?.email;
} }
function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined { function buildUsageAmount(
if (utilization === undefined) return undefined; utilization: number | undefined,
const clamped = Math.min(Math.max(utilization, 0), 100); ): UsageAmount | undefined {
const usedFraction = clamped / 100; if (utilization === undefined) return undefined;
return { const clamped = Math.min(Math.max(utilization, 0), 100);
used: clamped, const usedFraction = clamped / 100;
limit: 100, return {
remaining: Math.max(0, 100 - clamped), used: clamped,
usedFraction, limit: 100,
remainingFraction: Math.max(0, 1 - usedFraction), remaining: Math.max(0, 100 - clamped),
unit: "percent", usedFraction,
}; remainingFraction: Math.max(0, 1 - usedFraction),
unit: "percent",
};
} }
function buildUsageStatus(usedFraction: number | undefined): UsageStatus | undefined { function buildUsageStatus(
if (usedFraction === undefined) return undefined; usedFraction: number | undefined,
if (usedFraction >= 1) return "exhausted"; ): UsageStatus | undefined {
if (usedFraction >= 0.9) return "warning"; if (usedFraction === undefined) return undefined;
return "ok"; if (usedFraction >= 1) return "exhausted";
if (usedFraction >= 0.9) return "warning";
return "ok";
} }
function buildUsageLimit(args: { function buildUsageLimit(args: {
id: string; id: string;
label: string; label: string;
windowId: string; windowId: string;
windowLabel: string; windowLabel: string;
durationMs: number; durationMs: number;
bucket: ParsedUsageBucket | undefined; bucket: ParsedUsageBucket | undefined;
provider: "anthropic"; provider: "anthropic";
tier?: string; tier?: string;
shared?: boolean; shared?: boolean;
}): UsageLimit | null { }): UsageLimit | null {
if (!args.bucket) return null; if (!args.bucket) return null;
const amount = buildUsageAmount(args.bucket.utilization); const amount = buildUsageAmount(args.bucket.utilization);
if (!amount) return null; if (!amount) return null;
const window: UsageWindow = { const window: UsageWindow = {
id: args.windowId, id: args.windowId,
label: args.windowLabel, label: args.windowLabel,
durationMs: args.durationMs, durationMs: args.durationMs,
...(args.bucket.resetsAt !== undefined ? { resetsAt: args.bucket.resetsAt } : {}), ...(args.bucket.resetsAt !== undefined
}; ? { resetsAt: args.bucket.resetsAt }
return { : {}),
id: args.id, };
label: args.label, return {
scope: { id: args.id,
provider: args.provider, label: args.label,
windowId: args.windowId, scope: {
tier: args.tier, provider: args.provider,
shared: args.shared, windowId: args.windowId,
}, tier: args.tier,
window, shared: args.shared,
amount, },
status: buildUsageStatus(amount.usedFraction), window,
}; amount,
status: buildUsageStatus(amount.usedFraction),
};
} }
async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> { async function fetchClaudeUsage(
if (params.provider !== "anthropic") return null; params: UsageFetchParams,
const credential = params.credential; ctx: UsageFetchContext,
if (credential.type !== "oauth" || !credential.accessToken) return null; ): Promise<UsageReport | null> {
if (params.provider !== "anthropic") return null;
const credential = params.credential;
if (credential.type !== "oauth" || !credential.accessToken) return null;
const baseUrl = normalizeClaudeBaseUrl(params.baseUrl); const baseUrl = normalizeClaudeBaseUrl(params.baseUrl);
const url = `${baseUrl}/usage`; const url = `${baseUrl}/usage`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
...CLAUDE_HEADERS, ...CLAUDE_HEADERS,
authorization: `Bearer ${credential.accessToken}`, authorization: `Bearer ${credential.accessToken}`,
}; };
const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal); const payloadResult = await fetchUsagePayload(
if (!payloadResult || !isRecord(payloadResult.payload)) return null; url,
const { payload, orgId } = payloadResult; headers,
ctx,
params.signal,
);
if (!payloadResult || !isRecord(payloadResult.payload)) return null;
const { payload, orgId } = payloadResult;
const fiveHour = parseBucket(payload.five_hour); const fiveHour = parseBucket(payload.five_hour);
const sevenDay = parseBucket(payload.seven_day); const sevenDay = parseBucket(payload.seven_day);
const sevenDayOpus = parseBucket(payload.seven_day_opus); const sevenDayOpus = parseBucket(payload.seven_day_opus);
const sevenDaySonnet = parseBucket(payload.seven_day_sonnet); const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
const limits = [ const limits = [
buildUsageLimit({ buildUsageLimit({
id: "anthropic:5h", id: "anthropic:5h",
label: "Claude 5 Hour", label: "Claude 5 Hour",
windowId: "5h", windowId: "5h",
windowLabel: "5 Hour", windowLabel: "5 Hour",
durationMs: FIVE_HOURS_MS, durationMs: FIVE_HOURS_MS,
bucket: fiveHour, bucket: fiveHour,
provider: "anthropic", provider: "anthropic",
shared: true, shared: true,
}), }),
buildUsageLimit({ buildUsageLimit({
id: "anthropic:7d", id: "anthropic:7d",
label: "Claude 7 Day", label: "Claude 7 Day",
windowId: "7d", windowId: "7d",
windowLabel: "7 Day", windowLabel: "7 Day",
durationMs: SEVEN_DAYS_MS, durationMs: SEVEN_DAYS_MS,
bucket: sevenDay, bucket: sevenDay,
provider: "anthropic", provider: "anthropic",
shared: true, shared: true,
}), }),
buildUsageLimit({ buildUsageLimit({
id: "anthropic:7d:opus", id: "anthropic:7d:opus",
label: "Claude 7 Day (Opus)", label: "Claude 7 Day (Opus)",
windowId: "7d", windowId: "7d",
windowLabel: "7 Day", windowLabel: "7 Day",
durationMs: SEVEN_DAYS_MS, durationMs: SEVEN_DAYS_MS,
bucket: sevenDayOpus, bucket: sevenDayOpus,
provider: "anthropic", provider: "anthropic",
tier: "opus", tier: "opus",
}), }),
buildUsageLimit({ buildUsageLimit({
id: "anthropic:7d:sonnet", id: "anthropic:7d:sonnet",
label: "Claude 7 Day (Sonnet)", label: "Claude 7 Day (Sonnet)",
windowId: "7d", windowId: "7d",
windowLabel: "7 Day", windowLabel: "7 Day",
durationMs: SEVEN_DAYS_MS, durationMs: SEVEN_DAYS_MS,
bucket: sevenDaySonnet, bucket: sevenDaySonnet,
provider: "anthropic", provider: "anthropic",
tier: "sonnet", tier: "sonnet",
}), }),
].filter((limit): limit is UsageLimit => limit !== null); ].filter((limit): limit is UsageLimit => limit !== null);
if (limits.length === 0) return null; if (limits.length === 0) return null;
const identity = extractUsageIdentity(payload, orgId); const identity = extractUsageIdentity(payload, orgId);
const accountId = identity.accountId ?? credential.accountId; const accountId = identity.accountId ?? credential.accountId;
const email = identity.email ?? (await resolveEmail(params, ctx, baseUrl, headers)); const email =
identity.email ?? (await resolveEmail(params, ctx, baseUrl, headers));
const report: UsageReport = { const report: UsageReport = {
provider: params.provider, provider: params.provider,
fetchedAt: Date.now(), fetchedAt: Date.now(),
limits, limits,
metadata: { metadata: {
accountId, accountId,
email, email,
endpoint: url, endpoint: url,
}, },
raw: payload, raw: payload,
}; };
return report; return report;
} }
export const claudeUsageProvider: UsageProvider = { export const claudeUsageProvider: UsageProvider = {
id: "anthropic", id: "anthropic",
fetchUsage: fetchClaudeUsage, fetchUsage: fetchClaudeUsage,
supports: params => params.provider === "anthropic" && params.credential.type === "oauth", supports: (params) =>
params.provider === "anthropic" && params.credential.type === "oauth",
}; };
export const claudeRankingStrategy: CredentialRankingStrategy = { export const claudeRankingStrategy: CredentialRankingStrategy = {
findWindowLimits(report) { findWindowLimits(report) {
const primary = report.limits.find(l => l.id === "anthropic:5h"); const primary = report.limits.find((l) => l.id === "anthropic:5h");
const secondary = report.limits.find(l => l.id === "anthropic:7d"); const secondary = report.limits.find((l) => l.id === "anthropic:7d");
return { primary, secondary }; return { primary, secondary };
}, },
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 }, windowDefaults: {
primaryMs: 5 * 60 * 60 * 1000,
secondaryMs: 7 * 24 * 60 * 60 * 1000,
},
}; };

View File

@@ -1,377 +1,431 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { CODEX_BASE_URL } from "../providers/openai-codex/constants"; import { CODEX_BASE_URL } from "../providers/openai-codex/constants";
import type { import type {
CredentialRankingStrategy, CredentialRankingStrategy,
UsageAmount, UsageAmount,
UsageFetchContext, UsageFetchContext,
UsageFetchParams, UsageFetchParams,
UsageLimit, UsageLimit,
UsageProvider, UsageProvider,
UsageReport, UsageReport,
UsageWindow, UsageWindow,
} from "../usage"; } from "../usage";
import { isRecord } from "../utils"; import { isRecord, toNumber } from "../utils";
import { toNumber } from "./shared";
const CODEX_USAGE_PATH = "wham/usage"; const CODEX_USAGE_PATH = "wham/usage";
const JWT_AUTH_CLAIM = "https://api.openai.com/auth"; const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
const JWT_PROFILE_CLAIM = "https://api.openai.com/profile"; const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
interface CodexUsageWindowPayload { interface CodexUsageWindowPayload {
used_percent?: number; used_percent?: number;
limit_window_seconds?: number; limit_window_seconds?: number;
reset_after_seconds?: number; reset_after_seconds?: number;
reset_at?: number; reset_at?: number;
} }
interface CodexUsageRateLimitPayload { interface CodexUsageRateLimitPayload {
allowed?: boolean; allowed?: boolean;
limit_reached?: boolean; limit_reached?: boolean;
primary_window?: CodexUsageWindowPayload | null; primary_window?: CodexUsageWindowPayload | null;
secondary_window?: CodexUsageWindowPayload | null; secondary_window?: CodexUsageWindowPayload | null;
} }
interface CodexUsagePayload { interface CodexUsagePayload {
plan_type?: string; plan_type?: string;
rate_limit?: CodexUsageRateLimitPayload | null; rate_limit?: CodexUsageRateLimitPayload | null;
} }
interface ParsedUsageWindow { interface ParsedUsageWindow {
usedPercent?: number; usedPercent?: number;
limitWindowSeconds?: number; limitWindowSeconds?: number;
resetAfterSeconds?: number; resetAfterSeconds?: number;
resetAt?: number; resetAt?: number;
} }
interface ParsedUsage { interface ParsedUsage {
planType?: string; planType?: string;
allowed?: boolean; allowed?: boolean;
limitReached?: boolean; limitReached?: boolean;
primary?: ParsedUsageWindow; primary?: ParsedUsageWindow;
secondary?: ParsedUsageWindow; secondary?: ParsedUsageWindow;
raw: CodexUsagePayload; raw: CodexUsagePayload;
} }
interface JwtPayload { interface JwtPayload {
[JWT_AUTH_CLAIM]?: { [JWT_AUTH_CLAIM]?: {
chatgpt_account_id?: string; chatgpt_account_id?: string;
}; };
[JWT_PROFILE_CLAIM]?: { [JWT_PROFILE_CLAIM]?: {
email?: string; email?: string;
}; };
} }
const toBoolean = (value: unknown): boolean | undefined => { const toBoolean = (value: unknown): boolean | undefined => {
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
return undefined; return undefined;
}; };
function base64UrlDecode(input: string): string { function base64UrlDecode(input: string): string {
const base64 = input.replace(/-/g, "+").replace(/_/g, "/"); const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (base64.length % 4)) % 4; const padLen = (4 - (base64.length % 4)) % 4;
const padded = base64 + "=".repeat(padLen); const padded = base64 + "=".repeat(padLen);
return Buffer.from(padded, "base64").toString("utf8"); return Buffer.from(padded, "base64").toString("utf8");
} }
function parseJwt(token: string): JwtPayload | null { function parseJwt(token: string): JwtPayload | null {
const parts = token.split("."); const parts = token.split(".");
if (parts.length !== 3) return null; if (parts.length !== 3) return null;
try { try {
const payloadJson = base64UrlDecode(parts[1]); const payloadJson = base64UrlDecode(parts[1]);
return JSON.parse(payloadJson) as JwtPayload; return JSON.parse(payloadJson) as JwtPayload;
} catch { } catch {
return null; return null;
} }
} }
function normalizeEmail(email: string | undefined): string | undefined { function normalizeEmail(email: string | undefined): string | undefined {
if (!email) return undefined; if (!email) return undefined;
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
return normalized || undefined; return normalized || undefined;
} }
function extractAccountId(token: string | undefined): string | undefined { function extractAccountId(token: string | undefined): string | undefined {
if (!token) return undefined; if (!token) return undefined;
const payload = parseJwt(token); const payload = parseJwt(token);
return payload?.[JWT_AUTH_CLAIM]?.chatgpt_account_id ?? undefined; return payload?.[JWT_AUTH_CLAIM]?.chatgpt_account_id ?? undefined;
} }
function extractEmail(token: string | undefined): string | undefined { function extractEmail(token: string | undefined): string | undefined {
if (!token) return undefined; if (!token) return undefined;
const payload = parseJwt(token); const payload = parseJwt(token);
return normalizeEmail(payload?.[JWT_PROFILE_CLAIM]?.email); return normalizeEmail(payload?.[JWT_PROFILE_CLAIM]?.email);
} }
function parseUsageWindow(payload: unknown): ParsedUsageWindow | undefined { function parseUsageWindow(payload: unknown): ParsedUsageWindow | undefined {
if (!isRecord(payload)) return undefined; if (!isRecord(payload)) return undefined;
const usedPercent = toNumber(payload.used_percent); const usedPercent = toNumber(payload.used_percent);
const limitWindowSeconds = toNumber(payload.limit_window_seconds); const limitWindowSeconds = toNumber(payload.limit_window_seconds);
const resetAfterSeconds = toNumber(payload.reset_after_seconds); const resetAfterSeconds = toNumber(payload.reset_after_seconds);
const resetAt = toNumber(payload.reset_at); const resetAt = toNumber(payload.reset_at);
if ( if (
usedPercent === undefined && usedPercent === undefined &&
limitWindowSeconds === undefined && limitWindowSeconds === undefined &&
resetAfterSeconds === undefined && resetAfterSeconds === undefined &&
resetAt === undefined resetAt === undefined
) { ) {
return undefined; return undefined;
} }
return { return {
usedPercent, usedPercent,
limitWindowSeconds, limitWindowSeconds,
resetAfterSeconds, resetAfterSeconds,
resetAt, resetAt,
}; };
} }
function parseUsagePayload(payload: unknown): ParsedUsage | null { function parseUsagePayload(payload: unknown): ParsedUsage | null {
if (!isRecord(payload)) return null; if (!isRecord(payload)) return null;
const planType = typeof payload.plan_type === "string" ? payload.plan_type : undefined; const planType =
const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined; typeof payload.plan_type === "string" ? payload.plan_type : undefined;
if (!rateLimit) return null; const rateLimit = isRecord(payload.rate_limit)
const parsed: ParsedUsage = { ? payload.rate_limit
planType, : undefined;
allowed: toBoolean(rateLimit.allowed), if (!rateLimit) return null;
limitReached: toBoolean(rateLimit.limit_reached), const parsed: ParsedUsage = {
primary: parseUsageWindow(rateLimit.primary_window), planType,
secondary: parseUsageWindow(rateLimit.secondary_window), allowed: toBoolean(rateLimit.allowed),
raw: payload as CodexUsagePayload, limitReached: toBoolean(rateLimit.limit_reached),
}; primary: parseUsageWindow(rateLimit.primary_window),
if (!parsed.primary && !parsed.secondary && parsed.allowed === undefined && parsed.limitReached === undefined) { secondary: parseUsageWindow(rateLimit.secondary_window),
return null; raw: payload as CodexUsagePayload,
} };
return parsed; if (
!parsed.primary &&
!parsed.secondary &&
parsed.allowed === undefined &&
parsed.limitReached === undefined
) {
return null;
}
return parsed;
} }
function normalizeCodexBaseUrl(baseUrl?: string): string { function normalizeCodexBaseUrl(baseUrl?: string): string {
const fallback = CODEX_BASE_URL; const fallback = CODEX_BASE_URL;
const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback; const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
const base = trimmed.replace(/\/+$/, ""); const base = trimmed.replace(/\/+$/, "");
const lower = base.toLowerCase(); const lower = base.toLowerCase();
if ( if (
(lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) && (lower.startsWith("https://chatgpt.com") ||
!lower.includes("/backend-api") lower.startsWith("https://chat.openai.com")) &&
) { !lower.includes("/backend-api")
return `${base}/backend-api`; ) {
} return `${base}/backend-api`;
return base; }
return base;
} }
function buildCodexUsageUrl(baseUrl: string): string { function buildCodexUsageUrl(baseUrl: string): string {
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${normalized}${CODEX_USAGE_PATH}`; return `${normalized}${CODEX_USAGE_PATH}`;
} }
function formatWindowLabel(value: number, unit: "hour" | "day"): string { function formatWindowLabel(value: number, unit: "hour" | "day"): string {
const rounded = Math.round(value); const rounded = Math.round(value);
const suffix = rounded === 1 ? unit : `${unit}s`; const suffix = rounded === 1 ? unit : `${unit}s`;
return `${rounded} ${suffix}`; return `${rounded} ${suffix}`;
} }
function buildWindowLabel(seconds: number): { id: string; label: string } { function buildWindowLabel(seconds: number): { id: string; label: string } {
const daySeconds = 86_400; const daySeconds = 86_400;
if (seconds >= daySeconds) { if (seconds >= daySeconds) {
const days = Math.round(seconds / daySeconds); const days = Math.round(seconds / daySeconds);
return { id: `${days}d`, label: formatWindowLabel(days, "day") }; return { id: `${days}d`, label: formatWindowLabel(days, "day") };
} }
const hours = Math.max(1, Math.round(seconds / 3600)); const hours = Math.max(1, Math.round(seconds / 3600));
return { id: `${hours}h`, label: formatWindowLabel(hours, "hour") }; return { id: `${hours}h`, label: formatWindowLabel(hours, "hour") };
} }
function resolveResetTime(window: ParsedUsageWindow, nowMs: number): number | undefined { function resolveResetTime(
const resetAt = window.resetAt; window: ParsedUsageWindow,
if (resetAt !== undefined) { nowMs: number,
const resetAtMs = resetAt > 1_000_000_000_000 ? resetAt : resetAt * 1000; ): number | undefined {
if (Number.isFinite(resetAtMs)) return resetAtMs; const resetAt = window.resetAt;
} if (resetAt !== undefined) {
if (window.resetAfterSeconds !== undefined) { const resetAtMs = resetAt > 1_000_000_000_000 ? resetAt : resetAt * 1000;
return nowMs + window.resetAfterSeconds * 1000; if (Number.isFinite(resetAtMs)) return resetAtMs;
} }
return undefined; if (window.resetAfterSeconds !== undefined) {
return nowMs + window.resetAfterSeconds * 1000;
}
return undefined;
} }
function buildUsageWindow(window: ParsedUsageWindow, key: string, nowMs: number): UsageWindow { function buildUsageWindow(
const resetsAt = resolveResetTime(window, nowMs); window: ParsedUsageWindow,
if (window.limitWindowSeconds !== undefined) { key: string,
const { id, label } = buildWindowLabel(window.limitWindowSeconds); nowMs: number,
const durationMs = window.limitWindowSeconds * 1000; ): UsageWindow {
return { id, label, durationMs, ...(resetsAt !== undefined ? { resetsAt } : {}) }; const resetsAt = resolveResetTime(window, nowMs);
} if (window.limitWindowSeconds !== undefined) {
const fallbackLabel = key === "primary" ? "Primary window" : "Secondary window"; const { id, label } = buildWindowLabel(window.limitWindowSeconds);
return { id: key, label: fallbackLabel, ...(resetsAt !== undefined ? { resetsAt } : {}) }; const durationMs = window.limitWindowSeconds * 1000;
return {
id,
label,
durationMs,
...(resetsAt !== undefined ? { resetsAt } : {}),
};
}
const fallbackLabel =
key === "primary" ? "Primary window" : "Secondary window";
return {
id: key,
label: fallbackLabel,
...(resetsAt !== undefined ? { resetsAt } : {}),
};
} }
function buildUsageAmount(window: ParsedUsageWindow): UsageAmount { function buildUsageAmount(window: ParsedUsageWindow): UsageAmount {
const usedPercent = window.usedPercent; const usedPercent = window.usedPercent;
if (usedPercent === undefined) { if (usedPercent === undefined) {
return { unit: "percent" }; return { unit: "percent" };
} }
const clamped = Math.min(Math.max(usedPercent, 0), 100); const clamped = Math.min(Math.max(usedPercent, 0), 100);
const usedFraction = clamped / 100; const usedFraction = clamped / 100;
return { return {
used: clamped, used: clamped,
limit: 100, limit: 100,
remaining: Math.max(0, 100 - clamped), remaining: Math.max(0, 100 - clamped),
usedFraction, usedFraction,
remainingFraction: Math.max(0, 1 - usedFraction), remainingFraction: Math.max(0, 1 - usedFraction),
unit: "percent", unit: "percent",
}; };
} }
function buildUsageStatus(usedFraction?: number, limitReached?: boolean): UsageLimit["status"] { function buildUsageStatus(
if (limitReached) return "exhausted"; usedFraction?: number,
if (usedFraction === undefined) return "unknown"; limitReached?: boolean,
if (usedFraction >= 1) return "exhausted"; ): UsageLimit["status"] {
if (usedFraction >= 0.9) return "warning"; if (limitReached) return "exhausted";
return "ok"; if (usedFraction === undefined) return "unknown";
if (usedFraction >= 1) return "exhausted";
if (usedFraction >= 0.9) return "warning";
return "ok";
} }
function buildUsageLimit(args: { function buildUsageLimit(args: {
key: "primary" | "secondary"; key: "primary" | "secondary";
window: ParsedUsageWindow; window: ParsedUsageWindow;
accountId?: string; accountId?: string;
planType?: string; planType?: string;
limitReached?: boolean; limitReached?: boolean;
nowMs: number; nowMs: number;
}): UsageLimit { }): UsageLimit {
const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs); const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs);
const amount = buildUsageAmount(args.window); const amount = buildUsageAmount(args.window);
return { return {
id: `openai-codex:${args.key}`, id: `openai-codex:${args.key}`,
label: usageWindow.label, label: usageWindow.label,
scope: { scope: {
provider: "openai-codex", provider: "openai-codex",
accountId: args.accountId, accountId: args.accountId,
tier: args.planType, tier: args.planType,
windowId: usageWindow.id, windowId: usageWindow.id,
shared: true, shared: true,
}, },
window: usageWindow, window: usageWindow,
amount, amount,
status: buildUsageStatus(amount.usedFraction, args.limitReached), status: buildUsageStatus(amount.usedFraction, args.limitReached),
}; };
} }
export const openaiCodexUsageProvider: UsageProvider = { export const openaiCodexUsageProvider: UsageProvider = {
id: "openai-codex", id: "openai-codex",
supports(params: UsageFetchParams): boolean { supports(params: UsageFetchParams): boolean {
return params.provider === "openai-codex" && params.credential.type === "oauth"; return (
}, params.provider === "openai-codex" && params.credential.type === "oauth"
async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> { );
if (params.provider !== "openai-codex") return null; },
const { credential } = params; async fetchUsage(
if (credential.type !== "oauth") return null; params: UsageFetchParams,
ctx: UsageFetchContext,
): Promise<UsageReport | null> {
if (params.provider !== "openai-codex") return null;
const { credential } = params;
if (credential.type !== "oauth") return null;
const accessToken = credential.accessToken; const accessToken = credential.accessToken;
if (!accessToken) return null; if (!accessToken) return null;
const nowMs = Date.now(); const nowMs = Date.now();
if (credential.expiresAt !== undefined && credential.expiresAt <= nowMs) { if (credential.expiresAt !== undefined && credential.expiresAt <= nowMs) {
ctx.logger?.warn("Codex usage token expired", { provider: params.provider }); ctx.logger?.warn("Codex usage token expired", {
return null; provider: params.provider,
} });
return null;
}
const baseUrl = normalizeCodexBaseUrl(params.baseUrl); const baseUrl = normalizeCodexBaseUrl(params.baseUrl);
const accountId = credential.accountId ?? extractAccountId(accessToken); const accountId = credential.accountId ?? extractAccountId(accessToken);
const email = normalizeEmail(credential.email ?? extractEmail(accessToken)); const email = normalizeEmail(credential.email ?? extractEmail(accessToken));
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
"User-Agent": "OpenCode-Status-Plugin/1.0", "User-Agent": "OpenCode-Status-Plugin/1.0",
}; };
if (accountId) { if (accountId) {
headers["ChatGPT-Account-Id"] = accountId; headers["ChatGPT-Account-Id"] = accountId;
} }
const url = buildCodexUsageUrl(baseUrl); const url = buildCodexUsageUrl(baseUrl);
let payload: unknown; let payload: unknown;
try { try {
const response = await ctx.fetch(url, { headers, signal: params.signal }); const response = await ctx.fetch(url, { headers, signal: params.signal });
if (!response.ok) { if (!response.ok) {
ctx.logger?.warn("Codex usage request failed", { status: response.status, provider: params.provider }); ctx.logger?.warn("Codex usage request failed", {
return null; status: response.status,
} provider: params.provider,
payload = await response.json(); });
} catch (error) { return null;
ctx.logger?.warn("Codex usage request error", { provider: params.provider, error: String(error) }); }
return null; payload = await response.json();
} } catch (error) {
ctx.logger?.warn("Codex usage request error", {
provider: params.provider,
error: String(error),
});
return null;
}
const parsed = parseUsagePayload(payload); const parsed = parseUsagePayload(payload);
const planType = const planType =
parsed?.planType ?? parsed?.planType ??
(isRecord(payload) && typeof payload.plan_type === "string" ? payload.plan_type : undefined); (isRecord(payload) && typeof payload.plan_type === "string"
? payload.plan_type
: undefined);
const limits: UsageLimit[] = []; const limits: UsageLimit[] = [];
if (parsed?.primary) { if (parsed?.primary) {
limits.push( limits.push(
buildUsageLimit({ buildUsageLimit({
key: "primary", key: "primary",
window: parsed.primary, window: parsed.primary,
accountId, accountId,
planType, planType,
limitReached: parsed.limitReached, limitReached: parsed.limitReached,
nowMs, nowMs,
}), }),
); );
} }
if (parsed?.secondary) { if (parsed?.secondary) {
limits.push( limits.push(
buildUsageLimit({ buildUsageLimit({
key: "secondary", key: "secondary",
window: parsed.secondary, window: parsed.secondary,
accountId, accountId,
planType, planType,
limitReached: parsed.limitReached, limitReached: parsed.limitReached,
nowMs, nowMs,
}), }),
); );
} }
const report: UsageReport = { const report: UsageReport = {
provider: "openai-codex", provider: "openai-codex",
fetchedAt: nowMs, fetchedAt: nowMs,
limits, limits,
metadata: { metadata: {
planType, planType,
allowed: parsed?.allowed, allowed: parsed?.allowed,
limitReached: parsed?.limitReached, limitReached: parsed?.limitReached,
email, email,
accountId, accountId,
}, },
raw: parsed?.raw ?? payload, raw: parsed?.raw ?? payload,
}; };
return report; return report;
}, },
}; };
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000; const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
export const codexRankingStrategy: CredentialRankingStrategy = { export const codexRankingStrategy: CredentialRankingStrategy = {
findWindowLimits(report) { findWindowLimits(report) {
const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => { const findLimit = (
const direct = report.limits.find(l => l.id === `openai-codex:${key}`); key: "primary" | "secondary",
if (direct) return direct; ): UsageLimit | undefined => {
const byId = report.limits.find(l => l.id.toLowerCase().includes(key)); const direct = report.limits.find((l) => l.id === `openai-codex:${key}`);
if (byId) return byId; if (direct) return direct;
const windowId = key === "secondary" ? "7d" : "1h"; const byId = report.limits.find((l) => l.id.toLowerCase().includes(key));
return report.limits.find(l => l.scope.windowId?.toLowerCase() === windowId); if (byId) return byId;
}; const windowId = key === "secondary" ? "7d" : "1h";
return { primary: findLimit("primary"), secondary: findLimit("secondary") }; return report.limits.find(
}, (l) => l.scope.windowId?.toLowerCase() === windowId,
windowDefaults: { primaryMs: 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 }, );
hasPriorityBoost(primary) { };
if (!primary) return false; return { primary: findLimit("primary"), secondary: findLimit("secondary") };
const windowId = primary.scope.windowId?.toLowerCase(); },
const durationMs = primary.window?.durationMs; windowDefaults: {
const isFiveHourWindow = primaryMs: 60 * 60 * 1000,
windowId === "5h" || secondaryMs: 7 * 24 * 60 * 60 * 1000,
(typeof durationMs === "number" && },
Number.isFinite(durationMs) && hasPriorityBoost(primary) {
Math.abs(durationMs - FIVE_HOUR_MS) <= 60_000); if (!primary) return false;
if (!isFiveHourWindow) return false; const windowId = primary.scope.windowId?.toLowerCase();
const usedFraction = primary.amount.usedFraction; const durationMs = primary.window?.durationMs;
return typeof usedFraction === "number" && Number.isFinite(usedFraction) && usedFraction === 0; const isFiveHourWindow =
}, windowId === "5h" ||
(typeof durationMs === "number" &&
Number.isFinite(durationMs) &&
Math.abs(durationMs - FIVE_HOUR_MS) <= 60_000);
if (!isFiveHourWindow) return false;
const usedFraction = primary.amount.usedFraction;
return (
typeof usedFraction === "number" &&
Number.isFinite(usedFraction) &&
usedFraction === 0
);
},
}; };

View File

@@ -1,10 +0,0 @@
export const toNumber = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
};

View File

@@ -1,12 +1,12 @@
import type { import type {
UsageAmount, UsageAmount,
UsageFetchContext, UsageFetchContext,
UsageFetchParams, UsageFetchParams,
UsageLimit, UsageLimit,
UsageProvider, UsageProvider,
UsageReport, UsageReport,
UsageStatus, UsageStatus,
UsageWindow, UsageWindow,
} from "../usage"; } from "../usage";
import { isRecord, toNumber } from "../utils"; import { isRecord, toNumber } from "../utils";
@@ -16,232 +16,247 @@ const MODEL_USAGE_PATH = "/api/monitor/usage/model-usage";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function normalizeZaiBaseUrl(baseUrl?: string): string { function normalizeZaiBaseUrl(baseUrl?: string): string {
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT; if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
try { try {
return new URL(baseUrl.trim()).origin; return new URL(baseUrl.trim()).origin;
} catch { } catch {
return DEFAULT_ENDPOINT; return DEFAULT_ENDPOINT;
} }
} }
interface ZaiUsageLimitItem { interface ZaiUsageLimitItem {
type?: string; type?: string;
usage?: number; usage?: number;
currentValue?: number; currentValue?: number;
percentage?: number; percentage?: number;
remaining?: number; remaining?: number;
nextResetTime?: number; nextResetTime?: number;
} }
interface ZaiQuotaPayload { interface ZaiQuotaPayload {
success?: boolean; success?: boolean;
code?: number; code?: number;
msg?: string; msg?: string;
data?: { data?: {
limits?: ZaiUsageLimitItem[]; limits?: ZaiUsageLimitItem[];
}; };
} }
function parseMillis(value: unknown): number | undefined { function parseMillis(value: unknown): number | undefined {
const parsed = toNumber(value); const parsed = toNumber(value);
if (parsed === undefined) return undefined; if (parsed === undefined) return undefined;
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000; return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
} }
function parseLimitItem(value: unknown): ZaiUsageLimitItem | null { function parseLimitItem(value: unknown): ZaiUsageLimitItem | null {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
const type = typeof value.type === "string" ? value.type : undefined; const type = typeof value.type === "string" ? value.type : undefined;
if (!type) return null; if (!type) return null;
return { return {
type, type,
usage: toNumber(value.usage), usage: toNumber(value.usage),
currentValue: toNumber(value.currentValue), currentValue: toNumber(value.currentValue),
percentage: toNumber(value.percentage), percentage: toNumber(value.percentage),
remaining: toNumber(value.remaining), remaining: toNumber(value.remaining),
nextResetTime: parseMillis(value.nextResetTime), nextResetTime: parseMillis(value.nextResetTime),
}; };
} }
function buildUsageAmount(args: { function buildUsageAmount(args: {
used: number | undefined; used: number | undefined;
limit: number | undefined; limit: number | undefined;
remaining: number | undefined; remaining: number | undefined;
unit: UsageAmount["unit"]; unit: UsageAmount["unit"];
percentage?: number; percentage?: number;
}): UsageAmount { }): UsageAmount {
const usedFraction = const usedFraction =
args.percentage !== undefined args.percentage !== undefined
? Math.min(Math.max(args.percentage / 100, 0), 1) ? Math.min(Math.max(args.percentage / 100, 0), 1)
: args.used !== undefined && args.limit !== undefined && args.limit > 0 : args.used !== undefined && args.limit !== undefined && args.limit > 0
? Math.min(args.used / args.limit, 1) ? Math.min(args.used / args.limit, 1)
: undefined; : undefined;
const remainingFraction = usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined; const remainingFraction =
return { usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined;
used: args.used, return {
limit: args.limit, used: args.used,
remaining: args.remaining, limit: args.limit,
usedFraction, remaining: args.remaining,
remainingFraction, usedFraction,
unit: args.unit, remainingFraction,
}; unit: args.unit,
};
} }
function getUsageStatus(usedFraction: number | undefined): UsageStatus | undefined { function getUsageStatus(
if (usedFraction === undefined) return undefined; usedFraction: number | undefined,
if (usedFraction >= 1) return "exhausted"; ): UsageStatus | undefined {
if (usedFraction >= 0.9) return "warning"; if (usedFraction === undefined) return undefined;
return "ok"; if (usedFraction >= 1) return "exhausted";
if (usedFraction >= 0.9) return "warning";
return "ok";
} }
function formatDate(value: Date): string { function formatDate(value: Date): string {
const pad = (input: number) => String(input).padStart(2, "0"); const pad = (input: number) => String(input).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}+${pad(value.getHours())}:${pad( return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}+${pad(value.getHours())}:${pad(
value.getMinutes(), value.getMinutes(),
)}:${pad(value.getSeconds())}`; )}:${pad(value.getSeconds())}`;
} }
function buildModelUsageUrl(baseUrl: string, now: Date): string { function buildModelUsageUrl(baseUrl: string, now: Date): string {
const start = new Date(now.getTime() - SEVEN_DAYS_MS); const start = new Date(now.getTime() - SEVEN_DAYS_MS);
const startTime = formatDate(start); const startTime = formatDate(start);
const endTime = formatDate(now); const endTime = formatDate(now);
return `${baseUrl}${MODEL_USAGE_PATH}?startTime=${encodeURIComponent(startTime)}&endTime=${encodeURIComponent(endTime)}`; return `${baseUrl}${MODEL_USAGE_PATH}?startTime=${encodeURIComponent(startTime)}&endTime=${encodeURIComponent(endTime)}`;
} }
async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> { async function fetchZaiUsage(
if (params.provider !== "zai") return null; params: UsageFetchParams,
const credential = params.credential; ctx: UsageFetchContext,
if (credential.type !== "api_key" || !credential.apiKey) return null; ): Promise<UsageReport | null> {
if (params.provider !== "zai") return null;
const credential = params.credential;
if (credential.type !== "api_key" || !credential.apiKey) return null;
const baseUrl = normalizeZaiBaseUrl(params.baseUrl); const baseUrl = normalizeZaiBaseUrl(params.baseUrl);
const url = `${baseUrl}${QUOTA_PATH}`; const url = `${baseUrl}${QUOTA_PATH}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: credential.apiKey, Authorization: credential.apiKey,
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "OpenCode-Status-Plugin/1.0", "User-Agent": "OpenCode-Status-Plugin/1.0",
}; };
let payload: ZaiQuotaPayload | null = null; let payload: ZaiQuotaPayload | null = null;
try { try {
const response = await ctx.fetch(url, { const response = await ctx.fetch(url, {
headers, headers,
signal: params.signal, signal: params.signal,
}); });
if (!response.ok) { if (!response.ok) {
ctx.logger?.warn("ZAI usage fetch failed", { status: response.status, statusText: response.statusText }); ctx.logger?.warn("ZAI usage fetch failed", {
return null; status: response.status,
} statusText: response.statusText,
payload = (await response.json()) as ZaiQuotaPayload; });
} catch (error) { return null;
ctx.logger?.warn("ZAI usage fetch error", { error: String(error) }); }
return null; payload = (await response.json()) as ZaiQuotaPayload;
} } catch (error) {
ctx.logger?.warn("ZAI usage fetch error", { error: String(error) });
return null;
}
if (!payload) return null; if (!payload) return null;
if (payload.success !== true) { if (payload.success !== true) {
ctx.logger?.warn("ZAI usage response invalid", { code: payload.code, message: payload.msg }); ctx.logger?.warn("ZAI usage response invalid", {
return null; code: payload.code,
} message: payload.msg,
});
return null;
}
const limitsPayload = Array.isArray(payload.data?.limits) ? payload.data?.limits : []; const limitsPayload = Array.isArray(payload.data?.limits)
const limits: UsageLimit[] = []; ? payload.data?.limits
: [];
const limits: UsageLimit[] = [];
for (const rawLimit of limitsPayload) { for (const rawLimit of limitsPayload) {
const parsed = parseLimitItem(rawLimit); const parsed = parseLimitItem(rawLimit);
if (!parsed) continue; if (!parsed) continue;
if (parsed.type === "TOKENS_LIMIT") { if (parsed.type === "TOKENS_LIMIT") {
const amount = buildUsageAmount({ const amount = buildUsageAmount({
used: parsed.currentValue, used: parsed.currentValue,
limit: parsed.usage, limit: parsed.usage,
remaining: parsed.remaining, remaining: parsed.remaining,
percentage: parsed.percentage, percentage: parsed.percentage,
unit: "tokens", unit: "tokens",
}); });
const window: UsageWindow = { const window: UsageWindow = {
id: "quota", id: "quota",
label: "Quota", label: "Quota",
durationMs: SEVEN_DAYS_MS, durationMs: SEVEN_DAYS_MS,
resetsAt: parsed.nextResetTime, resetsAt: parsed.nextResetTime,
}; };
limits.push({ limits.push({
id: "zai:tokens", id: "zai:tokens",
label: "ZAI Token Quota", label: "ZAI Token Quota",
scope: { scope: {
provider: params.provider, provider: params.provider,
windowId: window?.id ?? "quota", windowId: window?.id ?? "quota",
shared: true, shared: true,
}, },
window, window,
amount, amount,
status: getUsageStatus(amount.usedFraction), status: getUsageStatus(amount.usedFraction),
}); });
} }
if (parsed.type === "TIME_LIMIT") { if (parsed.type === "TIME_LIMIT") {
const window: UsageWindow = { const window: UsageWindow = {
id: "quota", id: "quota",
label: "Quota", label: "Quota",
durationMs: SEVEN_DAYS_MS, durationMs: SEVEN_DAYS_MS,
resetsAt: parsed.nextResetTime, resetsAt: parsed.nextResetTime,
}; };
const amount = buildUsageAmount({ const amount = buildUsageAmount({
used: parsed.currentValue, used: parsed.currentValue,
limit: parsed.usage, limit: parsed.usage,
remaining: parsed.remaining, remaining: parsed.remaining,
percentage: parsed.percentage, percentage: parsed.percentage,
unit: "requests", unit: "requests",
}); });
limits.push({ limits.push({
id: "zai:requests", id: "zai:requests",
label: "ZAI Request Quota", label: "ZAI Request Quota",
scope: { scope: {
provider: params.provider, provider: params.provider,
windowId: "quota", windowId: "quota",
shared: true, shared: true,
}, },
window, window,
amount, amount,
status: getUsageStatus(amount.usedFraction), status: getUsageStatus(amount.usedFraction),
}); });
} }
} }
if (limits.length === 0) return null; if (limits.length === 0) return null;
const report: UsageReport = { const report: UsageReport = {
provider: params.provider, provider: params.provider,
fetchedAt: Date.now(), fetchedAt: Date.now(),
limits, limits,
metadata: { metadata: {
endpoint: url, endpoint: url,
accountId: credential.accountId, accountId: credential.accountId,
email: credential.email, email: credential.email,
}, },
raw: payload, raw: payload,
}; };
const modelUsageUrl = buildModelUsageUrl(baseUrl, new Date()); const modelUsageUrl = buildModelUsageUrl(baseUrl, new Date());
try { try {
const response = await ctx.fetch(modelUsageUrl, { const response = await ctx.fetch(modelUsageUrl, {
headers, headers,
signal: params.signal, signal: params.signal,
}); });
if (response.ok) { if (response.ok) {
const modelUsagePayload = (await response.json()) as unknown; const modelUsagePayload = (await response.json()) as unknown;
if (isRecord(modelUsagePayload)) { if (isRecord(modelUsagePayload)) {
report.metadata = { report.metadata = {
...report.metadata, ...report.metadata,
modelUsage: modelUsagePayload, modelUsage: modelUsagePayload,
}; };
} }
} }
} catch (error) { } catch (error) {
ctx.logger?.debug("ZAI model usage fetch failed", { error: String(error) }); ctx.logger?.debug("ZAI model usage fetch failed", { error: String(error) });
} }
return report; return report;
} }
export const zaiUsageProvider: UsageProvider = { export const zaiUsageProvider: UsageProvider = {
id: "zai", id: "zai",
fetchUsage: fetchZaiUsage, fetchUsage: fetchZaiUsage,
supports: params => params.provider === "zai" && params.credential.type === "api_key", supports: (params) =>
params.provider === "zai" && params.credential.type === "api_key",
}; };