feat: add configurable pi statusbar extension
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# pi-statusbar Agent Guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`pi-statusbar` is a pi extension that replaces the built-in footer with a configurable two-row statusbar. It combines native footer-like modules with account usage modules for providers such as Claude/Anthropic, Codex/ChatGPT, and Z.ai.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `index.ts` — Extension entrypoint, footer registration, refresh timers, 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.
|
||||||
|
- `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 copied/adapted from `oh-my-pi`.
|
||||||
|
- `providers/openai-codex/constants.ts`, `usage.ts`, `utils.ts` — Local shims/support for copied usage code.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Keep user layout changes in `config.ts` when possible.
|
||||||
|
- Preserve copied provider logic in `usage/` unless fixing integration issues.
|
||||||
|
- Use composable module config instead of subtype strings. For usage, prefer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ type: "usage", window: "current", style: "line", grow: 1 }
|
||||||
|
{ 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 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`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Reload pi with `/reload` after changes.
|
||||||
|
- Test narrow terminal widths after render changes.
|
||||||
|
- If terminal styling gets stuck during color work, reset with `printf '\033[0m'` or `reset`.
|
||||||
41
config.ts
Normal file
41
config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { StatusbarConfig } from "./types";
|
||||||
|
|
||||||
|
export const statusbarConfig: StatusbarConfig = {
|
||||||
|
separator: "·",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
left: ["directory"],
|
||||||
|
right: ["context", "model"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
center: [
|
||||||
|
{ type: "usage", window: "current", style: "line", grow: 1 },
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
window: "current",
|
||||||
|
style: "text",
|
||||||
|
parts: ["time"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "usage",
|
||||||
|
window: "week",
|
||||||
|
style: "text",
|
||||||
|
parts: ["time"],
|
||||||
|
},
|
||||||
|
{ type: "usage", window: "week", style: "line", grow: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modules: {
|
||||||
|
directory: { showGitBranch: true, showSessionName: true },
|
||||||
|
context: { showWindow: true },
|
||||||
|
model: { showProvider: true, showThinking: true },
|
||||||
|
cost: { showSubscription: false },
|
||||||
|
usage: {
|
||||||
|
showText: false,
|
||||||
|
parts: ["percent", "time"],
|
||||||
|
separator: " | ",
|
||||||
|
colorBars: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776734388,
|
||||||
|
"narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
29
flake.nix
Normal file
29
flake.nix
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
description = "Development Environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self
|
||||||
|
, nixpkgs
|
||||||
|
, flake-utils
|
||||||
|
,
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
nodejs_22
|
||||||
|
typescript-language-server
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
238
index.ts
Normal file
238
index.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { ExtensionAPI } 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 { contextModule, costModule, directoryModule, modelModule, thinkingModule } from "./modules/basic";
|
||||||
|
import { usageModule } from "./modules/usage";
|
||||||
|
import { moduleType, renderRow } from "./render";
|
||||||
|
import { claudeUsageProvider } from "./usage/claude";
|
||||||
|
import { openaiCodexUsageProvider } from "./usage/openai-codex";
|
||||||
|
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,
|
||||||
|
zai: zaiUsageProvider,
|
||||||
|
anthropic: claudeUsageProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeProvider(ctx: any): 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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModule(moduleCtx: ModuleContext, spec: ModuleSpec): RenderedModule {
|
||||||
|
switch (moduleType(spec)) {
|
||||||
|
case "directory":
|
||||||
|
return directoryModule(moduleCtx);
|
||||||
|
case "context":
|
||||||
|
return contextModule(moduleCtx);
|
||||||
|
case "model":
|
||||||
|
return modelModule(moduleCtx);
|
||||||
|
case "thinking":
|
||||||
|
return thinkingModule(moduleCtx);
|
||||||
|
case "cost":
|
||||||
|
return costModule(moduleCtx);
|
||||||
|
case "usage":
|
||||||
|
return usageModule(moduleCtx, spec);
|
||||||
|
default:
|
||||||
|
return { text: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter(ctx: any, footerData: any, 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)),
|
||||||
|
center: (row.center ?? []).map(spec => renderModule(moduleCtx, spec)),
|
||||||
|
right: (row.right ?? []).map(spec => renderModule(moduleCtx, spec)),
|
||||||
|
}, width, statusbarConfig.separator, theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageSummary(report: UsageReport | undefined, error: string | undefined): string {
|
||||||
|
if (error) return `usage: ${error}`;
|
||||||
|
if (!report) return "usage loading";
|
||||||
|
return "usage refreshed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function piStatusbarExtension(pi: ExtensionAPI) {
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let inFlight: AbortController | undefined;
|
||||||
|
let lastProvider: Provider | undefined;
|
||||||
|
let latestCtx: any;
|
||||||
|
let requestRender: (() => void) | undefined;
|
||||||
|
const statusbarState: StatusbarState = {};
|
||||||
|
|
||||||
|
function updateThinkingLevel() {
|
||||||
|
try {
|
||||||
|
statusbarState.thinkingLevel = pi.getThinkingLevel?.() ?? "off";
|
||||||
|
} catch {
|
||||||
|
statusbarState.thinkingLevel = "off";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerender(ctx: any) {
|
||||||
|
latestCtx = ctx;
|
||||||
|
requestRender?.();
|
||||||
|
if (ctx.hasUI) ctx.ui.setStatus("pi-statusbar", undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFooter(ctx: any) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
latestCtx = ctx;
|
||||||
|
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
|
||||||
|
requestRender = () => tui.requestRender();
|
||||||
|
return {
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number): string[] {
|
||||||
|
return renderFooter(latestCtx ?? ctx, footerData, statusbarState, width, theme);
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
requestRender = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(ctx: any, force = false) {
|
||||||
|
latestCtx = ctx;
|
||||||
|
const provider = activeProvider(ctx);
|
||||||
|
lastProvider = provider;
|
||||||
|
if (!provider) {
|
||||||
|
statusbarState.report = undefined;
|
||||||
|
statusbarState.error = undefined;
|
||||||
|
rerender(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minRefreshMs = provider === "anthropic" ? ANTHROPIC_REFRESH_MS : REFRESH_MS;
|
||||||
|
if (!force && statusbarState.report?.provider === provider && Date.now() - statusbarState.report.fetchedAt < minRefreshMs) {
|
||||||
|
rerender(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = readPiCredential(provider);
|
||||||
|
if (!credential) {
|
||||||
|
statusbarState.report = { provider, fetchedAt: Date.now(), limits: [] };
|
||||||
|
statusbarState.error = "not logged in";
|
||||||
|
rerender(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlight?.abort();
|
||||||
|
inFlight = new AbortController();
|
||||||
|
statusbarState.report = undefined;
|
||||||
|
statusbarState.error = undefined;
|
||||||
|
rerender(ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await usageProviders[provider].fetchUsage({
|
||||||
|
provider,
|
||||||
|
credential,
|
||||||
|
baseUrl: ctx.model?.baseUrl,
|
||||||
|
signal: inFlight.signal,
|
||||||
|
}, {
|
||||||
|
fetch: globalThis.fetch.bind(globalThis),
|
||||||
|
logger: {
|
||||||
|
debug: () => undefined,
|
||||||
|
warn: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
statusbarState.report = report ?? { provider, fetchedAt: Date.now(), limits: [] };
|
||||||
|
statusbarState.error = report ? undefined : "unavailable";
|
||||||
|
} catch (error) {
|
||||||
|
statusbarState.report = { provider, fetchedAt: Date.now(), limits: [] };
|
||||||
|
statusbarState.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastProvider === provider) rerender(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
updateThinkingLevel();
|
||||||
|
installFooter(ctx);
|
||||||
|
await refresh(ctx, true);
|
||||||
|
|
||||||
|
// Usage Refresh Timer
|
||||||
|
timer = setInterval(() => void refresh(latestCtx ?? ctx), REFRESH_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async (_event, ctx) => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
inFlight?.abort();
|
||||||
|
inFlight = undefined;
|
||||||
|
requestRender = undefined;
|
||||||
|
if (ctx.hasUI) {
|
||||||
|
ctx.ui.setStatus("pi-statusbar", undefined);
|
||||||
|
ctx.ui.setFooter(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("model_select", async (_event, ctx) => {
|
||||||
|
updateThinkingLevel();
|
||||||
|
await refresh(ctx, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("thinking_level_select", async (event, ctx) => {
|
||||||
|
statusbarState.thinkingLevel = event.level;
|
||||||
|
rerender(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", async (_event, ctx) => {
|
||||||
|
updateThinkingLevel();
|
||||||
|
await refresh(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("refresh-usage", {
|
||||||
|
description: "Refresh account usage for the active provider",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
await refresh(ctx, true);
|
||||||
|
ctx.ui.notify(usageSummary(statusbarState.report, statusbarState.error), statusbarState.error ? "warning" : "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
106
modules/basic.ts
Normal file
106
modules/basic.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
|
import type { ModuleContext, RenderedModule } from "../types";
|
||||||
|
|
||||||
|
function formatTokens(count: number): string {
|
||||||
|
if (count < 1000) return count.toString();
|
||||||
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
||||||
|
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
||||||
|
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
return `${Math.round(count / 1000000)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionEntries(ctx: any): any[] {
|
||||||
|
try {
|
||||||
|
return ctx.sessionManager?.getEntries?.() ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalCost(ctx: any): number {
|
||||||
|
return sessionEntries(ctx).reduce((total, entry) => {
|
||||||
|
const cost =
|
||||||
|
entry?.type === "message" && entry.message?.role === "assistant"
|
||||||
|
? entry.message.usage?.cost?.total
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
total + (typeof cost === "number" && Number.isFinite(cost) ? cost : 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsingSubscription(ctx: any): boolean {
|
||||||
|
const provider = ctx.model?.provider;
|
||||||
|
if (!provider) return false;
|
||||||
|
try {
|
||||||
|
return Boolean(ctx.modelRegistry?.isUsingOAuth?.(ctx.model));
|
||||||
|
} catch {
|
||||||
|
return provider === "anthropic" || provider === "openai-codex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function directoryModule(moduleCtx: ModuleContext): RenderedModule {
|
||||||
|
const cwd = moduleCtx.ctx.sessionManager?.getCwd?.() ?? process.cwd();
|
||||||
|
const home = homedir();
|
||||||
|
let text = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
|
||||||
|
|
||||||
|
// Git And Session Name
|
||||||
|
const options = moduleCtx.config.modules.directory;
|
||||||
|
if (options?.showGitBranch) {
|
||||||
|
const branch = moduleCtx.footerData?.getGitBranch?.();
|
||||||
|
if (branch) text = `${text} (${branch})`;
|
||||||
|
}
|
||||||
|
if (options?.showSessionName) {
|
||||||
|
const sessionName = moduleCtx.ctx.sessionManager?.getSessionName?.();
|
||||||
|
if (sessionName) text = `${text} • ${sessionName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextModule(moduleCtx: ModuleContext): RenderedModule {
|
||||||
|
const usage = moduleCtx.ctx.getContextUsage?.();
|
||||||
|
const contextWindow =
|
||||||
|
usage?.contextWindow ?? moduleCtx.ctx.model?.contextWindow ?? 0;
|
||||||
|
const percent = usage?.percent;
|
||||||
|
const showWindow = moduleCtx.config.modules.context?.showWindow ?? false;
|
||||||
|
const value =
|
||||||
|
percent === null || percent === undefined
|
||||||
|
? "?"
|
||||||
|
: `${Number(percent).toFixed(1)}%`;
|
||||||
|
return {
|
||||||
|
text: showWindow ? `${value} / ${formatTokens(contextWindow)}` : `${value}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelModule(moduleCtx: ModuleContext): RenderedModule {
|
||||||
|
const model = moduleCtx.ctx.model;
|
||||||
|
if (!model) return { text: "no-model" };
|
||||||
|
|
||||||
|
// Model Label
|
||||||
|
const options = moduleCtx.config.modules.model;
|
||||||
|
const provider = options?.showProvider ? `(${model.provider}) ` : "";
|
||||||
|
let text = `${provider}${model.id}`;
|
||||||
|
if (options?.showThinking && model.reasoning) {
|
||||||
|
const thinking = moduleCtx.state.thinkingLevel ?? "off";
|
||||||
|
text = thinking === "off" ? `${text} thinking off` : `${text} ${thinking}`;
|
||||||
|
}
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thinkingModule(moduleCtx: ModuleContext): RenderedModule {
|
||||||
|
if (!moduleCtx.ctx.model?.reasoning) return { text: "" };
|
||||||
|
const level = moduleCtx.state.thinkingLevel ?? "off";
|
||||||
|
if (level === "off" && moduleCtx.config.modules.thinking?.hideWhenOff)
|
||||||
|
return { text: "" };
|
||||||
|
return { text: `think ${level}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function costModule(moduleCtx: ModuleContext): RenderedModule {
|
||||||
|
const cost = totalCost(moduleCtx.ctx);
|
||||||
|
const subscription = isUsingSubscription(moduleCtx.ctx);
|
||||||
|
if (!cost && !subscription) return { text: "" };
|
||||||
|
if (subscription && moduleCtx.config.modules.cost?.showSubscription !== false)
|
||||||
|
return { text: cost ? `$${cost.toFixed(3)} sub` : "sub" };
|
||||||
|
return { text: `$${cost.toFixed(3)}` };
|
||||||
|
}
|
||||||
152
modules/usage.ts
Normal file
152
modules/usage.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { ModuleContext, ModuleSpec, RenderedModule, UsageTextPart, UsageWindows } from "../types";
|
||||||
|
import type { UsageLimit, UsageReport } from "../usage";
|
||||||
|
|
||||||
|
function usageFraction(limit: UsageLimit): number | undefined {
|
||||||
|
if (limit.amount.usedFraction !== undefined) return Math.min(Math.max(limit.amount.usedFraction, 0), 1);
|
||||||
|
if (limit.amount.used !== undefined && limit.amount.limit !== undefined && limit.amount.limit > 0) {
|
||||||
|
return Math.min(Math.max(limit.amount.used / limit.amount.limit, 0), 1);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(valueMs: number | undefined): string | undefined {
|
||||||
|
if (valueMs === undefined || !Number.isFinite(valueMs)) return 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);
|
||||||
|
if (days > 0) return `${days}d${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactNumber(value: number | undefined): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||||
|
return `${Math.round(value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(start: number, end: number, amount: number): number {
|
||||||
|
return Math.round(start + (end - start) * amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fgRgb(text: string, r: number, g: number, b: number): string {
|
||||||
|
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageColor(fraction: number): { r: number; g: number; b: number } {
|
||||||
|
const green = { r: 34, g: 197, b: 94 };
|
||||||
|
const yellow = { r: 234, g: 179, b: 8 };
|
||||||
|
const red = { r: 239, g: 68, b: 68 };
|
||||||
|
|
||||||
|
// Continuous Green → Yellow → Red Gradient
|
||||||
|
if (fraction <= 0.5) {
|
||||||
|
const amount = fraction / 0.5;
|
||||||
|
return {
|
||||||
|
r: lerp(green.r, yellow.r, amount),
|
||||||
|
g: lerp(green.g, yellow.g, amount),
|
||||||
|
b: lerp(green.b, yellow.b, amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = (fraction - 0.5) / 0.5;
|
||||||
|
return {
|
||||||
|
r: lerp(yellow.r, red.r, amount),
|
||||||
|
g: lerp(yellow.g, red.g, amount),
|
||||||
|
b: lerp(yellow.b, red.b, amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return { current, week };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineBar(limit: UsageLimit | undefined, width: number, theme?: any, color = true): string {
|
||||||
|
if (width <= 0) return "";
|
||||||
|
const fraction = limit ? usageFraction(limit) ?? 0 : 0;
|
||||||
|
|
||||||
|
const filled = Math.round(fraction * width);
|
||||||
|
const empty = width - filled;
|
||||||
|
const filledText = "━".repeat(filled);
|
||||||
|
const emptyText = "─".repeat(empty);
|
||||||
|
if (!color) return `${filledText}${emptyText}`;
|
||||||
|
|
||||||
|
const { r, g, b } = usageColor(fraction);
|
||||||
|
const coloredFilled = filledText ? fgRgb(filledText, r, g, b) : "";
|
||||||
|
const dimEmpty = emptyText && theme?.fg ? theme.fg("dim", emptyText) : emptyText;
|
||||||
|
return `${coloredFilled}${dimEmpty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLimit(moduleCtx: ModuleContext, spec: ModuleSpec): UsageLimit | undefined {
|
||||||
|
if (!moduleCtx.state.report || moduleCtx.state.error) return undefined;
|
||||||
|
const windows = findWindowLimits(moduleCtx.state.report);
|
||||||
|
const window = typeof spec === "string" ? "both" : spec.window ?? "both";
|
||||||
|
if (window === "week") return windows.week;
|
||||||
|
return windows.current ?? windows.week;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPart(limit: UsageLimit, part: UsageTextPart): string | undefined {
|
||||||
|
const fraction = usageFraction(limit);
|
||||||
|
switch (part) {
|
||||||
|
case "percent":
|
||||||
|
return fraction === undefined ? undefined : `${Math.round(fraction * 100)}%`;
|
||||||
|
case "time":
|
||||||
|
return duration(limit.window?.resetsAt !== undefined ? limit.window.resetsAt - Date.now() : undefined);
|
||||||
|
case "used":
|
||||||
|
return compactNumber(limit.amount.used);
|
||||||
|
case "limit":
|
||||||
|
return compactNumber(limit.amount.limit);
|
||||||
|
case "remaining":
|
||||||
|
return compactNumber(limit.amount.remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackTextPart(part: UsageTextPart): string | undefined {
|
||||||
|
switch (part) {
|
||||||
|
case "percent":
|
||||||
|
return "0%";
|
||||||
|
case "time":
|
||||||
|
return "∞";
|
||||||
|
case "used":
|
||||||
|
return "0";
|
||||||
|
case "remaining":
|
||||||
|
return "∞";
|
||||||
|
case "limit":
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textUsage(moduleCtx: ModuleContext, spec: ModuleSpec): string {
|
||||||
|
// Usage Text Parts
|
||||||
|
const parts = typeof spec === "string"
|
||||||
|
? moduleCtx.config.modules.usage?.parts ?? ["percent", "time"]
|
||||||
|
: spec.parts ?? moduleCtx.config.modules.usage?.parts ?? ["percent", "time"];
|
||||||
|
const separator = typeof spec === "string"
|
||||||
|
? moduleCtx.config.modules.usage?.separator ?? " | "
|
||||||
|
: spec.separator ?? moduleCtx.config.modules.usage?.separator ?? " | ";
|
||||||
|
|
||||||
|
const limit = !moduleCtx.state.report || moduleCtx.state.error ? undefined : selectedLimit(moduleCtx, spec);
|
||||||
|
return parts
|
||||||
|
.map(part => limit ? textPart(limit, part) : fallbackTextPart(part))
|
||||||
|
.filter((part): part is string => Boolean(part))
|
||||||
|
.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usageModule(moduleCtx: ModuleContext, spec: ModuleSpec): RenderedModule {
|
||||||
|
const style = typeof spec === "string" ? "line" : spec.style ?? "line";
|
||||||
|
const grow = typeof spec === "string" ? 1 : spec.grow ?? 1;
|
||||||
|
|
||||||
|
if (style === "text") return { text: textUsage(moduleCtx, spec) };
|
||||||
|
|
||||||
|
return {
|
||||||
|
grow,
|
||||||
|
render(width: number, theme?: any): string {
|
||||||
|
const limit = !moduleCtx.state.report || moduleCtx.state.error ? undefined : selectedLimit(moduleCtx, spec);
|
||||||
|
return lineBar(limit, width, theme, moduleCtx.config.modules.usage?.colorBars !== false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
4167
package-lock.json
generated
Normal file
4167
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-statusbar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "oxlint . --ignore-pattern=.direnv/** --ignore-pattern=node_modules/**"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.72.1",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"oxlint": "^1.62.0",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
providers/openai-codex/constants.ts
Normal file
45
providers/openai-codex/constants.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Constants for OpenAI Codex (ChatGPT OAuth) backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||||
|
|
||||||
|
export const OPENAI_HEADERS = {
|
||||||
|
BETA: "OpenAI-Beta",
|
||||||
|
ACCOUNT_ID: "chatgpt-account-id",
|
||||||
|
ORIGINATOR: "originator",
|
||||||
|
SESSION_ID: "session_id",
|
||||||
|
CONVERSATION_ID: "conversation_id",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const OPENAI_HEADER_VALUES = {
|
||||||
|
BETA_RESPONSES: "responses=experimental",
|
||||||
|
BETA_RESPONSES_WEBSOCKETS_V2: "responses_websockets=2026-02-06",
|
||||||
|
ORIGINATOR_CODEX: "pi",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const URL_PATHS = {
|
||||||
|
RESPONSES: "/responses",
|
||||||
|
CODEX_RESPONSES: "/codex/responses",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract account ID from a Codex JWT access token.
|
||||||
|
* Returns undefined if the token is not a valid Codex JWT.
|
||||||
|
*/
|
||||||
|
export function getCodexAccountId(accessToken: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parts = accessToken.split(".");
|
||||||
|
if (parts.length !== 3) return undefined;
|
||||||
|
const decoded = Buffer.from(parts[1] ?? "", "base64").toString("utf-8");
|
||||||
|
const payload = JSON.parse(decoded) as Record<string, unknown>;
|
||||||
|
const auth = payload[JWT_CLAIM_PATH] as
|
||||||
|
| { chatgpt_account_id?: string }
|
||||||
|
| undefined;
|
||||||
|
return auth?.chatgpt_account_id ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
render.ts
Normal file
93
render.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ModuleSpec, RenderedModule } from "./types";
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const ANSI_RE = new RegExp("\\u001b\\[[0-9;]*m", "g");
|
||||||
|
|
||||||
|
export function visibleWidth(text: string): number {
|
||||||
|
return text.replace(ANSI_RE, "").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(text: string, width: number): string {
|
||||||
|
if (width <= 0) return "";
|
||||||
|
if (visibleWidth(text) <= width) return text;
|
||||||
|
|
||||||
|
// ANSI-Aware Truncation
|
||||||
|
let output = "";
|
||||||
|
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"));
|
||||||
|
if (match) {
|
||||||
|
output += match[0];
|
||||||
|
index += match[0].length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output += text[index];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return `${output}\x1b[0m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinText(parts: string[], separator: string): string {
|
||||||
|
return parts.filter(Boolean).join(` ${separator} `);
|
||||||
|
}
|
||||||
|
|
||||||
|
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))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimText(text: string, theme?: any): string {
|
||||||
|
return text && theme?.fg ? theme.fg("dim", text) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroup(modules: RenderedModule[], width: number, separator: string, theme?: any): string {
|
||||||
|
const fixed = modules.map(module => module.text ?? "");
|
||||||
|
const growModules = modules.filter(module => module.render);
|
||||||
|
const groupSeparator = modules.every(module => module.render) ? " " : ` ${separator} `;
|
||||||
|
const renderedSeparator = modules.every(module => module.render) ? " " : dimText(groupSeparator, theme);
|
||||||
|
const fixedText = fixed.filter(Boolean).map(text => dimText(text, theme)).join(dimText(` ${separator} `, theme));
|
||||||
|
if (growModules.length === 0) return truncate(fixedText, width);
|
||||||
|
|
||||||
|
const separatorWidth = Math.max(0, modules.length - 1) * groupSeparator.length;
|
||||||
|
const fixedWidth = fixed.reduce((total, text) => total + visibleWidth(text), 0);
|
||||||
|
const remaining = Math.max(0, width - fixedWidth - separatorWidth);
|
||||||
|
const totalGrow = growModules.reduce((total, module) => total + (module.grow ?? 1), 0) || 1;
|
||||||
|
let used = 0;
|
||||||
|
|
||||||
|
const rendered = modules.map(module => {
|
||||||
|
if (!module.render) return dimText(module.text ?? "", theme);
|
||||||
|
const isLastGrow = growModules[growModules.length - 1] === module;
|
||||||
|
const allocated = isLastGrow ? remaining - used : Math.floor(remaining * ((module.grow ?? 1) / totalGrow));
|
||||||
|
used += allocated;
|
||||||
|
return module.render(Math.max(0, allocated), theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
return truncate(rendered.filter(Boolean).join(renderedSeparator), width);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRow(rendered: Record<"left" | "center" | "right", RenderedModule[]>, width: number, separator: string, theme?: any): string {
|
||||||
|
const leftText = renderGroup(rendered.left, width, separator, theme);
|
||||||
|
const rightText = renderGroup(rendered.right, width, separator, theme);
|
||||||
|
const gap = leftText && rightText ? 1 : 0;
|
||||||
|
const centerWidth = Math.max(0, width - visibleWidth(leftText) - visibleWidth(rightText) - gap);
|
||||||
|
const centerText = renderGroup(rendered.center, centerWidth, separator, theme);
|
||||||
|
|
||||||
|
if (centerText) {
|
||||||
|
const leftPad = leftText ? `${leftText} ` : "";
|
||||||
|
const rightPad = rightText ? ` ${rightText}` : "";
|
||||||
|
return truncate(`${leftPad}${centerText}${rightPad}`, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leftText) return `${" ".repeat(Math.max(0, width - visibleWidth(rightText)))}${truncate(rightText, width)}`;
|
||||||
|
if (!rightText) return truncate(leftText, width);
|
||||||
|
const padding = " ".repeat(Math.max(1, width - visibleWidth(leftText) - visibleWidth(rightText)));
|
||||||
|
return truncate(`${leftText}${padding}${rightText}`, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleType(spec: ModuleSpec): string {
|
||||||
|
return typeof spec === "string" ? spec : spec.type;
|
||||||
|
}
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "modules/**/*.ts", "providers/**/*.ts", "usage/**/*.ts"]
|
||||||
|
}
|
||||||
58
types.ts
Normal file
58
types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { UsageLimit, UsageReport } from "./usage";
|
||||||
|
|
||||||
|
export type ModuleName = "directory" | "context" | "model" | "thinking" | "cost" | "usage";
|
||||||
|
export type UsageWindow = "current" | "week" | "both";
|
||||||
|
export type UsageStyle = "line" | "text" | "miniBar";
|
||||||
|
export type UsageTextPart = "percent" | "time" | "used" | "limit" | "remaining";
|
||||||
|
|
||||||
|
export type ModuleSpec = ModuleName | {
|
||||||
|
type: ModuleName;
|
||||||
|
window?: UsageWindow;
|
||||||
|
style?: UsageStyle;
|
||||||
|
grow?: number;
|
||||||
|
parts?: UsageTextPart[];
|
||||||
|
separator?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StatusbarRowConfig {
|
||||||
|
left?: ModuleSpec[];
|
||||||
|
center?: ModuleSpec[];
|
||||||
|
right?: ModuleSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusbarConfig {
|
||||||
|
separator: string;
|
||||||
|
rows: StatusbarRowConfig[];
|
||||||
|
modules: {
|
||||||
|
directory?: { showGitBranch?: boolean; showSessionName?: boolean };
|
||||||
|
context?: { showWindow?: boolean };
|
||||||
|
model?: { showProvider?: boolean; showThinking?: boolean };
|
||||||
|
thinking?: { hideWhenOff?: boolean };
|
||||||
|
cost?: { showSubscription?: boolean };
|
||||||
|
usage?: { showText?: boolean; parts?: UsageTextPart[]; separator?: string; colorBars?: boolean };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusbarState {
|
||||||
|
report?: UsageReport;
|
||||||
|
error?: string;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleContext {
|
||||||
|
ctx: any;
|
||||||
|
footerData: any;
|
||||||
|
state: StatusbarState;
|
||||||
|
config: StatusbarConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedModule {
|
||||||
|
text?: string;
|
||||||
|
grow?: number;
|
||||||
|
render?: (width: number, theme?: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageWindows {
|
||||||
|
current?: UsageLimit;
|
||||||
|
week?: UsageLimit;
|
||||||
|
}
|
||||||
98
usage.ts
Normal file
98
usage.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
export type Provider = "openai-codex" | "zai" | "anthropic";
|
||||||
|
|
||||||
|
export type UsageUnit = "percent" | "tokens" | "requests" | "usd" | "minutes" | "bytes" | "unknown";
|
||||||
|
|
||||||
|
export type UsageStatus = "ok" | "warning" | "exhausted" | "unknown";
|
||||||
|
|
||||||
|
export interface UsageWindow {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
durationMs?: number;
|
||||||
|
resetsAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageAmount {
|
||||||
|
used?: number;
|
||||||
|
limit?: number;
|
||||||
|
remaining?: number;
|
||||||
|
usedFraction?: number;
|
||||||
|
remainingFraction?: number;
|
||||||
|
unit: UsageUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageScope {
|
||||||
|
provider: Provider;
|
||||||
|
accountId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
orgId?: string;
|
||||||
|
modelId?: string;
|
||||||
|
tier?: string;
|
||||||
|
windowId?: string;
|
||||||
|
shared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageLimit {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
scope: UsageScope;
|
||||||
|
window?: UsageWindow;
|
||||||
|
amount: UsageAmount;
|
||||||
|
status?: UsageStatus;
|
||||||
|
notes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageReport {
|
||||||
|
provider: Provider;
|
||||||
|
fetchedAt: number;
|
||||||
|
limits: UsageLimit[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageLogger {
|
||||||
|
debug(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
warn(message: string, meta?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageCredential {
|
||||||
|
type: "api_key" | "oauth";
|
||||||
|
apiKey?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
accountId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
email?: string;
|
||||||
|
enterpriseUrl?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageFetchParams {
|
||||||
|
provider: Provider;
|
||||||
|
credential: UsageCredential;
|
||||||
|
baseUrl?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageFetchContext {
|
||||||
|
fetch: typeof fetch;
|
||||||
|
logger?: UsageLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageProvider {
|
||||||
|
id: Provider;
|
||||||
|
fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null>;
|
||||||
|
supports?(params: UsageFetchParams): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialRankingStrategy {
|
||||||
|
findWindowLimits(report: UsageReport): {
|
||||||
|
primary?: UsageLimit;
|
||||||
|
secondary?: UsageLimit;
|
||||||
|
};
|
||||||
|
windowDefaults: {
|
||||||
|
primaryMs: number;
|
||||||
|
secondaryMs: number;
|
||||||
|
};
|
||||||
|
hasPriorityBoost?(primary: UsageLimit | undefined): boolean;
|
||||||
|
}
|
||||||
337
usage/claude.ts
Normal file
337
usage/claude.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import type {
|
||||||
|
CredentialRankingStrategy,
|
||||||
|
UsageAmount,
|
||||||
|
UsageFetchContext,
|
||||||
|
UsageFetchParams,
|
||||||
|
UsageLimit,
|
||||||
|
UsageProvider,
|
||||||
|
UsageReport,
|
||||||
|
UsageStatus,
|
||||||
|
UsageWindow,
|
||||||
|
} from "../usage";
|
||||||
|
import { isRecord, toNumber } from "../utils";
|
||||||
|
|
||||||
|
const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
|
||||||
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
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",
|
||||||
|
} 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeUsageBucket {
|
||||||
|
utilization?: number;
|
||||||
|
resets_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedUsageBucket {
|
||||||
|
utilization?: number;
|
||||||
|
resetsAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeUsageResponse {
|
||||||
|
five_hour?: ClaudeUsageBucket | null;
|
||||||
|
seven_day?: ClaudeUsageBucket | null;
|
||||||
|
seven_day_opus?: ClaudeUsageBucket | null;
|
||||||
|
seven_day_sonnet?: ClaudeUsageBucket | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaudeUsagePayload = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hasUsageData(payload: ClaudeUsageResponse): boolean {
|
||||||
|
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,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MAX_RETRIES - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, BASE_RETRY_DELAY_MS * 2 ** attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeProfile {
|
||||||
|
account?: {
|
||||||
|
uuid?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfile(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEmail(
|
||||||
|
params: UsageFetchParams,
|
||||||
|
ctx: UsageFetchContext,
|
||||||
|
baseUrl: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (params.credential.email) return params.credential.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 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;
|
||||||
|
}): 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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));
|
||||||
|
|
||||||
|
const report: UsageReport = {
|
||||||
|
provider: params.provider,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
limits,
|
||||||
|
metadata: {
|
||||||
|
accountId,
|
||||||
|
email,
|
||||||
|
endpoint: url,
|
||||||
|
},
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claudeUsageProvider: UsageProvider = {
|
||||||
|
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 },
|
||||||
|
};
|
||||||
377
usage/openai-codex.ts
Normal file
377
usage/openai-codex.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { CODEX_BASE_URL } from "../providers/openai-codex/constants";
|
||||||
|
import type {
|
||||||
|
CredentialRankingStrategy,
|
||||||
|
UsageAmount,
|
||||||
|
UsageFetchContext,
|
||||||
|
UsageFetchParams,
|
||||||
|
UsageLimit,
|
||||||
|
UsageProvider,
|
||||||
|
UsageReport,
|
||||||
|
UsageWindow,
|
||||||
|
} from "../usage";
|
||||||
|
import { isRecord } from "../utils";
|
||||||
|
import { toNumber } from "./shared";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexUsageRateLimitPayload {
|
||||||
|
allowed?: boolean;
|
||||||
|
limit_reached?: boolean;
|
||||||
|
primary_window?: CodexUsageWindowPayload | null;
|
||||||
|
secondary_window?: CodexUsageWindowPayload | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexUsagePayload {
|
||||||
|
plan_type?: string;
|
||||||
|
rate_limit?: CodexUsageRateLimitPayload | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedUsageWindow {
|
||||||
|
usedPercent?: number;
|
||||||
|
limitWindowSeconds?: number;
|
||||||
|
resetAfterSeconds?: number;
|
||||||
|
resetAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedUsage {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBoolean = (value: unknown): boolean | 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(email: string | undefined): string | 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEmail(token: string | undefined): string | undefined {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexUsageUrl(baseUrl: string): string {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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") };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}): 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 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 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 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
10
usage/shared.ts
Normal file
10
usage/shared.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
247
usage/zai.ts
Normal file
247
usage/zai.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import type {
|
||||||
|
UsageAmount,
|
||||||
|
UsageFetchContext,
|
||||||
|
UsageFetchParams,
|
||||||
|
UsageLimit,
|
||||||
|
UsageProvider,
|
||||||
|
UsageReport,
|
||||||
|
UsageStatus,
|
||||||
|
UsageWindow,
|
||||||
|
} from "../usage";
|
||||||
|
import { isRecord, toNumber } from "../utils";
|
||||||
|
|
||||||
|
const DEFAULT_ENDPOINT = "https://api.z.ai";
|
||||||
|
const QUOTA_PATH = "/api/monitor/usage/quota/limit";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZaiUsageLimitItem {
|
||||||
|
type?: string;
|
||||||
|
usage?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
percentage?: number;
|
||||||
|
remaining?: number;
|
||||||
|
nextResetTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZaiQuotaPayload {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsageAmount(args: {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zaiUsageProvider: UsageProvider = {
|
||||||
|
id: "zai",
|
||||||
|
fetchUsage: fetchZaiUsage,
|
||||||
|
supports: params => params.provider === "zai" && params.credential.type === "api_key",
|
||||||
|
};
|
||||||
12
utils.ts
Normal file
12
utils.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user