added pi
This commit is contained in:
731
pi/.pi/agent/extensions/usage-bars/core.ts
Normal file
731
pi/.pi/agent/extensions/usage-bars/core.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared disk cache — lets multiple concurrent pi sessions coordinate so only
|
||||
// one actually hits the API per cache window, regardless of how many sessions
|
||||
// are open. Modelled after claude-pulse's cache.json approach.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageCache {
|
||||
timestamp: number;
|
||||
data: Partial<Record<ProviderKey, UsageData>>;
|
||||
/** ISO timestamp until which a provider is rate-limited (429 backoff). */
|
||||
rateLimitedUntil?: Partial<Record<ProviderKey, number>>;
|
||||
}
|
||||
|
||||
const USAGE_CACHE_FILE = path.join(os.homedir(), ".pi", "agent", "usage-cache.json");
|
||||
|
||||
export function readUsageCache(): UsageCache | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(USAGE_CACHE_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.timestamp === "number") return parsed as UsageCache;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeUsageCache(cache: UsageCache): void {
|
||||
try {
|
||||
const dir = path.dirname(USAGE_CACHE_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${USAGE_CACHE_FILE}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2));
|
||||
fs.renameSync(tmp, USAGE_CACHE_FILE);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export type ProviderKey = "codex" | "claude" | "zai" | "gemini" | "antigravity";
|
||||
export type OAuthProviderId = "openai-codex" | "anthropic" | "google-gemini-cli" | "google-antigravity";
|
||||
|
||||
export interface AuthData {
|
||||
"openai-codex"?: { access?: string; refresh?: string; expires?: number };
|
||||
anthropic?: { access?: string; refresh?: string; expires?: number };
|
||||
zai?: { key?: string; access?: string; refresh?: string; expires?: number };
|
||||
"google-gemini-cli"?: { access?: string; refresh?: string; projectId?: string; expires?: number };
|
||||
"google-antigravity"?: { access?: string; refresh?: string; projectId?: string; expires?: number };
|
||||
}
|
||||
|
||||
export interface UsageData {
|
||||
session: number;
|
||||
weekly: number;
|
||||
sessionResetsIn?: string;
|
||||
weeklyResetsIn?: string;
|
||||
extraSpend?: number;
|
||||
extraLimit?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type UsageByProvider = Record<ProviderKey, UsageData | null>;
|
||||
|
||||
export interface UsageEndpoints {
|
||||
zai: string;
|
||||
gemini: string;
|
||||
antigravity: string;
|
||||
googleLoadCodeAssistEndpoints: string[];
|
||||
}
|
||||
|
||||
export interface FetchResponseLike {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<any>;
|
||||
}
|
||||
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface RequestConfig {
|
||||
fetchFn?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface FetchConfig extends RequestConfig {
|
||||
endpoints?: UsageEndpoints;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface OAuthApiKeyResult {
|
||||
newCredentials: Record<string, any>;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export type OAuthApiKeyResolver = (
|
||||
providerId: OAuthProviderId,
|
||||
credentials: Record<string, Record<string, any>>,
|
||||
) => Promise<OAuthApiKeyResult | null>;
|
||||
|
||||
export interface EnsureFreshAuthConfig {
|
||||
auth?: AuthData | null;
|
||||
authFile?: string;
|
||||
oauthResolver?: OAuthApiKeyResolver;
|
||||
nowMs?: number;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface FreshAuthResult {
|
||||
auth: AuthData | null;
|
||||
changed: boolean;
|
||||
refreshErrors: Partial<Record<OAuthProviderId, string>>;
|
||||
}
|
||||
|
||||
export interface FetchAllUsagesConfig extends FetchConfig, EnsureFreshAuthConfig {
|
||||
auth?: AuthData | null;
|
||||
authFile?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 12_000;
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
export const DEFAULT_AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
|
||||
export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
|
||||
export const GOOGLE_QUOTA_ENDPOINT = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota";
|
||||
export const GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS = [
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist",
|
||||
];
|
||||
|
||||
export function resolveUsageEndpoints(): UsageEndpoints {
|
||||
return {
|
||||
zai: DEFAULT_ZAI_USAGE_ENDPOINT,
|
||||
gemini: GOOGLE_QUOTA_ENDPOINT,
|
||||
antigravity: GOOGLE_QUOTA_ENDPOINT,
|
||||
googleLoadCodeAssistEndpoints: GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS,
|
||||
};
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") return "request timeout";
|
||||
return error.message || String(error);
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, any> | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return value as Record<string, any>;
|
||||
}
|
||||
|
||||
function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } {
|
||||
const clean = (v: number) => {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Number(v.toFixed(2));
|
||||
};
|
||||
return { session: clean(session), weekly: clean(weekly) };
|
||||
}
|
||||
|
||||
async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<{ ok: true; data: any } | { ok: false; error: string }> {
|
||||
const fetchFn = config.fetchFn ?? ((fetch as unknown) as FetchLike);
|
||||
const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
const timeout = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, { ...init, signal: controller.signal });
|
||||
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
|
||||
|
||||
try {
|
||||
const data = await response.json();
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, error: "invalid JSON response" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: toErrorMessage(error) };
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "now";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0 && h > 0) return `${d}d ${h}h`;
|
||||
if (d > 0) return `${d}d`;
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h`;
|
||||
if (m > 0) return `${m}m`;
|
||||
return "<1m";
|
||||
}
|
||||
|
||||
export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
|
||||
const resetTime = new Date(isoDate).getTime();
|
||||
if (!Number.isFinite(resetTime)) return "";
|
||||
const diffSeconds = Math.max(0, (resetTime - nowMs) / 1000);
|
||||
return formatDuration(diffSeconds);
|
||||
}
|
||||
|
||||
export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||
return asObject(parsed) as AuthData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean {
|
||||
try {
|
||||
const dir = path.dirname(authFile);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const tmpPath = `${authFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(auth, null, 2));
|
||||
fs.renameSync(tmpPath, authFile);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedOAuthResolver: OAuthApiKeyResolver | null = null;
|
||||
|
||||
async function getDefaultOAuthResolver(): Promise<OAuthApiKeyResolver> {
|
||||
if (cachedOAuthResolver) return cachedOAuthResolver;
|
||||
|
||||
const mod = await import("@mariozechner/pi-ai");
|
||||
if (typeof (mod as any).getOAuthApiKey !== "function") {
|
||||
throw new Error("oauth resolver unavailable");
|
||||
}
|
||||
|
||||
cachedOAuthResolver = (providerId, credentials) =>
|
||||
(mod as any).getOAuthApiKey(providerId, credentials) as Promise<OAuthApiKeyResult | null>;
|
||||
|
||||
return cachedOAuthResolver;
|
||||
}
|
||||
|
||||
function isCredentialExpired(creds: { expires?: number } | undefined, nowMs: number): boolean {
|
||||
if (!creds) return false;
|
||||
if (typeof creds.expires !== "number") return false;
|
||||
return nowMs + TOKEN_REFRESH_SKEW_MS >= creds.expires;
|
||||
}
|
||||
|
||||
export async function ensureFreshAuthForProviders(
|
||||
providerIds: OAuthProviderId[],
|
||||
config: EnsureFreshAuthConfig = {},
|
||||
): Promise<FreshAuthResult> {
|
||||
const authFile = config.authFile ?? DEFAULT_AUTH_FILE;
|
||||
const auth = config.auth ?? readAuth(authFile);
|
||||
if (!auth) {
|
||||
return { auth: null, changed: false, refreshErrors: {} };
|
||||
}
|
||||
|
||||
const nowMs = config.nowMs ?? Date.now();
|
||||
const uniqueProviders = Array.from(new Set(providerIds));
|
||||
const nextAuth: AuthData = { ...auth };
|
||||
const refreshErrors: Partial<Record<OAuthProviderId, string>> = {};
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const providerId of uniqueProviders) {
|
||||
const creds = (nextAuth as any)[providerId] as { access?: string; refresh?: string; expires?: number } | undefined;
|
||||
if (!creds?.refresh) continue;
|
||||
|
||||
const needsRefresh = !creds.access || isCredentialExpired(creds, nowMs);
|
||||
if (!needsRefresh) continue;
|
||||
|
||||
try {
|
||||
const resolver = config.oauthResolver ?? (await getDefaultOAuthResolver());
|
||||
const resolved = await resolver(providerId, nextAuth as any);
|
||||
if (!resolved?.newCredentials) {
|
||||
refreshErrors[providerId] = "missing OAuth credentials";
|
||||
continue;
|
||||
}
|
||||
|
||||
(nextAuth as any)[providerId] = {
|
||||
...(nextAuth as any)[providerId],
|
||||
...resolved.newCredentials,
|
||||
};
|
||||
changed = true;
|
||||
} catch (error) {
|
||||
refreshErrors[providerId] = toErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && config.persist !== false) {
|
||||
writeAuth(nextAuth, authFile);
|
||||
}
|
||||
|
||||
return { auth: nextAuth, changed, refreshErrors };
|
||||
}
|
||||
|
||||
export function readPercentCandidate(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
|
||||
if (value >= 0 && value <= 1) {
|
||||
if (Number.isInteger(value)) return value;
|
||||
return value * 100;
|
||||
}
|
||||
|
||||
if (value >= 0 && value <= 100) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readLimitPercent(limit: any): number | null {
|
||||
const direct = [
|
||||
limit?.percentage,
|
||||
limit?.utilization,
|
||||
limit?.used_percent,
|
||||
limit?.usedPercent,
|
||||
limit?.usagePercent,
|
||||
limit?.usage_percent,
|
||||
]
|
||||
.map(readPercentCandidate)
|
||||
.find((v) => v != null);
|
||||
|
||||
if (direct != null) return direct;
|
||||
|
||||
const current = typeof limit?.currentValue === "number" ? limit.currentValue : null;
|
||||
const remaining = typeof limit?.remaining === "number" ? limit.remaining : null;
|
||||
|
||||
if (current != null && remaining != null && current + remaining > 0) {
|
||||
return (current / (current + remaining)) * 100;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractUsageFromPayload(data: any): { session: number; weekly: number } | null {
|
||||
const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
|
||||
const limits = limitArrays.find((arr) => Array.isArray(arr)) as any[] | undefined;
|
||||
|
||||
if (limits) {
|
||||
const byType = (types: string[]) =>
|
||||
limits.find((l) => {
|
||||
const t = String(l?.type || "").toUpperCase();
|
||||
return types.some((x) => t === x);
|
||||
});
|
||||
|
||||
const sessionLimit = byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]);
|
||||
const weeklyLimit = byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]);
|
||||
|
||||
const s = readLimitPercent(sessionLimit);
|
||||
const w = readLimitPercent(weeklyLimit);
|
||||
if (s != null && w != null) return normalizeUsagePair(s, w);
|
||||
}
|
||||
|
||||
const sessionCandidates = [
|
||||
data?.session,
|
||||
data?.sessionPercent,
|
||||
data?.session_percent,
|
||||
data?.five_hour?.utilization,
|
||||
data?.rate_limit?.primary_window?.used_percent,
|
||||
data?.limits?.session?.utilization,
|
||||
data?.usage?.session,
|
||||
data?.data?.session,
|
||||
data?.data?.sessionPercent,
|
||||
data?.data?.session_percent,
|
||||
data?.data?.usage?.session,
|
||||
data?.quota?.session?.percentage,
|
||||
data?.data?.quota?.session?.percentage,
|
||||
];
|
||||
|
||||
const weeklyCandidates = [
|
||||
data?.weekly,
|
||||
data?.weeklyPercent,
|
||||
data?.weekly_percent,
|
||||
data?.seven_day?.utilization,
|
||||
data?.rate_limit?.secondary_window?.used_percent,
|
||||
data?.limits?.weekly?.utilization,
|
||||
data?.usage?.weekly,
|
||||
data?.data?.weekly,
|
||||
data?.data?.weeklyPercent,
|
||||
data?.data?.weekly_percent,
|
||||
data?.data?.usage?.weekly,
|
||||
data?.quota?.weekly?.percentage,
|
||||
data?.data?.quota?.weekly?.percentage,
|
||||
data?.quota?.daily?.percentage,
|
||||
data?.data?.quota?.daily?.percentage,
|
||||
];
|
||||
|
||||
const session = sessionCandidates.map(readPercentCandidate).find((v) => v != null);
|
||||
const weekly = weeklyCandidates.map(readPercentCandidate).find((v) => v != null);
|
||||
|
||||
if (session == null || weekly == null) return null;
|
||||
return normalizeUsagePair(session, weekly);
|
||||
}
|
||||
|
||||
export function googleMetadata(projectId?: string) {
|
||||
return {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
...(projectId ? { duetProject: projectId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function googleHeaders(token: string, projectId?: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"X-Goog-Api-Client": "gl-node/22.17.0",
|
||||
"Client-Metadata": JSON.stringify(googleMetadata(projectId)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverGoogleProjectId(token: string, config: FetchConfig = {}): Promise<string | undefined> {
|
||||
const env = config.env ?? process.env;
|
||||
const envProjectId = env.GOOGLE_CLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT_ID;
|
||||
if (envProjectId) return envProjectId;
|
||||
|
||||
const endpoints = config.endpoints ?? resolveUsageEndpoints();
|
||||
|
||||
for (const endpoint of endpoints.googleLoadCodeAssistEndpoints) {
|
||||
const result = await requestJson(
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: googleHeaders(token),
|
||||
body: JSON.stringify({ metadata: googleMetadata() }),
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) continue;
|
||||
|
||||
const data = result.data;
|
||||
if (typeof data?.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (data?.cloudaicompanionProject && typeof data.cloudaicompanionProject === "object") {
|
||||
const id = data.cloudaicompanionProject.id;
|
||||
if (typeof id === "string" && id) return id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function usedPercentFromRemainingFraction(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
const remaining = Math.max(0, Math.min(1, value));
|
||||
return (1 - remaining) * 100;
|
||||
}
|
||||
|
||||
export function pickMostUsedBucket(buckets: any[]): any | null {
|
||||
let best: any | null = null;
|
||||
let bestUsed = -1;
|
||||
for (const bucket of buckets) {
|
||||
const used = usedPercentFromRemainingFraction(bucket?.remainingFraction);
|
||||
if (used == null) continue;
|
||||
if (used > bestUsed) {
|
||||
bestUsed = used;
|
||||
best = bucket;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseGoogleQuotaBuckets(data: any, provider: "gemini" | "antigravity"): { session: number; weekly: number } | null {
|
||||
const allBuckets = Array.isArray(data?.buckets) ? data.buckets : [];
|
||||
if (!allBuckets.length) return null;
|
||||
|
||||
const requestBuckets = allBuckets.filter((b: any) => String(b?.tokenType || "").toUpperCase() === "REQUESTS");
|
||||
const buckets = requestBuckets.length ? requestBuckets : allBuckets;
|
||||
|
||||
const modelId = (b: any) => String(b?.modelId || "").toLowerCase();
|
||||
const claudeNonThinking = buckets.filter((b: any) => modelId(b).includes("claude") && !modelId(b).includes("thinking"));
|
||||
const geminiPro = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("pro"));
|
||||
const geminiFlash = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("flash"));
|
||||
|
||||
const primaryBucket =
|
||||
provider === "antigravity"
|
||||
? pickMostUsedBucket(claudeNonThinking) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets)
|
||||
: pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets);
|
||||
|
||||
const secondaryBucket = pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(buckets);
|
||||
|
||||
const session = usedPercentFromRemainingFraction(primaryBucket?.remainingFraction);
|
||||
const weekly = usedPercentFromRemainingFraction(secondaryBucket?.remainingFraction);
|
||||
|
||||
if (session == null || weekly == null) return null;
|
||||
return normalizeUsagePair(session, weekly);
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
|
||||
const result = await requestJson(
|
||||
"https://chatgpt.com/backend-api/wham/usage",
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const primary = result.data?.rate_limit?.primary_window;
|
||||
const secondary = result.data?.rate_limit?.secondary_window;
|
||||
|
||||
return {
|
||||
session: readPercentCandidate(primary?.used_percent) ?? 0,
|
||||
weekly: readPercentCandidate(secondary?.used_percent) ?? 0,
|
||||
sessionResetsIn: typeof primary?.reset_after_seconds === "number" ? formatDuration(primary.reset_after_seconds) : undefined,
|
||||
weeklyResetsIn: typeof secondary?.reset_after_seconds === "number" ? formatDuration(secondary.reset_after_seconds) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
|
||||
const result = await requestJson(
|
||||
"https://api.anthropic.com/api/oauth/usage",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const data = result.data;
|
||||
const usage: UsageData = {
|
||||
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
|
||||
weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
|
||||
sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
|
||||
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined,
|
||||
};
|
||||
|
||||
if (data?.extra_usage?.is_enabled) {
|
||||
usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined;
|
||||
usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined;
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
export async function fetchZaiUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
|
||||
const endpoint = (config.endpoints ?? resolveUsageEndpoints()).zai;
|
||||
if (!endpoint) return { session: 0, weekly: 0, error: "usage endpoint unavailable" };
|
||||
|
||||
const result = await requestJson(
|
||||
endpoint,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const parsed = extractUsageFromPayload(result.data);
|
||||
if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function fetchGoogleUsage(
|
||||
token: string,
|
||||
endpoint: string,
|
||||
projectId: string | undefined,
|
||||
provider: "gemini" | "antigravity",
|
||||
config: FetchConfig = {},
|
||||
): Promise<UsageData> {
|
||||
if (!endpoint) return { session: 0, weekly: 0, error: "configure endpoint" };
|
||||
|
||||
const discoveredProjectId = projectId || (await discoverGoogleProjectId(token, config));
|
||||
if (!discoveredProjectId) {
|
||||
return { session: 0, weekly: 0, error: "missing projectId (try /login again)" };
|
||||
}
|
||||
|
||||
const result = await requestJson(
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
headers: googleHeaders(token, discoveredProjectId),
|
||||
body: JSON.stringify({ project: discoveredProjectId }),
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||
|
||||
const quota = parseGoogleQuotaBuckets(result.data, provider);
|
||||
if (quota) return quota;
|
||||
|
||||
const parsed = extractUsageFromPayload(result.data);
|
||||
if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function detectProvider(
|
||||
model: { provider?: string; id?: string; name?: string; api?: string } | string | undefined | null,
|
||||
): ProviderKey | null {
|
||||
if (!model) return null;
|
||||
if (typeof model === "string") return null;
|
||||
|
||||
const provider = (model.provider || "").toLowerCase();
|
||||
|
||||
if (provider === "openai-codex") return "codex";
|
||||
if (provider === "anthropic") return "claude";
|
||||
if (provider === "zai") return "zai";
|
||||
if (provider === "google-gemini-cli") return "gemini";
|
||||
if (provider === "google-antigravity") return "antigravity";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function providerToOAuthProviderId(active: ProviderKey | null): OAuthProviderId | null {
|
||||
if (active === "codex") return "openai-codex";
|
||||
if (active === "claude") return "anthropic";
|
||||
if (active === "gemini") return "google-gemini-cli";
|
||||
if (active === "antigravity") return "google-antigravity";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canShowForProvider(active: ProviderKey | null, auth: AuthData | null, endpoints: UsageEndpoints): boolean {
|
||||
if (!active || !auth) return false;
|
||||
if (active === "codex") return !!(auth["openai-codex"]?.access || auth["openai-codex"]?.refresh);
|
||||
if (active === "claude") return !!(auth.anthropic?.access || auth.anthropic?.refresh);
|
||||
if (active === "zai") return !!(auth.zai?.access || auth.zai?.key) && !!endpoints.zai;
|
||||
if (active === "gemini") {
|
||||
return !!(auth["google-gemini-cli"]?.access || auth["google-gemini-cli"]?.refresh) && !!endpoints.gemini;
|
||||
}
|
||||
if (active === "antigravity") {
|
||||
return !!(auth["google-antigravity"]?.access || auth["google-antigravity"]?.refresh) && !!endpoints.antigravity;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function colorForPercent(value: number): "success" | "warning" | "error" {
|
||||
if (value >= 90) return "error";
|
||||
if (value >= 70) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
export async function fetchAllUsages(config: FetchAllUsagesConfig = {}): Promise<UsageByProvider> {
|
||||
const authFile = config.authFile ?? DEFAULT_AUTH_FILE;
|
||||
const auth = config.auth ?? readAuth(authFile);
|
||||
const endpoints = config.endpoints ?? resolveUsageEndpoints();
|
||||
|
||||
const results: UsageByProvider = {
|
||||
codex: null,
|
||||
claude: null,
|
||||
zai: null,
|
||||
gemini: null,
|
||||
antigravity: null,
|
||||
};
|
||||
|
||||
if (!auth) return results;
|
||||
|
||||
const oauthProviders: OAuthProviderId[] = [
|
||||
"openai-codex",
|
||||
"anthropic",
|
||||
"google-gemini-cli",
|
||||
"google-antigravity",
|
||||
];
|
||||
|
||||
const refreshed = await ensureFreshAuthForProviders(oauthProviders, {
|
||||
...config,
|
||||
auth,
|
||||
authFile,
|
||||
});
|
||||
|
||||
const authData = refreshed.auth ?? auth;
|
||||
|
||||
const refreshError = (providerId: OAuthProviderId): string | null => {
|
||||
const error = refreshed.refreshErrors[providerId];
|
||||
return error ? `auth refresh failed (${error})` : null;
|
||||
};
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
const assign = (provider: ProviderKey, task: Promise<UsageData>) => {
|
||||
tasks.push(
|
||||
task
|
||||
.then((usage) => {
|
||||
results[provider] = usage;
|
||||
})
|
||||
.catch((error) => {
|
||||
results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error) };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (authData["openai-codex"]?.access) {
|
||||
const err = refreshError("openai-codex");
|
||||
if (err) results.codex = { session: 0, weekly: 0, error: err };
|
||||
else assign("codex", fetchCodexUsage(authData["openai-codex"].access, config));
|
||||
}
|
||||
|
||||
if (authData.anthropic?.access) {
|
||||
const err = refreshError("anthropic");
|
||||
if (err) results.claude = { session: 0, weekly: 0, error: err };
|
||||
else assign("claude", fetchClaudeUsage(authData.anthropic.access, config));
|
||||
}
|
||||
|
||||
if (authData.zai?.access || authData.zai?.key) {
|
||||
assign("zai", fetchZaiUsage(authData.zai.access || authData.zai.key!, { ...config, endpoints }));
|
||||
}
|
||||
|
||||
if (authData["google-gemini-cli"]?.access) {
|
||||
const err = refreshError("google-gemini-cli");
|
||||
if (err) {
|
||||
results.gemini = { session: 0, weekly: 0, error: err };
|
||||
} else {
|
||||
const creds = authData["google-gemini-cli"];
|
||||
assign(
|
||||
"gemini",
|
||||
fetchGoogleUsage(creds.access!, endpoints.gemini, creds.projectId, "gemini", { ...config, endpoints }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (authData["google-antigravity"]?.access) {
|
||||
const err = refreshError("google-antigravity");
|
||||
if (err) {
|
||||
results.antigravity = { session: 0, weekly: 0, error: err };
|
||||
} else {
|
||||
const creds = authData["google-antigravity"];
|
||||
assign(
|
||||
"antigravity",
|
||||
fetchGoogleUsage(creds.access!, endpoints.antigravity, creds.projectId, "antigravity", { ...config, endpoints }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
return results;
|
||||
}
|
||||
576
pi/.pi/agent/extensions/usage-bars/index.ts
Normal file
576
pi/.pi/agent/extensions/usage-bars/index.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Usage Extension - Minimal API usage indicator for pi
|
||||
*
|
||||
* Shows Codex (OpenAI), Anthropic (Claude), Z.AI, and optionally
|
||||
* Google Gemini CLI / Antigravity usage as color-coded percentages
|
||||
* in the footer status bar.
|
||||
*/
|
||||
|
||||
import { DynamicBorder, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Input,
|
||||
Spacer,
|
||||
Text,
|
||||
getEditorKeybindings,
|
||||
type Focusable,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import {
|
||||
canShowForProvider,
|
||||
clampPercent,
|
||||
colorForPercent,
|
||||
detectProvider,
|
||||
fetchAllUsages,
|
||||
fetchClaudeUsage,
|
||||
fetchCodexUsage,
|
||||
fetchGoogleUsage,
|
||||
fetchZaiUsage,
|
||||
providerToOAuthProviderId,
|
||||
readAuth,
|
||||
readUsageCache,
|
||||
resolveUsageEndpoints,
|
||||
writeUsageCache,
|
||||
type ProviderKey,
|
||||
type UsageByProvider,
|
||||
type UsageData,
|
||||
} from "./core";
|
||||
|
||||
const CACHE_TTL_MS = 15 * 60 * 1000; // reuse cached data for 15 min
|
||||
const RATE_LIMITED_BACKOFF_MS = 60 * 60 * 1000; // 1 hour back-off after 429
|
||||
const STATUS_KEY = "usage-bars";
|
||||
|
||||
const PROVIDER_LABELS: Record<ProviderKey, string> = {
|
||||
codex: "Codex",
|
||||
claude: "Claude",
|
||||
zai: "Z.AI",
|
||||
gemini: "Gemini",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
|
||||
interface SubscriptionItem {
|
||||
name: string;
|
||||
provider: ProviderKey;
|
||||
data: UsageData | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
class UsageSelectorComponent extends Container implements Focusable {
|
||||
private searchInput: Input;
|
||||
private listContainer: Container;
|
||||
private hintText: Text;
|
||||
private tui: any;
|
||||
private theme: any;
|
||||
private onCancelCallback: () => void;
|
||||
private allItems: SubscriptionItem[] = [];
|
||||
private filteredItems: SubscriptionItem[] = [];
|
||||
private selectedIndex = 0;
|
||||
private loading = true;
|
||||
private activeProvider: ProviderKey | null;
|
||||
private fetchAllFn: () => Promise<UsageByProvider>;
|
||||
private _focused = false;
|
||||
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.searchInput.focused = value;
|
||||
}
|
||||
|
||||
constructor(
|
||||
tui: any,
|
||||
theme: any,
|
||||
activeProvider: ProviderKey | null,
|
||||
fetchAll: () => Promise<UsageByProvider>,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
this.tui = tui;
|
||||
this.theme = theme;
|
||||
this.activeProvider = activeProvider;
|
||||
this.fetchAllFn = fetchAll;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.hintText = new Text(theme.fg("dim", "Fetching usage from all providers…"), 0, 0);
|
||||
this.addChild(this.hintText);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.searchInput = new Input();
|
||||
this.addChild(this.searchInput);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
this.fetchAllFn()
|
||||
.then((results) => {
|
||||
this.loading = false;
|
||||
this.buildItems(results);
|
||||
this.updateList();
|
||||
this.hintText.setText(
|
||||
theme.fg("muted", "Only showing providers with credentials. ") +
|
||||
theme.fg("dim", "✓ = active provider"),
|
||||
);
|
||||
this.tui.requestRender();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
this.hintText.setText(theme.fg("error", "Failed to fetch usage data"));
|
||||
this.tui.requestRender();
|
||||
});
|
||||
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private buildItems(results: UsageByProvider) {
|
||||
const providers: Array<{ key: ProviderKey; name: string }> = [
|
||||
{ key: "codex", name: "Codex" },
|
||||
{ key: "claude", name: "Claude" },
|
||||
{ key: "zai", name: "Z.AI" },
|
||||
{ key: "gemini", name: "Gemini" },
|
||||
{ key: "antigravity", name: "Antigravity" },
|
||||
];
|
||||
|
||||
this.allItems = [];
|
||||
for (const p of providers) {
|
||||
if (results[p.key] !== null) {
|
||||
this.allItems.push({
|
||||
name: p.name,
|
||||
provider: p.key,
|
||||
data: results[p.key],
|
||||
isActive: this.activeProvider === p.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.filteredItems = this.allItems;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||
}
|
||||
|
||||
private filterItems(query: string) {
|
||||
if (!query) {
|
||||
this.filteredItems = this.allItems;
|
||||
} else {
|
||||
const q = query.toLowerCase();
|
||||
this.filteredItems = this.allItems.filter(
|
||||
(item) => item.name.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||
}
|
||||
|
||||
private renderBar(pct: number, width = 16): string {
|
||||
const value = clampPercent(pct);
|
||||
const filled = Math.round((value / 100) * width);
|
||||
const color = colorForPercent(value);
|
||||
const full = "█".repeat(Math.max(0, filled));
|
||||
const empty = "░".repeat(Math.max(0, width - filled));
|
||||
return this.theme.fg(color, full) + this.theme.fg("dim", empty);
|
||||
}
|
||||
|
||||
private renderItem(item: SubscriptionItem, isSelected: boolean) {
|
||||
const t = this.theme;
|
||||
const pointer = isSelected ? t.fg("accent", "→ ") : " ";
|
||||
const activeBadge = item.isActive ? t.fg("success", " ✓") : "";
|
||||
const name = isSelected ? t.fg("accent", t.bold(item.name)) : item.name;
|
||||
|
||||
this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
|
||||
|
||||
const indent = " ";
|
||||
|
||||
if (!item.data) {
|
||||
this.listContainer.addChild(new Text(indent + t.fg("dim", "No credentials"), 0, 0));
|
||||
} else if (item.data.error) {
|
||||
this.listContainer.addChild(new Text(indent + t.fg("error", item.data.error), 0, 0));
|
||||
} else {
|
||||
const session = clampPercent(item.data.session);
|
||||
const weekly = clampPercent(item.data.weekly);
|
||||
|
||||
const sessionReset = item.data.sessionResetsIn
|
||||
? t.fg("dim", ` resets in ${item.data.sessionResetsIn}`)
|
||||
: "";
|
||||
const weeklyReset = item.data.weeklyResetsIn
|
||||
? t.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
|
||||
: "";
|
||||
|
||||
this.listContainer.addChild(
|
||||
new Text(
|
||||
indent +
|
||||
t.fg("muted", "Session ") +
|
||||
this.renderBar(session) +
|
||||
" " +
|
||||
t.fg(colorForPercent(session), `${session}%`.padStart(4)) +
|
||||
sessionReset,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
this.listContainer.addChild(
|
||||
new Text(
|
||||
indent +
|
||||
t.fg("muted", "Weekly ") +
|
||||
this.renderBar(weekly) +
|
||||
" " +
|
||||
t.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) +
|
||||
weeklyReset,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") {
|
||||
this.listContainer.addChild(
|
||||
new Text(
|
||||
indent +
|
||||
t.fg("muted", "Extra ") +
|
||||
t.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
private updateList() {
|
||||
this.listContainer.clear();
|
||||
|
||||
if (this.loading) {
|
||||
this.listContainer.addChild(new Text(this.theme.fg("muted", " Loading…"), 0, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.filteredItems.length === 0) {
|
||||
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching providers"), 0, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.filteredItems.length; i++) {
|
||||
this.renderItem(this.filteredItems[i]!, i === this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
|
||||
if (kb.matches(keyData, "selectUp")) {
|
||||
if (this.filteredItems.length === 0) return;
|
||||
this.selectedIndex =
|
||||
this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
|
||||
this.updateList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(keyData, "selectDown")) {
|
||||
if (this.filteredItems.length === 0) return;
|
||||
this.selectedIndex =
|
||||
this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
this.updateList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(keyData, "selectCancel") || kb.matches(keyData, "selectConfirm")) {
|
||||
this.onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchInput.handleInput(keyData);
|
||||
this.filterItems(this.searchInput.getValue());
|
||||
this.updateList();
|
||||
}
|
||||
}
|
||||
|
||||
interface UsageState extends UsageByProvider {
|
||||
lastPoll: number;
|
||||
activeProvider: ProviderKey | null;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const endpoints = resolveUsageEndpoints();
|
||||
const state: UsageState = {
|
||||
codex: null,
|
||||
claude: null,
|
||||
zai: null,
|
||||
gemini: null,
|
||||
antigravity: null,
|
||||
lastPoll: 0,
|
||||
activeProvider: null,
|
||||
};
|
||||
|
||||
let pollInFlight: Promise<void> | null = null;
|
||||
let pollQueued = false;
|
||||
let ctx: any = null;
|
||||
|
||||
function renderPercent(theme: any, value: number): string {
|
||||
const v = clampPercent(value);
|
||||
return theme.fg(colorForPercent(v), `${v}%`);
|
||||
}
|
||||
|
||||
function renderBar(theme: any, value: number): string {
|
||||
const v = clampPercent(value);
|
||||
const width = 8;
|
||||
const filled = Math.round((v / 100) * width);
|
||||
const full = "█".repeat(Math.max(0, Math.min(width, filled)));
|
||||
const empty = "░".repeat(Math.max(0, width - filled));
|
||||
return theme.fg(colorForPercent(v), full) + theme.fg("dim", empty);
|
||||
}
|
||||
|
||||
function pickDataForProvider(provider: ProviderKey | null): UsageData | null {
|
||||
if (!provider) return null;
|
||||
return state[provider];
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const active = state.activeProvider;
|
||||
const data = pickDataForProvider(active);
|
||||
|
||||
if (data && !data.error) {
|
||||
pi.events.emit("usage:update", {
|
||||
session: data.session,
|
||||
weekly: data.weekly,
|
||||
sessionResetsIn: data.sessionResetsIn,
|
||||
weeklyResetsIn: data.weeklyResetsIn,
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx?.hasUI) return;
|
||||
|
||||
if (!active) {
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = readAuth();
|
||||
if (!canShowForProvider(active, auth, endpoints)) {
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const theme = ctx.ui.theme;
|
||||
const label = PROVIDER_LABELS[active];
|
||||
|
||||
if (!data) {
|
||||
ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `${label} usage: loading…`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const cache = readUsageCache();
|
||||
const blockedUntil = active ? (cache?.rateLimitedUntil?.[active] ?? 0) : 0;
|
||||
const backoffNote = blockedUntil > Date.now()
|
||||
? ` — retry in ${Math.ceil((blockedUntil - Date.now()) / 60000)}m`
|
||||
: "";
|
||||
ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error}${backoffNote})`));
|
||||
return;
|
||||
}
|
||||
|
||||
const session = clampPercent(data.session);
|
||||
const weekly = clampPercent(data.weekly);
|
||||
|
||||
const sessionReset = data.sessionResetsIn ? theme.fg("dim", ` ⟳ ${data.sessionResetsIn}`) : "";
|
||||
const weeklyReset = data.weeklyResetsIn ? theme.fg("dim", ` ⟳ ${data.weeklyResetsIn}`) : "";
|
||||
|
||||
const status =
|
||||
theme.fg("dim", `${label} `) +
|
||||
theme.fg("muted", "S ") +
|
||||
renderBar(theme, session) +
|
||||
" " +
|
||||
renderPercent(theme, session) +
|
||||
sessionReset +
|
||||
theme.fg("muted", " W ") +
|
||||
renderBar(theme, weekly) +
|
||||
" " +
|
||||
renderPercent(theme, weekly) +
|
||||
weeklyReset;
|
||||
|
||||
ctx.ui.setStatus(STATUS_KEY, status);
|
||||
}
|
||||
|
||||
function updateProviderFrom(modelLike: any): boolean {
|
||||
const previous = state.activeProvider;
|
||||
state.activeProvider = detectProvider(modelLike);
|
||||
|
||||
if (previous !== state.activeProvider) {
|
||||
updateStatus();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runPoll() {
|
||||
const auth = readAuth();
|
||||
const active = state.activeProvider;
|
||||
|
||||
if (!canShowForProvider(active, auth, endpoints) || !auth || !active) {
|
||||
state.lastPoll = Date.now();
|
||||
updateStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Shared disk cache check ---
|
||||
// All pi sessions read and write the same cache file so that only one
|
||||
// process hits the API per CACHE_TTL_MS window, no matter how many
|
||||
// sessions are open at once.
|
||||
const cache = readUsageCache();
|
||||
const now = Date.now();
|
||||
|
||||
// Respect cross-session rate-limit back-off written by any session.
|
||||
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
|
||||
if (now < blockedUntil) {
|
||||
// Use whatever data is in the cache so the bar still shows something.
|
||||
if (cache?.data?.[active]) state[active] = cache.data[active]!;
|
||||
state.lastPoll = now;
|
||||
updateStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// If another session already polled recently, use their result.
|
||||
if (cache && now - cache.timestamp < CACHE_TTL_MS && cache.data?.[active]) {
|
||||
state[active] = cache.data[active]!;
|
||||
state.lastPoll = now;
|
||||
updateStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Actually hit the API ---
|
||||
// Skip independent token refresh — pi manages OAuth tokens and refreshes
|
||||
// them in memory. A parallel refresh here would cause token rotation
|
||||
// conflicts (Anthropic invalidates the old refresh token on use).
|
||||
let result: UsageData;
|
||||
|
||||
if (active === "codex") {
|
||||
const access = auth["openai-codex"]?.access;
|
||||
result = access
|
||||
? await fetchCodexUsage(access)
|
||||
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
|
||||
} else if (active === "claude") {
|
||||
const access = auth.anthropic?.access;
|
||||
result = access
|
||||
? await fetchClaudeUsage(access)
|
||||
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
|
||||
} else if (active === "zai") {
|
||||
const token = auth.zai?.access || auth.zai?.key;
|
||||
result = token
|
||||
? await fetchZaiUsage(token, { endpoints })
|
||||
: { session: 0, weekly: 0, error: "missing token (try /login again)" };
|
||||
} else if (active === "gemini") {
|
||||
const creds = auth["google-gemini-cli"];
|
||||
result = creds?.access
|
||||
? await fetchGoogleUsage(creds.access, endpoints.gemini, creds.projectId, "gemini", { endpoints })
|
||||
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
|
||||
} else {
|
||||
const creds = auth["google-antigravity"];
|
||||
result = creds?.access
|
||||
? await fetchGoogleUsage(creds.access, endpoints.antigravity, creds.projectId, "antigravity", { endpoints })
|
||||
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
|
||||
}
|
||||
|
||||
state[active] = result;
|
||||
|
||||
// Write result + rate-limit state to shared cache so other sessions
|
||||
// (and our own next timer tick) don't need to re-hit the API.
|
||||
const nextCache: import("./core").UsageCache = {
|
||||
timestamp: now,
|
||||
data: { ...(cache?.data ?? {}), [active]: result },
|
||||
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) },
|
||||
};
|
||||
if (result.error === "HTTP 429") {
|
||||
nextCache.rateLimitedUntil![active] = now + RATE_LIMITED_BACKOFF_MS;
|
||||
} else {
|
||||
delete nextCache.rateLimitedUntil![active];
|
||||
}
|
||||
writeUsageCache(nextCache);
|
||||
|
||||
state.lastPoll = now;
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (pollInFlight) {
|
||||
pollQueued = true;
|
||||
await pollInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
do {
|
||||
pollQueued = false;
|
||||
pollInFlight = runPoll()
|
||||
.catch(() => {
|
||||
// Never crash extension event handlers on transient polling errors.
|
||||
})
|
||||
.finally(() => {
|
||||
pollInFlight = null;
|
||||
});
|
||||
|
||||
await pollInFlight;
|
||||
} while (pollQueued);
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, _ctx) => {
|
||||
ctx = _ctx;
|
||||
updateProviderFrom(_ctx.model);
|
||||
await poll();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (_event, _ctx) => {
|
||||
if (_ctx?.hasUI) {
|
||||
_ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh usage on every turn (like claude-pulse's UserPromptSubmit hook).
|
||||
// The disk cache means the API is only hit at most once per CACHE_TTL_MS
|
||||
// regardless of how many turns or sessions are active.
|
||||
pi.on("turn_start", async (_event, _ctx) => {
|
||||
ctx = _ctx;
|
||||
updateProviderFrom(_ctx.model);
|
||||
await poll();
|
||||
});
|
||||
|
||||
pi.on("model_select", async (event, _ctx) => {
|
||||
ctx = _ctx;
|
||||
const changed = updateProviderFrom(event.model ?? _ctx.model);
|
||||
if (changed) await poll();
|
||||
});
|
||||
|
||||
pi.registerCommand("usage", {
|
||||
description: "Show API usage for all subscriptions",
|
||||
handler: async (_args, _ctx) => {
|
||||
ctx = _ctx;
|
||||
updateProviderFrom(_ctx.model);
|
||||
|
||||
try {
|
||||
if (_ctx?.hasUI) {
|
||||
await _ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
||||
const selector = new UsageSelectorComponent(
|
||||
tui,
|
||||
theme,
|
||||
state.activeProvider,
|
||||
() => fetchAllUsages({ endpoints }),
|
||||
() => done(),
|
||||
);
|
||||
return selector;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await poll();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Re-poll immediately when the Claude account is switched via /switch-claude
|
||||
pi.events.on("claude-account:switched", () => {
|
||||
void poll();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user