Compare commits
15 Commits
2031ab0506
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 461773c677 | |||
| cf7ad047d3 | |||
| 26ba42f2b9 | |||
| 9951a11b29 | |||
| f8fac5fd45 | |||
| 7906519eeb | |||
| 35f8276a16 | |||
| 9c88e0a003 | |||
| 576f31b13a | |||
| 419c78c357 | |||
| c8c5de590d | |||
| 4e36542b8b | |||
| b035ab3d0c | |||
| 615762b0ac | |||
| 4b06f45b2c |
17
AGENTS.md
17
AGENTS.md
@@ -6,19 +6,21 @@
|
||||
|
||||
## 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.
|
||||
- `types.ts` — Shared config/module/state types.
|
||||
- `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/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
|
||||
- `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
|
||||
|
||||
- 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:
|
||||
|
||||
```ts
|
||||
@@ -26,14 +28,15 @@
|
||||
{ type: "usage", window: "week", style: "text", parts: ["percent", "time"], separator: " | " }
|
||||
```
|
||||
|
||||
- Usage fallback behavior should be stable/no-pop-in:
|
||||
- unknown/loading line bars render like 0% usage
|
||||
- unknown/loading percent renders `0%`
|
||||
- unknown/loading time renders `∞`
|
||||
- Usage data is preserved during refresh (no clearing before fetch) to prevent pop-in:
|
||||
- Old data stays visible until new data arrives.
|
||||
- When no data exists yet: line bars render like 0% usage, percent renders `0%`, time renders `∞`.
|
||||
- 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.
|
||||
- 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`.
|
||||
- `rerender()` is debounced via `queueMicrotask` — multiple rapid calls coalesce into a single render pass.
|
||||
- All providers use the same refresh interval (`REFRESH_MS`).
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
141
index.ts
141
index.ts
@@ -1,7 +1,4 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AuthStorage, type AuthCredential, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext, type ReadonlyFooterDataProvider, type SessionStartEvent, type SessionShutdownEvent, type AgentEndEvent } from "@mariozechner/pi-coding-agent";
|
||||
import { statusbarConfig } from "./config";
|
||||
import { contextModule, costModule, directoryModule, modelModule, thinkingModule } from "./modules/basic";
|
||||
import { usageModule } from "./modules/usage";
|
||||
@@ -12,9 +9,7 @@ import { zaiUsageProvider } from "./usage/zai";
|
||||
import type { ModuleContext, ModuleSpec, RenderedModule, StatusbarState } from "./types";
|
||||
import type { Provider, UsageCredential, UsageProvider, UsageReport } from "./usage";
|
||||
|
||||
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
||||
const REFRESH_MS = 60_000;
|
||||
const ANTHROPIC_REFRESH_MS = 15 * 60_000;
|
||||
|
||||
const usageProviders: Record<Provider, UsageProvider> = {
|
||||
"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);
|
||||
}
|
||||
|
||||
function activeProvider(ctx: any): Provider | undefined {
|
||||
function activeProvider(ctx: ExtensionContext): Provider | undefined {
|
||||
const provider = ctx.model?.provider as string | undefined;
|
||||
if (provider === "openai-codex" || provider === "zai" || provider === "anthropic") return provider;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readPiCredential(provider: Provider): UsageCredential | undefined {
|
||||
try {
|
||||
const auth = JSON.parse(readFileSync(AUTH_PATH, "utf8"));
|
||||
if (!isRecord(auth) || !isRecord(auth[provider])) return undefined;
|
||||
function credentialString(raw: unknown, key: string): string | undefined {
|
||||
return isRecord(raw) && typeof raw[key] === "string" ? raw[key] : undefined;
|
||||
}
|
||||
|
||||
const raw = auth[provider];
|
||||
if (raw.type === "oauth") {
|
||||
return {
|
||||
type: "oauth",
|
||||
accessToken: typeof raw.access === "string" ? raw.access : undefined,
|
||||
refreshToken: typeof raw.refresh === "string" ? raw.refresh : undefined,
|
||||
expiresAt: typeof raw.expires === "number" ? raw.expires : undefined,
|
||||
accountId: typeof raw.accountId === "string" ? raw.accountId : undefined,
|
||||
email: typeof raw.email === "string" ? raw.email : undefined,
|
||||
metadata: raw,
|
||||
};
|
||||
}
|
||||
function buildUsageCredential(raw: AuthCredential | undefined, apiKey: string): UsageCredential {
|
||||
if (raw?.type === "oauth") {
|
||||
return {
|
||||
type: "oauth",
|
||||
accessToken: apiKey,
|
||||
refreshToken: raw.refresh,
|
||||
expiresAt: raw.expires,
|
||||
accountId: credentialString(raw, "accountId"),
|
||||
email: credentialString(raw, "email"),
|
||||
metadata: raw,
|
||||
};
|
||||
}
|
||||
|
||||
if (raw.type === "api_key") {
|
||||
return {
|
||||
type: "api_key",
|
||||
apiKey: typeof raw.key === "string" ? raw.key : undefined,
|
||||
accountId: typeof raw.accountId === "string" ? raw.accountId : undefined,
|
||||
email: typeof raw.email === "string" ? raw.email : undefined,
|
||||
metadata: raw,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
type: "api_key",
|
||||
apiKey,
|
||||
accountId: credentialString(raw, "accountId"),
|
||||
email: credentialString(raw, "email"),
|
||||
metadata: raw,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -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 };
|
||||
return statusbarConfig.rows.map(row => renderRow({
|
||||
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 inFlight: AbortController | undefined;
|
||||
let lastProvider: Provider | undefined;
|
||||
let latestCtx: any;
|
||||
let latestCtx: ExtensionContext | undefined;
|
||||
let requestRender: (() => void) | undefined;
|
||||
const statusbarState: StatusbarState = {};
|
||||
const authStorage = AuthStorage.create();
|
||||
|
||||
function updateThinkingLevel() {
|
||||
try {
|
||||
@@ -114,13 +123,22 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
function rerender(ctx: any) {
|
||||
let rerenderScheduled = false;
|
||||
function rerender(ctx: ExtensionContext) {
|
||||
latestCtx = ctx;
|
||||
requestRender?.();
|
||||
if (ctx.hasUI) ctx.ui.setStatus("pi-statusbar", undefined);
|
||||
if (rerenderScheduled) return;
|
||||
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;
|
||||
latestCtx = ctx;
|
||||
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;
|
||||
const provider = activeProvider(ctx);
|
||||
lastProvider = provider;
|
||||
@@ -148,13 +166,13 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||
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) {
|
||||
rerender(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = readPiCredential(provider);
|
||||
const credential = await readPiCredential(authStorage, provider);
|
||||
if (!credential) {
|
||||
statusbarState.report = { provider, fetchedAt: Date.now(), limits: [] };
|
||||
statusbarState.error = "not logged in";
|
||||
@@ -163,25 +181,34 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
inFlight?.abort();
|
||||
inFlight = new AbortController();
|
||||
statusbarState.report = undefined;
|
||||
statusbarState.error = undefined;
|
||||
rerender(ctx);
|
||||
const controller = new AbortController();
|
||||
inFlight = controller;
|
||||
|
||||
try {
|
||||
const report = await usageProviders[provider].fetchUsage({
|
||||
provider,
|
||||
credential,
|
||||
baseUrl: ctx.model?.baseUrl,
|
||||
signal: inFlight.signal,
|
||||
}, {
|
||||
let activeCredential = credential;
|
||||
const usageCtx = {
|
||||
fetch: globalThis.fetch.bind(globalThis),
|
||||
logger: {
|
||||
debug: () => 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.error = report ? undefined : "unavailable";
|
||||
} catch (error) {
|
||||
@@ -192,7 +219,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||
if (lastProvider === provider) rerender(ctx);
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
||||
updateThinkingLevel();
|
||||
installFooter(ctx);
|
||||
await refresh(ctx, true);
|
||||
@@ -201,7 +228,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||
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);
|
||||
timer = undefined;
|
||||
inFlight?.abort();
|
||||
@@ -223,14 +250,14 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||
rerender(ctx);
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
pi.on("agent_end", async (_event: AgentEndEvent, ctx: ExtensionContext) => {
|
||||
updateThinkingLevel();
|
||||
await refresh(ctx);
|
||||
});
|
||||
|
||||
pi.registerCommand("refresh-usage", {
|
||||
description: "Refresh account usage for the active provider",
|
||||
handler: async (_args, ctx) => {
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
await refresh(ctx, true);
|
||||
ctx.ui.notify(usageSummary(statusbarState.report, statusbarState.error), statusbarState.error ? "warning" : "info");
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ function duration(valueMs: number | undefined): string | undefined {
|
||||
const ms = Math.max(0, valueMs);
|
||||
const days = Math.floor(ms / 86_400_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 (hours > 0) return `${hours}h${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
@@ -60,8 +60,18 @@ function usageColor(fraction: number): { r: number; g: number; b: number } {
|
||||
|
||||
function findWindowLimits(report: UsageReport): UsageWindows {
|
||||
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 week = report.limits.find(limit => ["7d", "secondary"].includes(windowId(limit))) ?? report.limits.find(limit => limit !== current);
|
||||
const limitId = (limit: UsageLimit) => (limit.id ?? "").toLowerCase();
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
|
||||
33
render.ts
33
render.ts
@@ -2,6 +2,7 @@ import type { ModuleSpec, RenderedModule } from "./types";
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = new RegExp("\\u001b\\[[0-9;]*m", "g");
|
||||
const ANSI_PREFIX_RE = /^\u001b\[[0-9;]*m/;
|
||||
|
||||
export function visibleWidth(text: string): number {
|
||||
return text.replace(ANSI_RE, "").length;
|
||||
@@ -16,8 +17,7 @@ export function truncate(text: string, width: number): string {
|
||||
let count = 0;
|
||||
for (let index = 0; index < text.length && count < width; index++) {
|
||||
if (text[index] === "\x1b") {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const match = text.slice(index).match(new RegExp("^\\u001b\\[[0-9;]*m"));
|
||||
const match = text.slice(index).match(ANSI_PREFIX_RE);
|
||||
if (match) {
|
||||
output += match[0];
|
||||
index += match[0].length - 1;
|
||||
@@ -34,10 +34,33 @@ export function joinText(parts: string[], separator: string): string {
|
||||
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 {
|
||||
if (visibleWidth(text) >= visibleWidth(base)) return truncate(text, visibleWidth(base));
|
||||
const start = Math.max(0, Math.floor((visibleWidth(base) - visibleWidth(text)) / 2));
|
||||
return `${base.slice(0, start)}${text}${base.slice(start + visibleWidth(text))}`;
|
||||
const baseWidth = visibleWidth(base);
|
||||
const textWidth = 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 {
|
||||
|
||||
5
types.ts
5
types.ts
@@ -1,3 +1,4 @@
|
||||
import type { ExtensionContext, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
|
||||
import type { UsageLimit, UsageReport } from "./usage";
|
||||
|
||||
export type ModuleName = "directory" | "context" | "model" | "thinking" | "cost" | "usage";
|
||||
@@ -40,8 +41,8 @@ export interface StatusbarState {
|
||||
}
|
||||
|
||||
export interface ModuleContext {
|
||||
ctx: any;
|
||||
footerData: any;
|
||||
ctx: ExtensionContext;
|
||||
footerData: ReadonlyFooterDataProvider;
|
||||
state: StatusbarState;
|
||||
config: StatusbarConfig;
|
||||
}
|
||||
|
||||
550
usage/claude.ts
550
usage/claude.ts
@@ -1,13 +1,13 @@
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageStatus,
|
||||
UsageWindow,
|
||||
CredentialRankingStrategy,
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageStatus,
|
||||
UsageWindow,
|
||||
} from "../usage";
|
||||
import { isRecord, toNumber } from "../utils";
|
||||
|
||||
@@ -18,320 +18,358 @@ const MAX_RETRIES = 3;
|
||||
const BASE_RETRY_DELAY_MS = 500;
|
||||
|
||||
const CLAUDE_HEADERS = {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-encoding": "gzip, compress, deflate, br",
|
||||
"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",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.63 (external, cli)",
|
||||
connection: "keep-alive",
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-encoding": "gzip, compress, deflate, br",
|
||||
"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",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.63 (external, cli)",
|
||||
connection: "keep-alive",
|
||||
} as const;
|
||||
|
||||
function normalizeClaudeBaseUrl(baseUrl?: string): string {
|
||||
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.endsWith("/api/oauth")) return trimmed;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return DEFAULT_ENDPOINT;
|
||||
}
|
||||
let path = url.pathname.replace(/\/+$/, "");
|
||||
if (path === "/") path = "";
|
||||
if (path.toLowerCase().endsWith("/v1")) {
|
||||
path = path.slice(0, -3);
|
||||
}
|
||||
if (!path) return `${url.origin}/api/oauth`;
|
||||
return `${url.origin}${path}/api/oauth`;
|
||||
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.endsWith("/api/oauth")) return trimmed;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return DEFAULT_ENDPOINT;
|
||||
}
|
||||
let path = url.pathname.replace(/\/+$/, "");
|
||||
if (path === "/") path = "";
|
||||
if (path.toLowerCase().endsWith("/v1")) {
|
||||
path = path.slice(0, -3);
|
||||
}
|
||||
if (!path) return `${url.origin}/api/oauth`;
|
||||
return `${url.origin}${path}/api/oauth`;
|
||||
}
|
||||
|
||||
interface ClaudeUsageBucket {
|
||||
utilization?: number;
|
||||
resets_at?: string;
|
||||
utilization?: number;
|
||||
resets_at?: string;
|
||||
}
|
||||
|
||||
interface ParsedUsageBucket {
|
||||
utilization?: number;
|
||||
resetsAt?: number;
|
||||
utilization?: number;
|
||||
resetsAt?: number;
|
||||
}
|
||||
|
||||
interface ClaudeUsageResponse {
|
||||
five_hour?: ClaudeUsageBucket | null;
|
||||
seven_day?: ClaudeUsageBucket | null;
|
||||
seven_day_opus?: ClaudeUsageBucket | null;
|
||||
seven_day_sonnet?: ClaudeUsageBucket | null;
|
||||
five_hour?: ClaudeUsageBucket | null;
|
||||
seven_day?: ClaudeUsageBucket | null;
|
||||
seven_day_opus?: ClaudeUsageBucket | null;
|
||||
seven_day_sonnet?: ClaudeUsageBucket | null;
|
||||
}
|
||||
|
||||
type ClaudeUsagePayload = {
|
||||
payload: ClaudeUsageResponse;
|
||||
orgId?: string;
|
||||
payload: ClaudeUsageResponse;
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
function parseIsoTime(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
if (!value) return undefined;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
|
||||
if (!isRecord(bucket)) return undefined;
|
||||
const utilization = toNumber(bucket.utilization);
|
||||
const resetsAt = parseIsoTime(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined);
|
||||
if (utilization === undefined && resetsAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { utilization, resetsAt };
|
||||
if (!isRecord(bucket)) return undefined;
|
||||
const utilization = toNumber(bucket.utilization);
|
||||
const resetsAt = parseIsoTime(
|
||||
typeof bucket.resets_at === "string" ? bucket.resets_at : undefined,
|
||||
);
|
||||
if (utilization === undefined && resetsAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { utilization, resetsAt };
|
||||
}
|
||||
|
||||
function getPayloadString(payload: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
function getPayloadString(
|
||||
payload: Record<string, unknown>,
|
||||
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 } {
|
||||
if (!isRecord(payload)) return { accountId: orgId };
|
||||
const accountId =
|
||||
getPayloadString(payload, "account_id") ??
|
||||
getPayloadString(payload, "accountId") ??
|
||||
getPayloadString(payload, "user_id") ??
|
||||
getPayloadString(payload, "userId") ??
|
||||
getPayloadString(payload, "org_id") ??
|
||||
getPayloadString(payload, "orgId") ??
|
||||
orgId;
|
||||
const email =
|
||||
getPayloadString(payload, "email") ??
|
||||
getPayloadString(payload, "user_email") ??
|
||||
getPayloadString(payload, "userEmail");
|
||||
return { accountId, email };
|
||||
function extractUsageIdentity(
|
||||
payload: ClaudeUsageResponse,
|
||||
orgId?: string,
|
||||
): { accountId?: string; email?: string } {
|
||||
if (!isRecord(payload)) return { accountId: orgId };
|
||||
const accountId =
|
||||
getPayloadString(payload, "account_id") ??
|
||||
getPayloadString(payload, "accountId") ??
|
||||
getPayloadString(payload, "user_id") ??
|
||||
getPayloadString(payload, "userId") ??
|
||||
getPayloadString(payload, "org_id") ??
|
||||
getPayloadString(payload, "orgId") ??
|
||||
orgId;
|
||||
const email =
|
||||
getPayloadString(payload, "email") ??
|
||||
getPayloadString(payload, "user_email") ??
|
||||
getPayloadString(payload, "userEmail");
|
||||
return { accountId, email };
|
||||
}
|
||||
|
||||
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(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
ctx: UsageFetchContext,
|
||||
signal?: AbortSignal,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
ctx: UsageFetchContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClaudeUsagePayload | null> {
|
||||
let lastPayload: ClaudeUsageResponse | null = null;
|
||||
let lastOrgId: string | undefined;
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await ctx.fetch(url, { headers, signal });
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("Claude usage fetch failed", { status: response.status, statusText: response.statusText });
|
||||
return null;
|
||||
}
|
||||
const payload = (await response.json()) as ClaudeUsageResponse;
|
||||
lastPayload = payload;
|
||||
const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
|
||||
lastOrgId = orgId ?? lastOrgId;
|
||||
if (payload && isRecord(payload) && hasUsageData(payload)) {
|
||||
return { payload, orgId };
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
let lastPayload: ClaudeUsageResponse | null = null;
|
||||
let lastOrgId: string | undefined;
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await ctx.fetch(url, { headers, signal });
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("Claude usage fetch failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const payload = (await response.json()) as ClaudeUsageResponse;
|
||||
lastPayload = payload;
|
||||
const orgId =
|
||||
response.headers.get("anthropic-organization-id")?.trim() || undefined;
|
||||
lastOrgId = orgId ?? lastOrgId;
|
||||
if (payload && isRecord(payload) && hasUsageData(payload)) {
|
||||
return { payload, orgId };
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, BASE_RETRY_DELAY_MS * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
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 {
|
||||
account?: {
|
||||
uuid?: string;
|
||||
email?: string;
|
||||
};
|
||||
account?: {
|
||||
uuid?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProfile(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
ctx: UsageFetchContext,
|
||||
signal?: AbortSignal,
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
ctx: UsageFetchContext,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClaudeProfile | null> {
|
||||
const url = `${baseUrl}/profile`;
|
||||
try {
|
||||
const response = await ctx.fetch(url, { headers, signal });
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as ClaudeProfile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const url = `${baseUrl}/profile`;
|
||||
try {
|
||||
const response = await ctx.fetch(url, { headers, signal });
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as ClaudeProfile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveEmail(
|
||||
params: UsageFetchParams,
|
||||
ctx: UsageFetchContext,
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
params: UsageFetchParams,
|
||||
ctx: UsageFetchContext,
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
): 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);
|
||||
return profile?.account?.email;
|
||||
const profile = await fetchProfile(baseUrl, headers, ctx, params.signal);
|
||||
return profile?.account?.email;
|
||||
}
|
||||
|
||||
function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined {
|
||||
if (utilization === undefined) return undefined;
|
||||
const clamped = Math.min(Math.max(utilization, 0), 100);
|
||||
const usedFraction = clamped / 100;
|
||||
return {
|
||||
used: clamped,
|
||||
limit: 100,
|
||||
remaining: Math.max(0, 100 - clamped),
|
||||
usedFraction,
|
||||
remainingFraction: Math.max(0, 1 - usedFraction),
|
||||
unit: "percent",
|
||||
};
|
||||
function buildUsageAmount(
|
||||
utilization: number | undefined,
|
||||
): UsageAmount | undefined {
|
||||
if (utilization === undefined) return undefined;
|
||||
const clamped = Math.min(Math.max(utilization, 0), 100);
|
||||
const usedFraction = clamped / 100;
|
||||
return {
|
||||
used: clamped,
|
||||
limit: 100,
|
||||
remaining: Math.max(0, 100 - clamped),
|
||||
usedFraction,
|
||||
remainingFraction: Math.max(0, 1 - usedFraction),
|
||||
unit: "percent",
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
|
||||
if (usedFraction === undefined) return undefined;
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
function buildUsageStatus(
|
||||
usedFraction: number | undefined,
|
||||
): UsageStatus | undefined {
|
||||
if (usedFraction === undefined) return undefined;
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function buildUsageLimit(args: {
|
||||
id: string;
|
||||
label: string;
|
||||
windowId: string;
|
||||
windowLabel: string;
|
||||
durationMs: number;
|
||||
bucket: ParsedUsageBucket | undefined;
|
||||
provider: "anthropic";
|
||||
tier?: string;
|
||||
shared?: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
windowId: string;
|
||||
windowLabel: string;
|
||||
durationMs: number;
|
||||
bucket: ParsedUsageBucket | undefined;
|
||||
provider: "anthropic";
|
||||
tier?: string;
|
||||
shared?: boolean;
|
||||
}): UsageLimit | null {
|
||||
if (!args.bucket) return null;
|
||||
const amount = buildUsageAmount(args.bucket.utilization);
|
||||
if (!amount) return null;
|
||||
const window: UsageWindow = {
|
||||
id: args.windowId,
|
||||
label: args.windowLabel,
|
||||
durationMs: args.durationMs,
|
||||
...(args.bucket.resetsAt !== undefined ? { resetsAt: args.bucket.resetsAt } : {}),
|
||||
};
|
||||
return {
|
||||
id: args.id,
|
||||
label: args.label,
|
||||
scope: {
|
||||
provider: args.provider,
|
||||
windowId: args.windowId,
|
||||
tier: args.tier,
|
||||
shared: args.shared,
|
||||
},
|
||||
window,
|
||||
amount,
|
||||
status: buildUsageStatus(amount.usedFraction),
|
||||
};
|
||||
if (!args.bucket) return null;
|
||||
const amount = buildUsageAmount(args.bucket.utilization);
|
||||
if (!amount) return null;
|
||||
const window: UsageWindow = {
|
||||
id: args.windowId,
|
||||
label: args.windowLabel,
|
||||
durationMs: args.durationMs,
|
||||
...(args.bucket.resetsAt !== undefined
|
||||
? { resetsAt: args.bucket.resetsAt }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
id: args.id,
|
||||
label: args.label,
|
||||
scope: {
|
||||
provider: args.provider,
|
||||
windowId: args.windowId,
|
||||
tier: args.tier,
|
||||
shared: args.shared,
|
||||
},
|
||||
window,
|
||||
amount,
|
||||
status: buildUsageStatus(amount.usedFraction),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
||||
if (params.provider !== "anthropic") return null;
|
||||
const credential = params.credential;
|
||||
if (credential.type !== "oauth" || !credential.accessToken) return null;
|
||||
async function fetchClaudeUsage(
|
||||
params: UsageFetchParams,
|
||||
ctx: UsageFetchContext,
|
||||
): 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 url = `${baseUrl}/usage`;
|
||||
const headers: Record<string, string> = {
|
||||
...CLAUDE_HEADERS,
|
||||
authorization: `Bearer ${credential.accessToken}`,
|
||||
};
|
||||
const baseUrl = normalizeClaudeBaseUrl(params.baseUrl);
|
||||
const url = `${baseUrl}/usage`;
|
||||
const headers: Record<string, string> = {
|
||||
...CLAUDE_HEADERS,
|
||||
authorization: `Bearer ${credential.accessToken}`,
|
||||
};
|
||||
|
||||
const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal);
|
||||
if (!payloadResult || !isRecord(payloadResult.payload)) return null;
|
||||
const { payload, orgId } = payloadResult;
|
||||
const payloadResult = await fetchUsagePayload(
|
||||
url,
|
||||
headers,
|
||||
ctx,
|
||||
params.signal,
|
||||
);
|
||||
if (!payloadResult || !isRecord(payloadResult.payload)) return null;
|
||||
const { payload, orgId } = payloadResult;
|
||||
|
||||
const fiveHour = parseBucket(payload.five_hour);
|
||||
const sevenDay = parseBucket(payload.seven_day);
|
||||
const sevenDayOpus = parseBucket(payload.seven_day_opus);
|
||||
const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
|
||||
const fiveHour = parseBucket(payload.five_hour);
|
||||
const sevenDay = parseBucket(payload.seven_day);
|
||||
const sevenDayOpus = parseBucket(payload.seven_day_opus);
|
||||
const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
|
||||
|
||||
const limits = [
|
||||
buildUsageLimit({
|
||||
id: "anthropic:5h",
|
||||
label: "Claude 5 Hour",
|
||||
windowId: "5h",
|
||||
windowLabel: "5 Hour",
|
||||
durationMs: FIVE_HOURS_MS,
|
||||
bucket: fiveHour,
|
||||
provider: "anthropic",
|
||||
shared: true,
|
||||
}),
|
||||
buildUsageLimit({
|
||||
id: "anthropic:7d",
|
||||
label: "Claude 7 Day",
|
||||
windowId: "7d",
|
||||
windowLabel: "7 Day",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
bucket: sevenDay,
|
||||
provider: "anthropic",
|
||||
shared: true,
|
||||
}),
|
||||
buildUsageLimit({
|
||||
id: "anthropic:7d:opus",
|
||||
label: "Claude 7 Day (Opus)",
|
||||
windowId: "7d",
|
||||
windowLabel: "7 Day",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
bucket: sevenDayOpus,
|
||||
provider: "anthropic",
|
||||
tier: "opus",
|
||||
}),
|
||||
buildUsageLimit({
|
||||
id: "anthropic:7d:sonnet",
|
||||
label: "Claude 7 Day (Sonnet)",
|
||||
windowId: "7d",
|
||||
windowLabel: "7 Day",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
bucket: sevenDaySonnet,
|
||||
provider: "anthropic",
|
||||
tier: "sonnet",
|
||||
}),
|
||||
].filter((limit): limit is UsageLimit => limit !== null);
|
||||
const limits = [
|
||||
buildUsageLimit({
|
||||
id: "anthropic:5h",
|
||||
label: "Claude 5 Hour",
|
||||
windowId: "5h",
|
||||
windowLabel: "5 Hour",
|
||||
durationMs: FIVE_HOURS_MS,
|
||||
bucket: fiveHour,
|
||||
provider: "anthropic",
|
||||
shared: true,
|
||||
}),
|
||||
buildUsageLimit({
|
||||
id: "anthropic:7d",
|
||||
label: "Claude 7 Day",
|
||||
windowId: "7d",
|
||||
windowLabel: "7 Day",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
bucket: sevenDay,
|
||||
provider: "anthropic",
|
||||
shared: true,
|
||||
}),
|
||||
buildUsageLimit({
|
||||
id: "anthropic:7d:opus",
|
||||
label: "Claude 7 Day (Opus)",
|
||||
windowId: "7d",
|
||||
windowLabel: "7 Day",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
bucket: sevenDayOpus,
|
||||
provider: "anthropic",
|
||||
tier: "opus",
|
||||
}),
|
||||
buildUsageLimit({
|
||||
id: "anthropic:7d:sonnet",
|
||||
label: "Claude 7 Day (Sonnet)",
|
||||
windowId: "7d",
|
||||
windowLabel: "7 Day",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
bucket: sevenDaySonnet,
|
||||
provider: "anthropic",
|
||||
tier: "sonnet",
|
||||
}),
|
||||
].filter((limit): limit is UsageLimit => limit !== null);
|
||||
|
||||
if (limits.length === 0) return null;
|
||||
const identity = extractUsageIdentity(payload, orgId);
|
||||
const accountId = identity.accountId ?? credential.accountId;
|
||||
const email = identity.email ?? (await resolveEmail(params, ctx, baseUrl, headers));
|
||||
if (limits.length === 0) return null;
|
||||
const identity = extractUsageIdentity(payload, orgId);
|
||||
const accountId = identity.accountId ?? credential.accountId;
|
||||
const email =
|
||||
identity.email ?? (await resolveEmail(params, ctx, baseUrl, headers));
|
||||
|
||||
const report: UsageReport = {
|
||||
provider: params.provider,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: {
|
||||
accountId,
|
||||
email,
|
||||
endpoint: url,
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
const report: UsageReport = {
|
||||
provider: params.provider,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: {
|
||||
accountId,
|
||||
email,
|
||||
endpoint: url,
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
|
||||
return report;
|
||||
return report;
|
||||
}
|
||||
|
||||
export const claudeUsageProvider: UsageProvider = {
|
||||
id: "anthropic",
|
||||
fetchUsage: fetchClaudeUsage,
|
||||
supports: params => params.provider === "anthropic" && params.credential.type === "oauth",
|
||||
id: "anthropic",
|
||||
fetchUsage: fetchClaudeUsage,
|
||||
supports: (params) =>
|
||||
params.provider === "anthropic" && params.credential.type === "oauth",
|
||||
};
|
||||
|
||||
export const claudeRankingStrategy: CredentialRankingStrategy = {
|
||||
findWindowLimits(report) {
|
||||
const primary = report.limits.find(l => l.id === "anthropic:5h");
|
||||
const secondary = report.limits.find(l => l.id === "anthropic:7d");
|
||||
return { primary, secondary };
|
||||
},
|
||||
windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
|
||||
findWindowLimits(report) {
|
||||
const primary = report.limits.find((l) => l.id === "anthropic:5h");
|
||||
const secondary = report.limits.find((l) => l.id === "anthropic:7d");
|
||||
return { primary, secondary };
|
||||
},
|
||||
windowDefaults: {
|
||||
primaryMs: 5 * 60 * 60 * 1000,
|
||||
secondaryMs: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,377 +1,431 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { CODEX_BASE_URL } from "../providers/openai-codex/constants";
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageWindow,
|
||||
CredentialRankingStrategy,
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageWindow,
|
||||
} from "../usage";
|
||||
import { isRecord } from "../utils";
|
||||
import { toNumber } from "./shared";
|
||||
import { isRecord, toNumber } from "../utils";
|
||||
|
||||
const CODEX_USAGE_PATH = "wham/usage";
|
||||
const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
|
||||
const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
|
||||
|
||||
interface CodexUsageWindowPayload {
|
||||
used_percent?: number;
|
||||
limit_window_seconds?: number;
|
||||
reset_after_seconds?: number;
|
||||
reset_at?: number;
|
||||
used_percent?: number;
|
||||
limit_window_seconds?: number;
|
||||
reset_after_seconds?: number;
|
||||
reset_at?: number;
|
||||
}
|
||||
|
||||
interface CodexUsageRateLimitPayload {
|
||||
allowed?: boolean;
|
||||
limit_reached?: boolean;
|
||||
primary_window?: CodexUsageWindowPayload | null;
|
||||
secondary_window?: CodexUsageWindowPayload | null;
|
||||
allowed?: boolean;
|
||||
limit_reached?: boolean;
|
||||
primary_window?: CodexUsageWindowPayload | null;
|
||||
secondary_window?: CodexUsageWindowPayload | null;
|
||||
}
|
||||
|
||||
interface CodexUsagePayload {
|
||||
plan_type?: string;
|
||||
rate_limit?: CodexUsageRateLimitPayload | null;
|
||||
plan_type?: string;
|
||||
rate_limit?: CodexUsageRateLimitPayload | null;
|
||||
}
|
||||
|
||||
interface ParsedUsageWindow {
|
||||
usedPercent?: number;
|
||||
limitWindowSeconds?: number;
|
||||
resetAfterSeconds?: number;
|
||||
resetAt?: number;
|
||||
usedPercent?: number;
|
||||
limitWindowSeconds?: number;
|
||||
resetAfterSeconds?: number;
|
||||
resetAt?: number;
|
||||
}
|
||||
|
||||
interface ParsedUsage {
|
||||
planType?: string;
|
||||
allowed?: boolean;
|
||||
limitReached?: boolean;
|
||||
primary?: ParsedUsageWindow;
|
||||
secondary?: ParsedUsageWindow;
|
||||
raw: CodexUsagePayload;
|
||||
planType?: string;
|
||||
allowed?: boolean;
|
||||
limitReached?: boolean;
|
||||
primary?: ParsedUsageWindow;
|
||||
secondary?: ParsedUsageWindow;
|
||||
raw: CodexUsagePayload;
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
[JWT_AUTH_CLAIM]?: {
|
||||
chatgpt_account_id?: string;
|
||||
};
|
||||
[JWT_PROFILE_CLAIM]?: {
|
||||
email?: string;
|
||||
};
|
||||
[JWT_AUTH_CLAIM]?: {
|
||||
chatgpt_account_id?: string;
|
||||
};
|
||||
[JWT_PROFILE_CLAIM]?: {
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const toBoolean = (value: unknown): boolean | undefined => {
|
||||
if (typeof value === "boolean") return value;
|
||||
return undefined;
|
||||
if (typeof value === "boolean") return value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function base64UrlDecode(input: string): string {
|
||||
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (base64.length % 4)) % 4;
|
||||
const padded = base64 + "=".repeat(padLen);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (base64.length % 4)) % 4;
|
||||
const padded = base64 + "=".repeat(padLen);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
function parseJwt(token: string): JwtPayload | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const payloadJson = base64UrlDecode(parts[1]);
|
||||
return JSON.parse(payloadJson) as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const payloadJson = base64UrlDecode(parts[1]);
|
||||
return JSON.parse(payloadJson) as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string | undefined): string | undefined {
|
||||
if (!email) return undefined;
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
if (!email) return undefined;
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function extractAccountId(token: string | undefined): string | undefined {
|
||||
if (!token) return undefined;
|
||||
const payload = parseJwt(token);
|
||||
return payload?.[JWT_AUTH_CLAIM]?.chatgpt_account_id ?? undefined;
|
||||
if (!token) return undefined;
|
||||
const payload = parseJwt(token);
|
||||
return payload?.[JWT_AUTH_CLAIM]?.chatgpt_account_id ?? undefined;
|
||||
}
|
||||
|
||||
function extractEmail(token: string | undefined): string | undefined {
|
||||
if (!token) return undefined;
|
||||
const payload = parseJwt(token);
|
||||
return normalizeEmail(payload?.[JWT_PROFILE_CLAIM]?.email);
|
||||
if (!token) return undefined;
|
||||
const payload = parseJwt(token);
|
||||
return normalizeEmail(payload?.[JWT_PROFILE_CLAIM]?.email);
|
||||
}
|
||||
|
||||
function parseUsageWindow(payload: unknown): ParsedUsageWindow | undefined {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const usedPercent = toNumber(payload.used_percent);
|
||||
const limitWindowSeconds = toNumber(payload.limit_window_seconds);
|
||||
const resetAfterSeconds = toNumber(payload.reset_after_seconds);
|
||||
const resetAt = toNumber(payload.reset_at);
|
||||
if (
|
||||
usedPercent === undefined &&
|
||||
limitWindowSeconds === undefined &&
|
||||
resetAfterSeconds === undefined &&
|
||||
resetAt === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
usedPercent,
|
||||
limitWindowSeconds,
|
||||
resetAfterSeconds,
|
||||
resetAt,
|
||||
};
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const usedPercent = toNumber(payload.used_percent);
|
||||
const limitWindowSeconds = toNumber(payload.limit_window_seconds);
|
||||
const resetAfterSeconds = toNumber(payload.reset_after_seconds);
|
||||
const resetAt = toNumber(payload.reset_at);
|
||||
if (
|
||||
usedPercent === undefined &&
|
||||
limitWindowSeconds === undefined &&
|
||||
resetAfterSeconds === undefined &&
|
||||
resetAt === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
usedPercent,
|
||||
limitWindowSeconds,
|
||||
resetAfterSeconds,
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUsagePayload(payload: unknown): ParsedUsage | null {
|
||||
if (!isRecord(payload)) return null;
|
||||
const planType = typeof payload.plan_type === "string" ? payload.plan_type : undefined;
|
||||
const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined;
|
||||
if (!rateLimit) return null;
|
||||
const parsed: ParsedUsage = {
|
||||
planType,
|
||||
allowed: toBoolean(rateLimit.allowed),
|
||||
limitReached: toBoolean(rateLimit.limit_reached),
|
||||
primary: parseUsageWindow(rateLimit.primary_window),
|
||||
secondary: parseUsageWindow(rateLimit.secondary_window),
|
||||
raw: payload as CodexUsagePayload,
|
||||
};
|
||||
if (!parsed.primary && !parsed.secondary && parsed.allowed === undefined && parsed.limitReached === undefined) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
if (!isRecord(payload)) return null;
|
||||
const planType =
|
||||
typeof payload.plan_type === "string" ? payload.plan_type : undefined;
|
||||
const rateLimit = isRecord(payload.rate_limit)
|
||||
? payload.rate_limit
|
||||
: undefined;
|
||||
if (!rateLimit) return null;
|
||||
const parsed: ParsedUsage = {
|
||||
planType,
|
||||
allowed: toBoolean(rateLimit.allowed),
|
||||
limitReached: toBoolean(rateLimit.limit_reached),
|
||||
primary: parseUsageWindow(rateLimit.primary_window),
|
||||
secondary: parseUsageWindow(rateLimit.secondary_window),
|
||||
raw: payload as CodexUsagePayload,
|
||||
};
|
||||
if (
|
||||
!parsed.primary &&
|
||||
!parsed.secondary &&
|
||||
parsed.allowed === undefined &&
|
||||
parsed.limitReached === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeCodexBaseUrl(baseUrl?: string): string {
|
||||
const fallback = CODEX_BASE_URL;
|
||||
const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
|
||||
const base = trimmed.replace(/\/+$/, "");
|
||||
const lower = base.toLowerCase();
|
||||
if (
|
||||
(lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) &&
|
||||
!lower.includes("/backend-api")
|
||||
) {
|
||||
return `${base}/backend-api`;
|
||||
}
|
||||
return base;
|
||||
const fallback = CODEX_BASE_URL;
|
||||
const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
|
||||
const base = trimmed.replace(/\/+$/, "");
|
||||
const lower = base.toLowerCase();
|
||||
if (
|
||||
(lower.startsWith("https://chatgpt.com") ||
|
||||
lower.startsWith("https://chat.openai.com")) &&
|
||||
!lower.includes("/backend-api")
|
||||
) {
|
||||
return `${base}/backend-api`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildCodexUsageUrl(baseUrl: string): string {
|
||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return `${normalized}${CODEX_USAGE_PATH}`;
|
||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return `${normalized}${CODEX_USAGE_PATH}`;
|
||||
}
|
||||
|
||||
function formatWindowLabel(value: number, unit: "hour" | "day"): string {
|
||||
const rounded = Math.round(value);
|
||||
const suffix = rounded === 1 ? unit : `${unit}s`;
|
||||
return `${rounded} ${suffix}`;
|
||||
const rounded = Math.round(value);
|
||||
const suffix = rounded === 1 ? unit : `${unit}s`;
|
||||
return `${rounded} ${suffix}`;
|
||||
}
|
||||
|
||||
function buildWindowLabel(seconds: number): { id: string; label: string } {
|
||||
const daySeconds = 86_400;
|
||||
if (seconds >= daySeconds) {
|
||||
const days = Math.round(seconds / daySeconds);
|
||||
return { id: `${days}d`, label: formatWindowLabel(days, "day") };
|
||||
}
|
||||
const hours = Math.max(1, Math.round(seconds / 3600));
|
||||
return { id: `${hours}h`, label: formatWindowLabel(hours, "hour") };
|
||||
const daySeconds = 86_400;
|
||||
if (seconds >= daySeconds) {
|
||||
const days = Math.round(seconds / daySeconds);
|
||||
return { id: `${days}d`, label: formatWindowLabel(days, "day") };
|
||||
}
|
||||
const hours = Math.max(1, Math.round(seconds / 3600));
|
||||
return { id: `${hours}h`, label: formatWindowLabel(hours, "hour") };
|
||||
}
|
||||
|
||||
function resolveResetTime(window: ParsedUsageWindow, nowMs: number): number | undefined {
|
||||
const resetAt = window.resetAt;
|
||||
if (resetAt !== undefined) {
|
||||
const resetAtMs = resetAt > 1_000_000_000_000 ? resetAt : resetAt * 1000;
|
||||
if (Number.isFinite(resetAtMs)) return resetAtMs;
|
||||
}
|
||||
if (window.resetAfterSeconds !== undefined) {
|
||||
return nowMs + window.resetAfterSeconds * 1000;
|
||||
}
|
||||
return undefined;
|
||||
function resolveResetTime(
|
||||
window: ParsedUsageWindow,
|
||||
nowMs: number,
|
||||
): number | undefined {
|
||||
const resetAt = window.resetAt;
|
||||
if (resetAt !== undefined) {
|
||||
const resetAtMs = resetAt > 1_000_000_000_000 ? resetAt : resetAt * 1000;
|
||||
if (Number.isFinite(resetAtMs)) return resetAtMs;
|
||||
}
|
||||
if (window.resetAfterSeconds !== undefined) {
|
||||
return nowMs + window.resetAfterSeconds * 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildUsageWindow(window: ParsedUsageWindow, key: string, nowMs: number): UsageWindow {
|
||||
const resetsAt = resolveResetTime(window, nowMs);
|
||||
if (window.limitWindowSeconds !== undefined) {
|
||||
const { id, label } = buildWindowLabel(window.limitWindowSeconds);
|
||||
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 buildUsageWindow(
|
||||
window: ParsedUsageWindow,
|
||||
key: string,
|
||||
nowMs: number,
|
||||
): UsageWindow {
|
||||
const resetsAt = resolveResetTime(window, nowMs);
|
||||
if (window.limitWindowSeconds !== undefined) {
|
||||
const { id, label } = buildWindowLabel(window.limitWindowSeconds);
|
||||
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 {
|
||||
const usedPercent = window.usedPercent;
|
||||
if (usedPercent === undefined) {
|
||||
return { unit: "percent" };
|
||||
}
|
||||
const clamped = Math.min(Math.max(usedPercent, 0), 100);
|
||||
const usedFraction = clamped / 100;
|
||||
return {
|
||||
used: clamped,
|
||||
limit: 100,
|
||||
remaining: Math.max(0, 100 - clamped),
|
||||
usedFraction,
|
||||
remainingFraction: Math.max(0, 1 - usedFraction),
|
||||
unit: "percent",
|
||||
};
|
||||
const usedPercent = window.usedPercent;
|
||||
if (usedPercent === undefined) {
|
||||
return { unit: "percent" };
|
||||
}
|
||||
const clamped = Math.min(Math.max(usedPercent, 0), 100);
|
||||
const usedFraction = clamped / 100;
|
||||
return {
|
||||
used: clamped,
|
||||
limit: 100,
|
||||
remaining: Math.max(0, 100 - clamped),
|
||||
usedFraction,
|
||||
remainingFraction: Math.max(0, 1 - usedFraction),
|
||||
unit: "percent",
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsageStatus(usedFraction?: number, limitReached?: boolean): UsageLimit["status"] {
|
||||
if (limitReached) return "exhausted";
|
||||
if (usedFraction === undefined) return "unknown";
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
function buildUsageStatus(
|
||||
usedFraction?: number,
|
||||
limitReached?: boolean,
|
||||
): UsageLimit["status"] {
|
||||
if (limitReached) return "exhausted";
|
||||
if (usedFraction === undefined) return "unknown";
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function buildUsageLimit(args: {
|
||||
key: "primary" | "secondary";
|
||||
window: ParsedUsageWindow;
|
||||
accountId?: string;
|
||||
planType?: string;
|
||||
limitReached?: boolean;
|
||||
nowMs: number;
|
||||
key: "primary" | "secondary";
|
||||
window: ParsedUsageWindow;
|
||||
accountId?: string;
|
||||
planType?: string;
|
||||
limitReached?: boolean;
|
||||
nowMs: number;
|
||||
}): UsageLimit {
|
||||
const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs);
|
||||
const amount = buildUsageAmount(args.window);
|
||||
return {
|
||||
id: `openai-codex:${args.key}`,
|
||||
label: usageWindow.label,
|
||||
scope: {
|
||||
provider: "openai-codex",
|
||||
accountId: args.accountId,
|
||||
tier: args.planType,
|
||||
windowId: usageWindow.id,
|
||||
shared: true,
|
||||
},
|
||||
window: usageWindow,
|
||||
amount,
|
||||
status: buildUsageStatus(amount.usedFraction, args.limitReached),
|
||||
};
|
||||
const usageWindow = buildUsageWindow(args.window, args.key, args.nowMs);
|
||||
const amount = buildUsageAmount(args.window);
|
||||
return {
|
||||
id: `openai-codex:${args.key}`,
|
||||
label: usageWindow.label,
|
||||
scope: {
|
||||
provider: "openai-codex",
|
||||
accountId: args.accountId,
|
||||
tier: args.planType,
|
||||
windowId: usageWindow.id,
|
||||
shared: true,
|
||||
},
|
||||
window: usageWindow,
|
||||
amount,
|
||||
status: buildUsageStatus(amount.usedFraction, args.limitReached),
|
||||
};
|
||||
}
|
||||
|
||||
export const openaiCodexUsageProvider: UsageProvider = {
|
||||
id: "openai-codex",
|
||||
supports(params: UsageFetchParams): boolean {
|
||||
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;
|
||||
if (credential.type !== "oauth") return null;
|
||||
id: "openai-codex",
|
||||
supports(params: UsageFetchParams): boolean {
|
||||
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;
|
||||
if (credential.type !== "oauth") return null;
|
||||
|
||||
const accessToken = credential.accessToken;
|
||||
if (!accessToken) return null;
|
||||
const accessToken = credential.accessToken;
|
||||
if (!accessToken) return null;
|
||||
|
||||
const nowMs = Date.now();
|
||||
if (credential.expiresAt !== undefined && credential.expiresAt <= nowMs) {
|
||||
ctx.logger?.warn("Codex usage token expired", { provider: params.provider });
|
||||
return null;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (credential.expiresAt !== undefined && credential.expiresAt <= nowMs) {
|
||||
ctx.logger?.warn("Codex usage token expired", {
|
||||
provider: params.provider,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeCodexBaseUrl(params.baseUrl);
|
||||
const accountId = credential.accountId ?? extractAccountId(accessToken);
|
||||
const email = normalizeEmail(credential.email ?? extractEmail(accessToken));
|
||||
const baseUrl = normalizeCodexBaseUrl(params.baseUrl);
|
||||
const accountId = credential.accountId ?? extractAccountId(accessToken);
|
||||
const email = normalizeEmail(credential.email ?? extractEmail(accessToken));
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"User-Agent": "OpenCode-Status-Plugin/1.0",
|
||||
};
|
||||
if (accountId) {
|
||||
headers["ChatGPT-Account-Id"] = accountId;
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"User-Agent": "OpenCode-Status-Plugin/1.0",
|
||||
};
|
||||
if (accountId) {
|
||||
headers["ChatGPT-Account-Id"] = accountId;
|
||||
}
|
||||
|
||||
const url = buildCodexUsageUrl(baseUrl);
|
||||
let payload: unknown;
|
||||
try {
|
||||
const response = await ctx.fetch(url, { headers, signal: params.signal });
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("Codex usage request failed", { status: response.status, provider: params.provider });
|
||||
return null;
|
||||
}
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
ctx.logger?.warn("Codex usage request error", { provider: params.provider, error: String(error) });
|
||||
return null;
|
||||
}
|
||||
const url = buildCodexUsageUrl(baseUrl);
|
||||
let payload: unknown;
|
||||
try {
|
||||
const response = await ctx.fetch(url, { headers, signal: params.signal });
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("Codex usage request failed", {
|
||||
status: response.status,
|
||||
provider: params.provider,
|
||||
});
|
||||
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 planType =
|
||||
parsed?.planType ??
|
||||
(isRecord(payload) && typeof payload.plan_type === "string" ? payload.plan_type : undefined);
|
||||
const parsed = parseUsagePayload(payload);
|
||||
const planType =
|
||||
parsed?.planType ??
|
||||
(isRecord(payload) && typeof payload.plan_type === "string"
|
||||
? payload.plan_type
|
||||
: undefined);
|
||||
|
||||
const limits: UsageLimit[] = [];
|
||||
if (parsed?.primary) {
|
||||
limits.push(
|
||||
buildUsageLimit({
|
||||
key: "primary",
|
||||
window: parsed.primary,
|
||||
accountId,
|
||||
planType,
|
||||
limitReached: parsed.limitReached,
|
||||
nowMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (parsed?.secondary) {
|
||||
limits.push(
|
||||
buildUsageLimit({
|
||||
key: "secondary",
|
||||
window: parsed.secondary,
|
||||
accountId,
|
||||
planType,
|
||||
limitReached: parsed.limitReached,
|
||||
nowMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const limits: UsageLimit[] = [];
|
||||
if (parsed?.primary) {
|
||||
limits.push(
|
||||
buildUsageLimit({
|
||||
key: "primary",
|
||||
window: parsed.primary,
|
||||
accountId,
|
||||
planType,
|
||||
limitReached: parsed.limitReached,
|
||||
nowMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (parsed?.secondary) {
|
||||
limits.push(
|
||||
buildUsageLimit({
|
||||
key: "secondary",
|
||||
window: parsed.secondary,
|
||||
accountId,
|
||||
planType,
|
||||
limitReached: parsed.limitReached,
|
||||
nowMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const report: UsageReport = {
|
||||
provider: "openai-codex",
|
||||
fetchedAt: nowMs,
|
||||
limits,
|
||||
metadata: {
|
||||
planType,
|
||||
allowed: parsed?.allowed,
|
||||
limitReached: parsed?.limitReached,
|
||||
email,
|
||||
accountId,
|
||||
},
|
||||
raw: parsed?.raw ?? payload,
|
||||
};
|
||||
const report: UsageReport = {
|
||||
provider: "openai-codex",
|
||||
fetchedAt: nowMs,
|
||||
limits,
|
||||
metadata: {
|
||||
planType,
|
||||
allowed: parsed?.allowed,
|
||||
limitReached: parsed?.limitReached,
|
||||
email,
|
||||
accountId,
|
||||
},
|
||||
raw: parsed?.raw ?? payload,
|
||||
};
|
||||
|
||||
return report;
|
||||
},
|
||||
return report;
|
||||
},
|
||||
};
|
||||
|
||||
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
export const codexRankingStrategy: CredentialRankingStrategy = {
|
||||
findWindowLimits(report) {
|
||||
const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => {
|
||||
const direct = report.limits.find(l => l.id === `openai-codex:${key}`);
|
||||
if (direct) return direct;
|
||||
const byId = report.limits.find(l => l.id.toLowerCase().includes(key));
|
||||
if (byId) return byId;
|
||||
const windowId = key === "secondary" ? "7d" : "1h";
|
||||
return report.limits.find(l => l.scope.windowId?.toLowerCase() === windowId);
|
||||
};
|
||||
return { primary: findLimit("primary"), secondary: findLimit("secondary") };
|
||||
},
|
||||
windowDefaults: { primaryMs: 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
|
||||
hasPriorityBoost(primary) {
|
||||
if (!primary) return false;
|
||||
const windowId = primary.scope.windowId?.toLowerCase();
|
||||
const durationMs = primary.window?.durationMs;
|
||||
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;
|
||||
},
|
||||
findWindowLimits(report) {
|
||||
const findLimit = (
|
||||
key: "primary" | "secondary",
|
||||
): UsageLimit | undefined => {
|
||||
const direct = report.limits.find((l) => l.id === `openai-codex:${key}`);
|
||||
if (direct) return direct;
|
||||
const byId = report.limits.find((l) => l.id.toLowerCase().includes(key));
|
||||
if (byId) return byId;
|
||||
const windowId = key === "secondary" ? "7d" : "1h";
|
||||
return report.limits.find(
|
||||
(l) => l.scope.windowId?.toLowerCase() === windowId,
|
||||
);
|
||||
};
|
||||
return { primary: findLimit("primary"), secondary: findLimit("secondary") };
|
||||
},
|
||||
windowDefaults: {
|
||||
primaryMs: 60 * 60 * 1000,
|
||||
secondaryMs: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
hasPriorityBoost(primary) {
|
||||
if (!primary) return false;
|
||||
const windowId = primary.scope.windowId?.toLowerCase();
|
||||
const durationMs = primary.window?.durationMs;
|
||||
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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
411
usage/zai.ts
411
usage/zai.ts
@@ -1,12 +1,12 @@
|
||||
import type {
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageStatus,
|
||||
UsageWindow,
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
UsageLimit,
|
||||
UsageProvider,
|
||||
UsageReport,
|
||||
UsageStatus,
|
||||
UsageWindow,
|
||||
} from "../usage";
|
||||
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;
|
||||
|
||||
function normalizeZaiBaseUrl(baseUrl?: string): string {
|
||||
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
||||
try {
|
||||
return new URL(baseUrl.trim()).origin;
|
||||
} catch {
|
||||
return DEFAULT_ENDPOINT;
|
||||
}
|
||||
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
||||
try {
|
||||
return new URL(baseUrl.trim()).origin;
|
||||
} catch {
|
||||
return DEFAULT_ENDPOINT;
|
||||
}
|
||||
}
|
||||
|
||||
interface ZaiUsageLimitItem {
|
||||
type?: string;
|
||||
usage?: number;
|
||||
currentValue?: number;
|
||||
percentage?: number;
|
||||
remaining?: number;
|
||||
nextResetTime?: number;
|
||||
type?: string;
|
||||
usage?: number;
|
||||
currentValue?: number;
|
||||
percentage?: number;
|
||||
remaining?: number;
|
||||
nextResetTime?: number;
|
||||
}
|
||||
|
||||
interface ZaiQuotaPayload {
|
||||
success?: boolean;
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
limits?: ZaiUsageLimitItem[];
|
||||
};
|
||||
success?: boolean;
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
limits?: ZaiUsageLimitItem[];
|
||||
};
|
||||
}
|
||||
|
||||
function parseMillis(value: unknown): number | undefined {
|
||||
const parsed = toNumber(value);
|
||||
if (parsed === undefined) return undefined;
|
||||
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
|
||||
const parsed = toNumber(value);
|
||||
if (parsed === undefined) return undefined;
|
||||
return parsed > 1_000_000_000_000 ? parsed : parsed * 1000;
|
||||
}
|
||||
|
||||
function parseLimitItem(value: unknown): ZaiUsageLimitItem | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const type = typeof value.type === "string" ? value.type : undefined;
|
||||
if (!type) return null;
|
||||
return {
|
||||
type,
|
||||
usage: toNumber(value.usage),
|
||||
currentValue: toNumber(value.currentValue),
|
||||
percentage: toNumber(value.percentage),
|
||||
remaining: toNumber(value.remaining),
|
||||
nextResetTime: parseMillis(value.nextResetTime),
|
||||
};
|
||||
if (!isRecord(value)) return null;
|
||||
const type = typeof value.type === "string" ? value.type : undefined;
|
||||
if (!type) return null;
|
||||
return {
|
||||
type,
|
||||
usage: toNumber(value.usage),
|
||||
currentValue: toNumber(value.currentValue),
|
||||
percentage: toNumber(value.percentage),
|
||||
remaining: toNumber(value.remaining),
|
||||
nextResetTime: parseMillis(value.nextResetTime),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsageAmount(args: {
|
||||
used: number | undefined;
|
||||
limit: number | undefined;
|
||||
remaining: number | undefined;
|
||||
unit: UsageAmount["unit"];
|
||||
percentage?: number;
|
||||
used: number | undefined;
|
||||
limit: number | undefined;
|
||||
remaining: number | undefined;
|
||||
unit: UsageAmount["unit"];
|
||||
percentage?: number;
|
||||
}): UsageAmount {
|
||||
const usedFraction =
|
||||
args.percentage !== undefined
|
||||
? Math.min(Math.max(args.percentage / 100, 0), 1)
|
||||
: args.used !== undefined && args.limit !== undefined && args.limit > 0
|
||||
? Math.min(args.used / args.limit, 1)
|
||||
: undefined;
|
||||
const remainingFraction = usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined;
|
||||
return {
|
||||
used: args.used,
|
||||
limit: args.limit,
|
||||
remaining: args.remaining,
|
||||
usedFraction,
|
||||
remainingFraction,
|
||||
unit: args.unit,
|
||||
};
|
||||
const usedFraction =
|
||||
args.percentage !== undefined
|
||||
? Math.min(Math.max(args.percentage / 100, 0), 1)
|
||||
: args.used !== undefined && args.limit !== undefined && args.limit > 0
|
||||
? Math.min(args.used / args.limit, 1)
|
||||
: undefined;
|
||||
const remainingFraction =
|
||||
usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined;
|
||||
return {
|
||||
used: args.used,
|
||||
limit: args.limit,
|
||||
remaining: args.remaining,
|
||||
usedFraction,
|
||||
remainingFraction,
|
||||
unit: args.unit,
|
||||
};
|
||||
}
|
||||
|
||||
function getUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
|
||||
if (usedFraction === undefined) return undefined;
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
function getUsageStatus(
|
||||
usedFraction: number | undefined,
|
||||
): UsageStatus | undefined {
|
||||
if (usedFraction === undefined) return undefined;
|
||||
if (usedFraction >= 1) return "exhausted";
|
||||
if (usedFraction >= 0.9) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function formatDate(value: Date): string {
|
||||
const pad = (input: number) => String(input).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}+${pad(value.getHours())}:${pad(
|
||||
value.getMinutes(),
|
||||
)}:${pad(value.getSeconds())}`;
|
||||
const pad = (input: number) => String(input).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}+${pad(value.getHours())}:${pad(
|
||||
value.getMinutes(),
|
||||
)}:${pad(value.getSeconds())}`;
|
||||
}
|
||||
|
||||
function buildModelUsageUrl(baseUrl: string, now: Date): string {
|
||||
const start = new Date(now.getTime() - SEVEN_DAYS_MS);
|
||||
const startTime = formatDate(start);
|
||||
const endTime = formatDate(now);
|
||||
return `${baseUrl}${MODEL_USAGE_PATH}?startTime=${encodeURIComponent(startTime)}&endTime=${encodeURIComponent(endTime)}`;
|
||||
const start = new Date(now.getTime() - SEVEN_DAYS_MS);
|
||||
const startTime = formatDate(start);
|
||||
const endTime = formatDate(now);
|
||||
return `${baseUrl}${MODEL_USAGE_PATH}?startTime=${encodeURIComponent(startTime)}&endTime=${encodeURIComponent(endTime)}`;
|
||||
}
|
||||
|
||||
async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
|
||||
if (params.provider !== "zai") return null;
|
||||
const credential = params.credential;
|
||||
if (credential.type !== "api_key" || !credential.apiKey) return null;
|
||||
async function fetchZaiUsage(
|
||||
params: UsageFetchParams,
|
||||
ctx: UsageFetchContext,
|
||||
): 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 url = `${baseUrl}${QUOTA_PATH}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: credential.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OpenCode-Status-Plugin/1.0",
|
||||
};
|
||||
const baseUrl = normalizeZaiBaseUrl(params.baseUrl);
|
||||
const url = `${baseUrl}${QUOTA_PATH}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: credential.apiKey,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OpenCode-Status-Plugin/1.0",
|
||||
};
|
||||
|
||||
let payload: ZaiQuotaPayload | null = null;
|
||||
try {
|
||||
const response = await ctx.fetch(url, {
|
||||
headers,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("ZAI usage fetch failed", { status: response.status, statusText: response.statusText });
|
||||
return null;
|
||||
}
|
||||
payload = (await response.json()) as ZaiQuotaPayload;
|
||||
} catch (error) {
|
||||
ctx.logger?.warn("ZAI usage fetch error", { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
let payload: ZaiQuotaPayload | null = null;
|
||||
try {
|
||||
const response = await ctx.fetch(url, {
|
||||
headers,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
ctx.logger?.warn("ZAI usage fetch failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
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.success !== true) {
|
||||
ctx.logger?.warn("ZAI usage response invalid", { code: payload.code, message: payload.msg });
|
||||
return null;
|
||||
}
|
||||
if (!payload) return null;
|
||||
if (payload.success !== true) {
|
||||
ctx.logger?.warn("ZAI usage response invalid", {
|
||||
code: payload.code,
|
||||
message: payload.msg,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const limitsPayload = Array.isArray(payload.data?.limits) ? payload.data?.limits : [];
|
||||
const limits: UsageLimit[] = [];
|
||||
const limitsPayload = Array.isArray(payload.data?.limits)
|
||||
? payload.data?.limits
|
||||
: [];
|
||||
const limits: UsageLimit[] = [];
|
||||
|
||||
for (const rawLimit of limitsPayload) {
|
||||
const parsed = parseLimitItem(rawLimit);
|
||||
if (!parsed) continue;
|
||||
if (parsed.type === "TOKENS_LIMIT") {
|
||||
const amount = buildUsageAmount({
|
||||
used: parsed.currentValue,
|
||||
limit: parsed.usage,
|
||||
remaining: parsed.remaining,
|
||||
percentage: parsed.percentage,
|
||||
unit: "tokens",
|
||||
});
|
||||
const window: UsageWindow = {
|
||||
id: "quota",
|
||||
label: "Quota",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
resetsAt: parsed.nextResetTime,
|
||||
};
|
||||
limits.push({
|
||||
id: "zai:tokens",
|
||||
label: "ZAI Token Quota",
|
||||
scope: {
|
||||
provider: params.provider,
|
||||
windowId: window?.id ?? "quota",
|
||||
shared: true,
|
||||
},
|
||||
window,
|
||||
amount,
|
||||
status: getUsageStatus(amount.usedFraction),
|
||||
});
|
||||
}
|
||||
if (parsed.type === "TIME_LIMIT") {
|
||||
const window: UsageWindow = {
|
||||
id: "quota",
|
||||
label: "Quota",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
resetsAt: parsed.nextResetTime,
|
||||
};
|
||||
const amount = buildUsageAmount({
|
||||
used: parsed.currentValue,
|
||||
limit: parsed.usage,
|
||||
remaining: parsed.remaining,
|
||||
percentage: parsed.percentage,
|
||||
unit: "requests",
|
||||
});
|
||||
limits.push({
|
||||
id: "zai:requests",
|
||||
label: "ZAI Request Quota",
|
||||
scope: {
|
||||
provider: params.provider,
|
||||
windowId: "quota",
|
||||
shared: true,
|
||||
},
|
||||
window,
|
||||
amount,
|
||||
status: getUsageStatus(amount.usedFraction),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const rawLimit of limitsPayload) {
|
||||
const parsed = parseLimitItem(rawLimit);
|
||||
if (!parsed) continue;
|
||||
if (parsed.type === "TOKENS_LIMIT") {
|
||||
const amount = buildUsageAmount({
|
||||
used: parsed.currentValue,
|
||||
limit: parsed.usage,
|
||||
remaining: parsed.remaining,
|
||||
percentage: parsed.percentage,
|
||||
unit: "tokens",
|
||||
});
|
||||
const window: UsageWindow = {
|
||||
id: "quota",
|
||||
label: "Quota",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
resetsAt: parsed.nextResetTime,
|
||||
};
|
||||
limits.push({
|
||||
id: "zai:tokens",
|
||||
label: "ZAI Token Quota",
|
||||
scope: {
|
||||
provider: params.provider,
|
||||
windowId: window?.id ?? "quota",
|
||||
shared: true,
|
||||
},
|
||||
window,
|
||||
amount,
|
||||
status: getUsageStatus(amount.usedFraction),
|
||||
});
|
||||
}
|
||||
if (parsed.type === "TIME_LIMIT") {
|
||||
const window: UsageWindow = {
|
||||
id: "quota",
|
||||
label: "Quota",
|
||||
durationMs: SEVEN_DAYS_MS,
|
||||
resetsAt: parsed.nextResetTime,
|
||||
};
|
||||
const amount = buildUsageAmount({
|
||||
used: parsed.currentValue,
|
||||
limit: parsed.usage,
|
||||
remaining: parsed.remaining,
|
||||
percentage: parsed.percentage,
|
||||
unit: "requests",
|
||||
});
|
||||
limits.push({
|
||||
id: "zai:requests",
|
||||
label: "ZAI Request Quota",
|
||||
scope: {
|
||||
provider: params.provider,
|
||||
windowId: "quota",
|
||||
shared: true,
|
||||
},
|
||||
window,
|
||||
amount,
|
||||
status: getUsageStatus(amount.usedFraction),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (limits.length === 0) return null;
|
||||
if (limits.length === 0) return null;
|
||||
|
||||
const report: UsageReport = {
|
||||
provider: params.provider,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: {
|
||||
endpoint: url,
|
||||
accountId: credential.accountId,
|
||||
email: credential.email,
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
const report: UsageReport = {
|
||||
provider: params.provider,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: {
|
||||
endpoint: url,
|
||||
accountId: credential.accountId,
|
||||
email: credential.email,
|
||||
},
|
||||
raw: payload,
|
||||
};
|
||||
|
||||
const modelUsageUrl = buildModelUsageUrl(baseUrl, new Date());
|
||||
try {
|
||||
const response = await ctx.fetch(modelUsageUrl, {
|
||||
headers,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
const modelUsagePayload = (await response.json()) as unknown;
|
||||
if (isRecord(modelUsagePayload)) {
|
||||
report.metadata = {
|
||||
...report.metadata,
|
||||
modelUsage: modelUsagePayload,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger?.debug("ZAI model usage fetch failed", { error: String(error) });
|
||||
}
|
||||
const modelUsageUrl = buildModelUsageUrl(baseUrl, new Date());
|
||||
try {
|
||||
const response = await ctx.fetch(modelUsageUrl, {
|
||||
headers,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
const modelUsagePayload = (await response.json()) as unknown;
|
||||
if (isRecord(modelUsagePayload)) {
|
||||
report.metadata = {
|
||||
...report.metadata,
|
||||
modelUsage: modelUsagePayload,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger?.debug("ZAI model usage fetch failed", { error: String(error) });
|
||||
}
|
||||
|
||||
return report;
|
||||
return report;
|
||||
}
|
||||
|
||||
export const zaiUsageProvider: UsageProvider = {
|
||||
id: "zai",
|
||||
fetchUsage: fetchZaiUsage,
|
||||
supports: params => params.provider === "zai" && params.credential.type === "api_key",
|
||||
id: "zai",
|
||||
fetchUsage: fetchZaiUsage,
|
||||
supports: (params) =>
|
||||
params.provider === "zai" && params.credential.type === "api_key",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user