feat(ai): added comprehensive usage tracking system for multiple AI providers

- Added comprehensive usage tracking system with normalized schema for provider quota/limit endpoints.
- Added usage providers for Claude, GitHub Copilot, Google Antigravity, Google Gemini CLI, OpenAI Codex, and ZAI.
- Added `/usage` command to display provider usage and limits with visual progress bars and account aggregation.
- Refactored usage tracking from Codex-specific to generic provider system with persistent caching.
- Fixed API validation errors when sending empty user messages across multiple providers.
- Clamped OpenAI Codex reasoning effort "minimal" to "low" for gpt-5.2 models.
This commit is contained in:
can1357
2026-01-21 21:56:31 +01:00
parent 4d89571679
commit 5eb3a29665
19 changed files with 2925 additions and 225 deletions
+16 -5
View File
@@ -1,14 +1,25 @@
# Changelog
## [Unreleased]
### Added
- Added usage tracking system with normalized schema for provider quota/limit endpoints
- Added Claude usage provider for 5-hour and 7-day quota windows
- Added GitHub Copilot usage provider for chat, completions, and premium requests
- Added Google Antigravity usage provider for model quota tracking
- Added Google Gemini CLI usage provider for tier-based quota monitoring
- Added OpenAI Codex usage provider for primary and secondary rate limit windows
- Added ZAI usage provider for token and request quota tracking
### Fixed
- Fixed API validation errors when sending empty user messages (resume with `.`) across all providers:
- Google Cloud Code Assist (google-shared.ts)
- OpenAI Responses API (openai-responses.ts)
- OpenAI Codex Responses API (openai-codex-responses.ts)
- Cursor (cursor.ts)
- Amazon Bedrock (amazon-bedrock.ts)
- Google Cloud Code Assist (google-shared.ts)
- OpenAI Responses API (openai-responses.ts)
- OpenAI Codex Responses API (openai-codex-responses.ts)
- Cursor (cursor.ts)
- Amazon Bedrock (amazon-bedrock.ts)
- Clamped OpenAI Codex reasoning effort "minimal" to "low" for gpt-5.2 models to avoid API errors
## [6.9.69] - 2026-01-21
+7
View File
@@ -5,11 +5,18 @@ export * from "./providers/anthropic";
export * from "./providers/cursor";
export * from "./providers/google";
export * from "./providers/google-gemini-cli";
export * from "./providers/google-gemini-cli-usage";
export * from "./providers/google-vertex";
export * from "./providers/openai-completions";
export * from "./providers/openai-responses";
export * from "./stream";
export * from "./types";
export * from "./usage";
export * from "./usage/claude";
export * from "./usage/github-copilot";
export * from "./usage/google-antigravity";
export * from "./usage/openai-codex";
export * from "./usage/zai";
export * from "./utils/event-stream";
export * from "./utils/oauth/index";
export * from "./utils/overflow";
@@ -0,0 +1,261 @@
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageWindow,
} from "../usage";
import { refreshGoogleCloudToken } from "../utils/oauth/google-gemini-cli";
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const CACHE_TTL_MS = 60_000;
const GEMINI_CLI_HEADERS = {
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
const GEMINI_TIER_MAP: Array<{ tier: string; models: string[] }> = [
{
tier: "3-Flash",
models: ["gemini-3-flash-preview", "gemini-3-flash"],
},
{
tier: "Flash",
models: ["gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.0-flash", "gemini-1.5-flash"],
},
{
tier: "Pro",
models: ["gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3-pro", "gemini-1.5-pro"],
},
];
interface LoadCodeAssistResponse {
cloudaicompanionProject?: string | { id?: string };
currentTier?: { id?: string; name?: string };
}
interface RetrieveUserQuotaResponse {
buckets?: Array<{
modelId?: string;
remainingFraction?: number;
resetTime?: string;
}>;
}
function getProjectId(payload: LoadCodeAssistResponse | undefined): string | undefined {
if (!payload) return undefined;
if (typeof payload.cloudaicompanionProject === "string") {
return payload.cloudaicompanionProject;
}
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === "object") {
return payload.cloudaicompanionProject.id;
}
return undefined;
}
function getModelTier(modelId: string): string | undefined {
for (const entry of GEMINI_TIER_MAP) {
if (entry.models.includes(modelId)) {
return entry.tier;
}
}
const normalized = modelId.toLowerCase();
if (normalized.includes("flash")) return "Flash";
if (normalized.includes("pro")) return "Pro";
return undefined;
}
function parseWindow(resetTime: string | undefined, now: number): UsageWindow | undefined {
if (!resetTime) return undefined;
const resetsAt = Date.parse(resetTime);
if (Number.isNaN(resetsAt)) return undefined;
return {
id: `reset-${resetsAt}`,
label: "Quota window",
resetsAt,
resetInMs: Math.max(0, resetsAt - now),
};
}
function buildAmount(remainingFraction: number | undefined): UsageAmount {
if (remainingFraction === undefined || !Number.isFinite(remainingFraction)) {
return { unit: "percent" };
}
const remaining = Math.min(Math.max(remainingFraction, 0), 1);
const used = Math.min(Math.max(1 - remaining, 0), 1);
return {
unit: "percent",
used: Math.round(used * 1000) / 10,
remaining: Math.round(remaining * 1000) / 10,
limit: 100,
usedFraction: used,
remainingFraction: remaining,
};
}
async function resolveAccessToken(params: UsageFetchParams, ctx: UsageFetchContext): Promise<string | undefined> {
const { credential } = params;
if (credential.type !== "oauth") return undefined;
const now = ctx.now();
if (credential.accessToken && (!credential.expiresAt || credential.expiresAt > now + 60_000)) {
return credential.accessToken;
}
if (!credential.refreshToken || !credential.projectId) return credential.accessToken;
try {
const refreshed = await refreshGoogleCloudToken(credential.refreshToken, credential.projectId);
return refreshed.access;
} catch (error) {
ctx.logger?.warn("Gemini CLI token refresh failed", { error: String(error) });
return credential.accessToken;
}
}
async function loadCodeAssist(
params: UsageFetchParams,
ctx: UsageFetchContext,
accessToken: string,
baseUrl: string,
projectId?: string,
): Promise<LoadCodeAssistResponse | undefined> {
const response = await ctx.fetch(`${baseUrl}/v1internal:loadCodeAssist`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...GEMINI_CLI_HEADERS,
},
body: JSON.stringify({
...(projectId ? { cloudaicompanionProject: projectId } : {}),
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
signal: params.signal,
});
if (!response.ok) {
const errorText = await response.text();
ctx.logger?.warn("Gemini CLI loadCodeAssist failed", {
status: response.status,
error: errorText,
});
return undefined;
}
return (await response.json()) as LoadCodeAssistResponse;
}
async function fetchQuota(
params: UsageFetchParams,
ctx: UsageFetchContext,
accessToken: string,
baseUrl: string,
projectId?: string,
): Promise<RetrieveUserQuotaResponse | undefined> {
const response = await ctx.fetch(`${baseUrl}/v1internal:retrieveUserQuota`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...GEMINI_CLI_HEADERS,
},
body: JSON.stringify(projectId ? { project: projectId } : {}),
signal: params.signal,
});
if (!response.ok) {
const errorText = await response.text();
ctx.logger?.warn("Gemini CLI retrieveUserQuota failed", {
status: response.status,
error: errorText,
});
return undefined;
}
return (await response.json()) as RetrieveUserQuotaResponse;
}
export const googleGeminiCliUsageProvider: UsageProvider = {
id: "google-gemini-cli",
supports: ({ credential }) => credential.type === "oauth" && !!credential.accessToken,
async fetchUsage(params, ctx) {
const { credential } = params;
if (credential.type !== "oauth") {
return null;
}
const accessToken = await resolveAccessToken(params, ctx);
if (!accessToken) {
return null;
}
const now = ctx.now();
const baseUrl = (params.baseUrl?.trim() || DEFAULT_ENDPOINT).replace(/\/$/, "");
const cacheKey = `usage:${params.provider}:${credential.accountId ?? credential.email ?? "default"}:${baseUrl}:${
credential.projectId ?? "default"
}`;
const cached = await ctx.cache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const loadResponse = await loadCodeAssist(params, ctx, accessToken, baseUrl, credential.projectId);
const projectId = credential.projectId ?? getProjectId(loadResponse);
const quotaResponse = await fetchQuota(params, ctx, accessToken, baseUrl, projectId);
if (!quotaResponse) {
const entry = { value: null, expiresAt: now + CACHE_TTL_MS };
await ctx.cache.set(cacheKey, entry);
return null;
}
const limits: UsageLimit[] = [];
const buckets = quotaResponse.buckets ?? [];
buckets.forEach((bucket, index) => {
const modelId = bucket.modelId;
const window = parseWindow(bucket.resetTime, now);
const amount = buildAmount(bucket.remainingFraction);
const tier = modelId ? getModelTier(modelId) : undefined;
const label = modelId ? `Gemini ${modelId}` : "Gemini quota";
const id = `${modelId ?? "unknown"}:${window?.id ?? index}`;
limits.push({
id,
label,
scope: {
provider: params.provider,
accountId: credential.accountId,
projectId,
modelId,
tier,
windowId: window?.id,
},
window,
amount,
});
});
const report: UsageReport = {
provider: params.provider,
fetchedAt: now,
limits,
metadata: {
currentTierId: loadResponse?.currentTier?.id,
currentTierName: loadResponse?.currentTier?.name,
},
raw: quotaResponse,
};
await ctx.cache.set(cacheKey, { value: report, expiresAt: now + CACHE_TTL_MS });
return report;
},
};
+133
View File
@@ -0,0 +1,133 @@
/**
* Usage reporting types for provider quota/limit endpoints.
*
* Provides a normalized schema to represent multiple limit windows, model tiers,
* and shared quotas across providers.
*/
import type { Provider } from "./types";
export type UsageUnit = "percent" | "tokens" | "requests" | "usd" | "minutes" | "bytes" | "unknown";
export type UsageStatus = "ok" | "warning" | "exhausted" | "unknown";
/** Time window for a limit (e.g. 5h, 7d, monthly). */
export interface UsageWindow {
/** Stable identifier (e.g. "5h", "7d", "monthly"). */
id: string;
/** Human label (e.g. "5 Hour", "7 Day"). */
label: string;
/** Window duration in milliseconds, when known. */
durationMs?: number;
/** Absolute reset timestamp in milliseconds since epoch. */
resetsAt?: number;
/** Relative reset time in milliseconds, computed at fetch time. */
resetInMs?: number;
}
/** Quantitative usage data. */
export interface UsageAmount {
/** Amount used in the given unit. */
used?: number;
/** Maximum limit in the given unit. */
limit?: number;
/** Remaining amount in the given unit. */
remaining?: number;
/** Fraction used (0..1). */
usedFraction?: number;
/** Fraction remaining (0..1). */
remainingFraction?: number;
/** Unit for the amounts (percent, tokens, etc.). */
unit: UsageUnit;
}
/** Scope metadata describing what the limit applies to. */
export interface UsageScope {
provider: Provider;
accountId?: string;
projectId?: string;
orgId?: string;
modelId?: string;
tier?: string;
windowId?: string;
shared?: boolean;
}
/** Normalized limit entry for a single window or quota bucket. */
export interface UsageLimit {
/** Stable identifier for this limit entry. */
id: string;
/** Human label for display. */
label: string;
scope: UsageScope;
window?: UsageWindow;
amount: UsageAmount;
status?: UsageStatus;
notes?: string[];
}
/** Aggregated usage report for a provider. */
export interface UsageReport {
provider: Provider;
fetchedAt: number;
limits: UsageLimit[];
metadata?: Record<string, unknown>;
raw?: unknown;
}
/** Cache entry for usage reports with absolute expiry. */
export interface UsageCacheEntry {
value: UsageReport | null;
expiresAt: number;
}
/** Dependency-injected cache store for usage responses. */
export interface UsageCache {
get(key: string): UsageCacheEntry | undefined | Promise<UsageCacheEntry | undefined>;
set(key: string, entry: UsageCacheEntry): void | Promise<void>;
delete?(key: string): void | Promise<void>;
cleanup?(): void | Promise<void>;
}
/** Optional logger for usage fetchers. */
export interface UsageLogger {
debug(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
}
/** Credential bundle for usage endpoints. */
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>;
}
/** Parameters provided to a usage fetcher. */
export interface UsageFetchParams {
provider: Provider;
credential: UsageCredential;
baseUrl?: string;
signal?: AbortSignal;
}
/** Shared runtime utilities for fetchers. */
export interface UsageFetchContext {
cache: UsageCache;
fetch: typeof fetch;
now: () => number;
logger?: UsageLogger;
}
/** Provider implementation for fetching usage information. */
export interface UsageProvider {
id: Provider;
fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null>;
supports?(params: UsageFetchParams): boolean;
}
+321
View File
@@ -0,0 +1,321 @@
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageStatus,
UsageWindow,
} from "../usage";
const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
const DEFAULT_CACHE_TTL_MS = 60_000;
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": "oauth-2025-04-20",
"content-type": "application/json",
"user-agent": "claude-code/2.0.20",
connection: "keep-alive",
} as const;
function normalizeClaudeBaseUrl(baseUrl?: string): string {
if (!baseUrl || !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;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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;
}
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) {
if ("utilization" in bucket || "resets_at" in bucket) {
return { utilization: 0, resetsAt: undefined };
}
return undefined;
}
return { utilization, resetsAt };
}
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<ClaudeUsageResponse | null> {
let lastPayload: ClaudeUsageResponse | null = null;
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;
if (payload && isRecord(payload) && hasUsageData(payload)) {
return payload;
}
} catch (error) {
ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
return null;
}
if (attempt < MAX_RETRIES - 1) {
await Bun.sleep(BASE_RETRY_DELAY_MS * 2 ** attempt);
}
}
return lastPayload;
}
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 buildUsageWindow(
id: string,
label: string,
durationMs: number,
resetsAt: number | undefined,
now: number,
): UsageWindow {
const resolvedResetAt = resetsAt ?? now + durationMs;
const resetInMs = Math.max(0, resolvedResetAt - now);
return {
id,
label,
durationMs,
resetsAt: resolvedResetAt,
resetInMs,
};
}
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;
now: number;
}): UsageLimit | null {
if (!args.bucket) return null;
const amount = buildUsageAmount(args.bucket.utilization);
if (!amount) return null;
const window = buildUsageWindow(args.windowId, args.windowLabel, args.durationMs, args.bucket.resetsAt, args.now);
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),
};
}
function buildCacheKey(params: UsageFetchParams): string {
const credential = params.credential;
const account = credential.accountId ?? credential.email ?? "unknown";
const token = credential.accessToken ?? credential.refreshToken;
const fingerprint = token && typeof token === "string" ? Bun.hash(token).toString(16) : "anonymous";
const baseUrl = params.baseUrl ?? DEFAULT_ENDPOINT;
return `usage:${params.provider}:${account}:${fingerprint}:${baseUrl}`;
}
function resolveCacheExpiry(now: number, limits: UsageLimit[]): number {
const earliestReset = limits
.map((limit) => limit.window?.resetsAt)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
.reduce((min, value) => (min === undefined ? value : Math.min(min, value)), undefined as number | undefined);
const exhausted = limits.some((limit) => limit.status === "exhausted");
if (earliestReset === undefined) return now + DEFAULT_CACHE_TTL_MS;
if (exhausted) return earliestReset;
return Math.min(now + DEFAULT_CACHE_TTL_MS, earliestReset);
}
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 cacheKey = buildCacheKey(params);
const cachedEntry = await ctx.cache.get(cacheKey);
const now = ctx.now();
if (cachedEntry && cachedEntry.expiresAt > now) {
return cachedEntry.value;
}
const cachedValue = cachedEntry?.value ?? null;
const baseUrl = normalizeClaudeBaseUrl(params.baseUrl);
const url = `${baseUrl}/usage`;
const headers: Record<string, string> = {
...CLAUDE_HEADERS,
authorization: `Bearer ${credential.accessToken}`,
};
const payload = await fetchUsagePayload(url, headers, ctx, params.signal);
if (!payload || !isRecord(payload)) return cachedValue;
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,
now,
}),
buildUsageLimit({
id: "anthropic:7d",
label: "Claude 7 Day",
windowId: "7d",
windowLabel: "7 Day",
durationMs: SEVEN_DAYS_MS,
bucket: sevenDay,
provider: "anthropic",
shared: true,
now,
}),
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",
now,
}),
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",
now,
}),
].filter((limit): limit is UsageLimit => limit !== null);
if (limits.length === 0) return cachedValue;
const report: UsageReport = {
provider: params.provider,
fetchedAt: now,
limits,
metadata: {
accountId: credential.accountId,
email: credential.email,
endpoint: url,
},
raw: payload,
};
const expiresAt = resolveCacheExpiry(now, limits);
await ctx.cache.set(cacheKey, { value: report, expiresAt });
return report;
}
export const claudeUsageProvider: UsageProvider = {
id: "anthropic",
fetchUsage: fetchClaudeUsage,
supports: (params) => params.provider === "anthropic" && params.credential.type === "oauth",
};
+502
View File
@@ -0,0 +1,502 @@
/**
* GitHub Copilot usage provider.
*
* Normalizes Copilot quota usage into the shared UsageReport schema.
*/
import type {
UsageAmount,
UsageCacheEntry,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageStatus,
UsageWindow,
} from "../usage";
const COPILOT_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
"Copilot-Integration-Id": "vscode-chat",
} as const;
const DEFAULT_CACHE_TTL_MS = 60_000;
const MAX_CACHE_TTL_MS = 300_000;
type CopilotQuotaDetail = {
entitlement: number;
overage_count: number;
overage_permitted: boolean;
percent_remaining: number;
quota_id: string;
quota_remaining: number;
remaining: number;
unlimited: boolean;
};
type CopilotQuotaSnapshots = {
chat?: CopilotQuotaDetail;
completions?: CopilotQuotaDetail;
premium_interactions?: CopilotQuotaDetail;
};
type CopilotUsageResponse = {
copilot_plan: string;
quota_reset_date: string;
quota_snapshots: CopilotQuotaSnapshots;
};
type CopilotTokenResponse = {
token: string;
expires_at: number;
};
type BillingUsageItem = {
product: string;
sku: string;
model?: string;
unitType: string;
grossQuantity: number;
netQuantity: number;
limit?: number;
};
type BillingUsageResponse = {
timePeriod: { year: number; month?: number };
user: string;
usageItems: BillingUsageItem[];
};
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;
}
function toBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function resolveGitHubApiBaseUrl(params: UsageFetchParams): string {
const baseUrl = params.baseUrl?.replace(/\/$/, "");
if (baseUrl && !baseUrl.includes("githubcopilot.com")) return baseUrl;
const enterpriseUrl = params.credential.enterpriseUrl?.trim();
if (!enterpriseUrl) return "https://api.github.com";
if (enterpriseUrl.startsWith("http://") || enterpriseUrl.startsWith("https://")) {
return enterpriseUrl.replace(/\/$/, "");
}
if (enterpriseUrl.startsWith("api.")) {
return `https://${enterpriseUrl}`;
}
return `https://api.${enterpriseUrl}`;
}
function resolveCopilotApiBaseUrl(params: UsageFetchParams): string {
if (params.baseUrl) return params.baseUrl.replace(/\/$/, "");
const enterpriseUrl = params.credential.enterpriseUrl?.trim();
if (enterpriseUrl) return `https://api.${enterpriseUrl}`;
return "https://api.individual.githubcopilot.com";
}
function buildCacheKey(params: UsageFetchParams): string {
const parts: string[] = [params.provider];
const { credential } = params;
if (credential.accountId) parts.push(credential.accountId);
if (credential.email) parts.push(credential.email);
const token =
credential.apiKey || credential.accessToken || credential.refreshToken || credential.metadata?.username;
if (token && typeof token === "string") {
const fingerprint = Bun.hash(token).toString(16);
parts.push(fingerprint);
}
return parts.join(":");
}
function buildWindow(resetDate: string | undefined, now: number): UsageWindow | undefined {
if (!resetDate) return undefined;
const resetAt = Date.parse(resetDate);
if (!Number.isFinite(resetAt)) return undefined;
return {
id: "monthly",
label: "Monthly",
resetsAt: resetAt,
resetInMs: resetAt - now,
};
}
function buildAmount(used: number | undefined, limit: number | undefined, unit: UsageAmount["unit"]): UsageAmount {
const safeLimit = limit !== undefined && Number.isFinite(limit) ? limit : undefined;
const safeUsed = used !== undefined && Number.isFinite(used) ? used : undefined;
const remaining = safeLimit !== undefined && safeUsed !== undefined ? Math.max(0, safeLimit - safeUsed) : undefined;
const usedFraction =
safeLimit !== undefined && safeUsed !== undefined && safeLimit > 0 ? safeUsed / safeLimit : undefined;
const remainingFraction =
safeLimit !== undefined && remaining !== undefined && safeLimit > 0 ? remaining / safeLimit : undefined;
return {
used: safeUsed,
limit: safeLimit,
remaining,
usedFraction,
remainingFraction,
unit,
};
}
function deriveStatus(amount: UsageAmount, unlimited: boolean): UsageStatus {
if (unlimited) return "ok";
if (amount.remainingFraction === undefined) return "unknown";
if (amount.remainingFraction <= 0) return "exhausted";
if (amount.remainingFraction <= 0.1) return "warning";
return "ok";
}
function parseQuotaDetail(value: unknown): CopilotQuotaDetail | null {
if (!isRecord(value)) return null;
const entitlement = toNumber(value.entitlement);
const remaining = toNumber(value.remaining);
const percentRemaining = toNumber(value.percent_remaining);
const unlimited = toBoolean(value.unlimited);
if (
entitlement === undefined ||
remaining === undefined ||
percentRemaining === undefined ||
unlimited === undefined
) {
return null;
}
const overageCount = toNumber(value.overage_count) ?? 0;
const overagePermitted = toBoolean(value.overage_permitted) ?? false;
const quotaId = typeof value.quota_id === "string" ? value.quota_id : "";
const quotaRemaining = toNumber(value.quota_remaining) ?? remaining;
return {
entitlement,
overage_count: overageCount,
overage_permitted: overagePermitted,
percent_remaining: percentRemaining,
quota_id: quotaId,
quota_remaining: quotaRemaining,
remaining,
unlimited,
};
}
async function fetchJson(ctx: UsageFetchContext, url: string, init: RequestInit): Promise<unknown> {
const response = await ctx.fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status} ${response.statusText}: ${text}`);
}
return response.json();
}
async function resolveGitHubUsername(
ctx: UsageFetchContext,
baseUrl: string,
token: string,
signal?: AbortSignal,
): Promise<string | undefined> {
try {
const data = await fetchJson(ctx, `${baseUrl}/user`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
signal,
});
if (!isRecord(data)) return undefined;
return typeof data.login === "string" ? data.login : undefined;
} catch {
return undefined;
}
}
async function exchangeForCopilotToken(
ctx: UsageFetchContext,
baseUrl: string,
oauthToken: string,
signal?: AbortSignal,
): Promise<CopilotTokenResponse | null> {
try {
const data = await fetchJson(ctx, `${baseUrl}/copilot_internal/v2/token`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${oauthToken}`,
...COPILOT_HEADERS,
},
signal,
});
if (!isRecord(data)) return null;
const token = typeof data.token === "string" ? data.token : undefined;
const expiresAt = toNumber(data.expires_at);
if (!token || !expiresAt) return null;
return { token, expires_at: expiresAt };
} catch {
return null;
}
}
async function fetchInternalUsage(
ctx: UsageFetchContext,
baseUrl: string,
oauthToken: string,
accessToken: string | undefined,
expiresAt: number | undefined,
signal?: AbortSignal,
): Promise<CopilotUsageResponse> {
const requestWithToken = async (token: string, legacy: boolean) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: legacy ? `token ${token}` : `Bearer ${token}`,
...COPILOT_HEADERS,
};
const data = await fetchJson(ctx, `${baseUrl}/copilot_internal/user`, { headers, signal });
if (!isRecord(data)) throw new Error("Invalid Copilot usage response");
return data as CopilotUsageResponse;
};
const now = ctx.now();
if (accessToken && expiresAt && accessToken !== oauthToken && expiresAt > now) {
try {
return await requestWithToken(accessToken, false);
} catch {
// Ignore and try other strategies.
}
}
try {
return await requestWithToken(oauthToken, true);
} catch {
const exchanged = await exchangeForCopilotToken(ctx, baseUrl, oauthToken, signal);
if (!exchanged) throw new Error("Copilot usage token exchange failed");
return requestWithToken(exchanged.token, false);
}
}
async function fetchBillingUsage(
ctx: UsageFetchContext,
baseUrl: string,
username: string,
token: string,
signal?: AbortSignal,
): Promise<BillingUsageResponse> {
const data = await fetchJson(
ctx,
`${baseUrl}/users/${encodeURIComponent(username)}/settings/billing/premium_request/usage`,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
signal,
},
);
if (!isRecord(data)) throw new Error("Invalid Copilot billing usage response");
return data as BillingUsageResponse;
}
function buildLimitFromQuota(
key: string,
label: string,
quota: CopilotQuotaDetail,
plan: string,
window: UsageWindow | undefined,
): UsageLimit {
const used = quota.unlimited ? undefined : Math.max(0, quota.entitlement - quota.remaining);
const limit = quota.unlimited ? undefined : quota.entitlement;
const amount = buildAmount(used, limit, "requests");
const notes: string[] = [];
if (quota.unlimited) notes.push("Unlimited");
if (quota.overage_count > 0) {
notes.push(`Overage requests: ${quota.overage_count}`);
}
return {
id: `copilot:${key}`,
label,
scope: {
provider: "github-copilot",
tier: plan,
windowId: window?.id,
},
window,
amount,
status: deriveStatus(amount, quota.unlimited),
notes: notes.length > 0 ? notes : undefined,
};
}
function normalizeQuotaSnapshots(
data: CopilotUsageResponse,
now: number,
): { limits: UsageLimit[]; window?: UsageWindow } {
const window = buildWindow(data.quota_reset_date, now);
const snapshots = data.quota_snapshots ?? {};
const limits: UsageLimit[] = [];
const premium = parseQuotaDetail(snapshots.premium_interactions);
if (premium) {
limits.push(buildLimitFromQuota("premium", "Premium Requests", premium, data.copilot_plan, window));
}
const chat = parseQuotaDetail(snapshots.chat);
if (chat && !chat.unlimited) {
limits.push(buildLimitFromQuota("chat", "Chat Requests", chat, data.copilot_plan, window));
}
const completions = parseQuotaDetail(snapshots.completions);
if (completions && !completions.unlimited) {
limits.push(buildLimitFromQuota("completions", "Completions", completions, data.copilot_plan, window));
}
return { limits, window };
}
function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
const limits: UsageLimit[] = [];
const periodLabel = data.timePeriod.month
? `${data.timePeriod.year}-${String(data.timePeriod.month).padStart(2, "0")}`
: `${data.timePeriod.year}`;
const window: UsageWindow = {
id: "billing-period",
label: periodLabel,
};
const premiumItems = data.usageItems.filter(
(item) => item.sku === "Copilot Premium Request" || item.sku.includes("Premium"),
);
const totalUsed = premiumItems.reduce((sum, item) => sum + item.grossQuantity, 0);
const totalLimit = premiumItems.reduce((sum, item) => sum + (item.limit ?? 0), 0) || undefined;
const totalAmount = buildAmount(totalUsed, totalLimit, "requests");
limits.push({
id: "copilot:premium",
label: "Premium Requests",
scope: {
provider: "github-copilot",
accountId: data.user,
windowId: window.id,
},
window,
amount: totalAmount,
status: deriveStatus(totalAmount, false),
});
for (const item of data.usageItems) {
if (!item.model) continue;
if (item.grossQuantity <= 0) continue;
const amount = buildAmount(item.grossQuantity, item.limit, "requests");
limits.push({
id: `copilot:model:${item.model}`,
label: `Model ${item.model}`,
scope: {
provider: "github-copilot",
accountId: data.user,
modelId: item.model,
windowId: window.id,
},
window,
amount,
status: deriveStatus(amount, false),
});
}
return limits;
}
function resolveCacheTtl(now: number, report: UsageReport | null): UsageCacheEntry["expiresAt"] {
if (!report) return now + DEFAULT_CACHE_TTL_MS;
const resetInMs = report.limits
.map((limit) => limit.window?.resetInMs)
.find((value): value is number => typeof value === "number" && Number.isFinite(value));
if (!resetInMs || resetInMs <= 0) return now + DEFAULT_CACHE_TTL_MS;
return now + Math.min(MAX_CACHE_TTL_MS, resetInMs);
}
export const githubCopilotUsageProvider: UsageProvider = {
id: "github-copilot",
supports: ({ provider, credential }) => {
if (provider !== "github-copilot") return false;
if (credential.type === "oauth") {
return Boolean(credential.refreshToken || credential.accessToken);
}
return Boolean(credential.apiKey);
},
fetchUsage: async (params, ctx) => {
if (!githubCopilotUsageProvider.supports?.(params)) return null;
const now = ctx.now();
const cacheKey = buildCacheKey(params);
const cached = await ctx.cache.get(cacheKey);
if (cached && cached.expiresAt > now) return cached.value;
const baseUrl =
params.credential.type === "api_key" ? resolveGitHubApiBaseUrl(params) : resolveCopilotApiBaseUrl(params);
let report: UsageReport | null = null;
if (params.credential.type === "api_key") {
let username =
params.credential.accountId || params.credential.metadata?.username || params.credential.metadata?.user;
if ((!username || typeof username !== "string" || !username.trim()) && params.credential.apiKey) {
username = await resolveGitHubUsername(ctx, baseUrl, params.credential.apiKey, params.signal);
}
if (typeof username !== "string" || !username.trim()) {
ctx.logger?.warn("Copilot usage requires username for billing API", { provider: params.provider });
} else if (params.credential.apiKey) {
try {
const billing = await fetchBillingUsage(ctx, baseUrl, username, params.credential.apiKey, params.signal);
report = {
provider: "github-copilot",
fetchedAt: now,
limits: normalizeBillingUsage(billing),
metadata: {
account: billing.user,
period: billing.timePeriod,
},
};
} catch (error) {
ctx.logger?.warn("Copilot usage fetch failed", { error: String(error) });
}
}
} else {
const { refreshToken, accessToken, expiresAt } = params.credential;
if (!refreshToken && !accessToken) return null;
const oauthToken = refreshToken || accessToken;
if (!oauthToken) return null;
try {
const usage = await fetchInternalUsage(
ctx,
baseUrl,
oauthToken,
accessToken ?? undefined,
expiresAt ?? undefined,
params.signal,
);
const normalized = normalizeQuotaSnapshots(usage, now);
report = {
provider: "github-copilot",
fetchedAt: now,
limits: normalized.limits,
metadata: {
plan: usage.copilot_plan,
quotaResetDate: usage.quota_reset_date,
},
raw: usage,
};
} catch (error) {
ctx.logger?.warn("Copilot usage fetch failed", { error: String(error) });
}
}
const expiresAt = resolveCacheTtl(now, report);
await ctx.cache.set(cacheKey, { value: report, expiresAt });
return report;
},
};
+218
View File
@@ -0,0 +1,218 @@
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageStatus,
UsageWindow,
} from "../usage";
import { refreshAntigravityToken } from "../utils/oauth/google-antigravity";
interface AntigravityQuotaInfo {
remainingFraction?: number;
resetTime?: string;
tier?: string;
windowId?: string;
windowLabel?: string;
}
interface AntigravityModelInfo {
displayName?: string;
quotaInfo?: AntigravityQuotaInfo | AntigravityQuotaInfo[];
quotaInfos?: AntigravityQuotaInfo[];
quotaInfoByTier?: Record<string, AntigravityQuotaInfo | AntigravityQuotaInfo[]>;
}
interface AntigravityUsageResponse {
models: Record<string, AntigravityModelInfo>;
}
const DEFAULT_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
const USER_AGENT = "antigravity/1.11.9 windows/amd64";
const DEFAULT_CACHE_TTL_MS = 60_000;
function clampFraction(value: number | undefined): number | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
function getUsageStatus(remainingFraction: number | undefined): UsageStatus | undefined {
if (remainingFraction === undefined) return "unknown";
if (remainingFraction <= 0) return "exhausted";
if (remainingFraction <= 0.1) return "warning";
return "ok";
}
function parseWindow(info: AntigravityQuotaInfo, nowMs: number): UsageWindow | undefined {
if (!info.resetTime) return undefined;
const resetAt = Date.parse(info.resetTime);
if (!Number.isFinite(resetAt)) return undefined;
return {
id: info.windowId ?? "default",
label: info.windowLabel ?? "Default",
resetsAt: resetAt,
resetInMs: Math.max(0, resetAt - nowMs),
};
}
function buildAmount(info: AntigravityQuotaInfo): UsageAmount {
const remainingFraction = clampFraction(info.remainingFraction);
const amount: UsageAmount = { unit: "percent" };
if (remainingFraction === undefined) return amount;
const usedFraction = clampFraction(1 - remainingFraction);
amount.remainingFraction = remainingFraction;
amount.usedFraction = usedFraction;
amount.remaining = remainingFraction * 100;
amount.used = usedFraction !== undefined ? usedFraction * 100 : undefined;
amount.limit = 100;
return amount;
}
function normalizeQuotaInfos(info: AntigravityModelInfo): AntigravityQuotaInfo[] {
const results: AntigravityQuotaInfo[] = [];
const addInfo = (value: AntigravityQuotaInfo, tier?: string) => {
results.push({ ...value, ...(tier ? { tier } : {}) });
};
const addArray = (values?: AntigravityQuotaInfo[]) => {
if (!values) return;
for (const value of values) addInfo(value);
};
if (Array.isArray(info.quotaInfo)) {
addArray(info.quotaInfo);
} else if (info.quotaInfo) {
addInfo(info.quotaInfo);
}
addArray(info.quotaInfos);
if (info.quotaInfoByTier) {
for (const [tier, value] of Object.entries(info.quotaInfoByTier)) {
if (Array.isArray(value)) {
for (const entry of value) addInfo(entry, tier);
} else if (value) {
addInfo(value, tier);
}
}
}
return results;
}
function buildCacheKey(params: UsageFetchParams): string {
const credential = params.credential;
const accountPart = credential.accountId ?? credential.email ?? "unknown";
const projectPart = credential.projectId ?? "unknown";
return `usage:${params.provider}:${accountPart}:${projectPart}`;
}
async function resolveAccessToken(params: UsageFetchParams, ctx: UsageFetchContext): Promise<string | undefined> {
const { credential } = params;
if (credential.accessToken && (!credential.expiresAt || credential.expiresAt > ctx.now() + 60_000)) {
return credential.accessToken;
}
if (!credential.refreshToken || !credential.projectId) return undefined;
try {
const refreshed = await refreshAntigravityToken(credential.refreshToken, credential.projectId);
return refreshed.access;
} catch (error) {
ctx.logger?.warn("Antigravity usage token refresh failed", { error: String(error) });
return undefined;
}
}
async function fetchAntigravityUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
const credential = params.credential;
if (!credential.projectId) return null;
const cacheKey = buildCacheKey(params);
const cached = await ctx.cache.get(cacheKey);
const nowMs = ctx.now();
if (cached && cached.expiresAt > nowMs) {
return cached.value;
}
const accessToken = await resolveAccessToken(params, ctx);
if (!accessToken) return null;
const baseUrl = params.baseUrl?.replace(/\/+$/, "") || DEFAULT_ENDPOINT;
const url = `${baseUrl}${FETCH_AVAILABLE_MODELS_PATH}`;
const response = await ctx.fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({ project: credential.projectId }),
signal: params.signal,
});
if (!response.ok) {
ctx.logger?.warn("Antigravity usage fetch failed", {
status: response.status,
statusText: response.statusText,
});
return null;
}
const data = (await response.json()) as AntigravityUsageResponse;
const limits: UsageLimit[] = [];
let earliestReset: number | undefined;
for (const [modelId, modelInfo] of Object.entries(data.models ?? {})) {
const quotaInfos = normalizeQuotaInfos(modelInfo);
for (const quotaInfo of quotaInfos) {
const amount = buildAmount(quotaInfo);
const window = parseWindow(quotaInfo, nowMs);
if (window?.resetsAt) {
earliestReset = earliestReset ? Math.min(earliestReset, window.resetsAt) : window.resetsAt;
}
const labelBase = modelInfo.displayName || modelId;
const label = quotaInfo.tier ? `${labelBase} (${quotaInfo.tier})` : labelBase;
const windowId = window?.id ?? "default";
limits.push({
id: `${modelId}:${quotaInfo.tier ?? "default"}:${windowId}`,
label,
scope: {
provider: params.provider,
accountId: credential.accountId,
projectId: credential.projectId,
modelId,
tier: quotaInfo.tier,
windowId,
},
window,
amount,
status: getUsageStatus(amount.remainingFraction),
});
}
}
const report: UsageReport = {
provider: params.provider,
fetchedAt: nowMs,
limits,
metadata: {
endpoint: url,
projectId: credential.projectId,
},
raw: data,
};
const expiresAt = earliestReset
? Math.min(earliestReset, nowMs + DEFAULT_CACHE_TTL_MS)
: nowMs + DEFAULT_CACHE_TTL_MS;
await ctx.cache.set(cacheKey, { value: report, expiresAt });
return report;
}
export const antigravityUsageProvider: UsageProvider = {
id: "google-antigravity",
fetchUsage: fetchAntigravityUsage,
supports: (params) => params.provider === "google-antigravity",
};
+393
View File
@@ -0,0 +1,393 @@
import { Buffer } from "node:buffer";
import { CODEX_BASE_URL } from "../providers/openai-codex/constants";
import type {
UsageAmount,
UsageCache,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageWindow,
} from "../usage";
const CODEX_USAGE_PATH = "wham/usage";
const DEFAULT_CACHE_TTL_MS = 60_000;
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 isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
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;
};
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 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 payload?.[JWT_PROFILE_CLAIM]?.email ?? undefined;
}
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 resolveResetTimes(window: ParsedUsageWindow, nowMs: number): Pick<UsageWindow, "resetsAt" | "resetInMs"> {
const resetAt = window.resetAt;
if (resetAt !== undefined) {
const resetAtMs = resetAt > 1_000_000_000_000 ? resetAt : resetAt * 1000;
if (Number.isFinite(resetAtMs)) {
return { resetsAt: resetAtMs, resetInMs: resetAtMs - nowMs };
}
}
if (window.resetAfterSeconds !== undefined) {
const resetInMs = window.resetAfterSeconds * 1000;
return { resetsAt: nowMs + resetInMs, resetInMs };
}
return {};
}
function buildUsageWindow(window: ParsedUsageWindow, key: string, nowMs: number): UsageWindow {
if (window.limitWindowSeconds !== undefined) {
const { id, label } = buildWindowLabel(window.limitWindowSeconds);
const durationMs = window.limitWindowSeconds * 1000;
return { id, label, durationMs, ...resolveResetTimes(window, nowMs) };
}
const fallbackLabel = key === "primary" ? "Primary window" : "Secondary window";
return { id: key, label: fallbackLabel, ...resolveResetTimes(window, nowMs) };
}
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),
};
}
function resolveCacheExpiry(args: { report: UsageReport | null; nowMs: number }): number {
const { report, nowMs } = args;
if (!report) return nowMs + DEFAULT_CACHE_TTL_MS;
const exhausted = report.limits.some((limit) => limit.status === "exhausted");
const resetCandidates = report.limits
.map((limit) => limit.window?.resetsAt)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
const earliestReset = resetCandidates.length > 0 ? Math.min(...resetCandidates) : undefined;
if (exhausted && earliestReset) return earliestReset;
if (earliestReset) return Math.min(nowMs + DEFAULT_CACHE_TTL_MS, earliestReset);
return nowMs + DEFAULT_CACHE_TTL_MS;
}
async function getCachedReport(
cache: UsageCache,
cacheKey: string,
nowMs: number,
): Promise<UsageReport | null | undefined> {
const cached = await cache.get(cacheKey);
if (!cached) return undefined;
if (cached.expiresAt <= nowMs) return undefined;
return cached.value;
}
async function setCachedReport(
cache: UsageCache,
cacheKey: string,
report: UsageReport | null,
expiresAt: number,
): Promise<void> {
await cache.set(cacheKey, { value: report, expiresAt });
}
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 = ctx.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 cacheKey = `usage:openai-codex:${accountId ?? "unknown"}:${baseUrl}`;
const cached = await getCachedReport(ctx.cache, cacheKey, nowMs);
if (cached !== undefined) return cached;
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);
if (!parsed) {
ctx.logger?.warn("Codex usage response invalid", { provider: params.provider });
return null;
}
const limits: UsageLimit[] = [];
if (parsed.primary) {
limits.push(
buildUsageLimit({
key: "primary",
window: parsed.primary,
accountId,
planType: parsed.planType,
limitReached: parsed.limitReached,
nowMs,
}),
);
}
if (parsed.secondary) {
limits.push(
buildUsageLimit({
key: "secondary",
window: parsed.secondary,
accountId,
planType: parsed.planType,
limitReached: parsed.limitReached,
nowMs,
}),
);
}
const report: UsageReport = {
provider: "openai-codex",
fetchedAt: nowMs,
limits,
metadata: {
planType: parsed.planType,
allowed: parsed.allowed,
limitReached: parsed.limitReached,
email: credential.email ?? extractEmail(accessToken),
},
raw: parsed.raw,
};
const expiresAt = resolveCacheExpiry({ report, nowMs });
await setCachedReport(ctx.cache, cacheKey, report, expiresAt);
return report;
},
};
+292
View File
@@ -0,0 +1,292 @@
import type {
UsageAmount,
UsageFetchContext,
UsageFetchParams,
UsageLimit,
UsageProvider,
UsageReport,
UsageStatus,
UsageWindow,
} from "../usage";
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 DEFAULT_CACHE_TTL_MS = 60_000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
function normalizeZaiBaseUrl(baseUrl?: string): string {
if (!baseUrl || !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 isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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;
}
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 buildUsageWindow(
id: string,
label: string,
resetsAt: number | undefined,
now: number,
): UsageWindow | undefined {
if (!resetsAt) return { id, label };
const resetInMs = Math.max(0, resetsAt - now);
return {
id,
label,
resetsAt,
resetInMs,
};
}
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 buildCacheKey(params: UsageFetchParams): string {
const credential = params.credential;
const account = credential.accountId ?? credential.email ?? "unknown";
const token = credential.apiKey ?? credential.accessToken;
const fingerprint = token && typeof token === "string" ? Bun.hash(token).toString(16) : "anonymous";
const baseUrl = params.baseUrl ?? DEFAULT_ENDPOINT;
return `usage:${params.provider}:${account}:${fingerprint}:${baseUrl}`;
}
function resolveCacheExpiry(now: number, limits: UsageLimit[]): number {
const earliestReset = limits
.map((limit) => limit.window?.resetsAt)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
.reduce((min, value) => (min === undefined ? value : Math.min(min, value)), undefined as number | undefined);
if (!earliestReset) return now + DEFAULT_CACHE_TTL_MS;
return Math.min(earliestReset, now + DEFAULT_CACHE_TTL_MS);
}
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 cacheKey = buildCacheKey(params);
const cachedEntry = await ctx.cache.get(cacheKey);
const now = ctx.now();
if (cachedEntry && cachedEntry.expiresAt > now) return cachedEntry.value;
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 = buildUsageWindow("quota", "Quota", parsed.nextResetTime, now);
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 = buildUsageWindow("quota", "Quota", undefined, now);
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: now,
limits,
metadata: {
endpoint: url,
accountId: credential.accountId,
email: credential.email,
},
raw: payload,
};
const expiresAt = resolveCacheExpiry(now, limits);
await ctx.cache.set(cacheKey, { value: report, expiresAt });
const modelUsageUrl = buildModelUsageUrl(baseUrl, new Date(now));
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",
};
+9 -1
View File
@@ -1,9 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added `/usage` command to display provider usage and limits
- Added support for multiple usage providers beyond Codex
- Added usage report caching with configurable TTL
- Added visual usage bars and account aggregation in usage display
- Added `fetchUsageReports()` method to agent session
- Added `output()` function to read task/agent outputs by ID with support for multiple formats and queries
- Added session file support to Python executor for accessing task outputs
- Added support for jq-like queries when reading JSON outputs
@@ -12,6 +16,9 @@
### Changed
- Refactored usage tracking from Codex-specific to generic provider system
- Updated usage limit detection to work with multiple provider APIs
- Changed usage cache to use persistent storage instead of in-memory only
- Limited diagnostic messages to 50 items to prevent overwhelming output when processing files with many issues
- Changed `/dump` command to include complete agent context: system prompt, model config, available tools with schemas, and all message types (bash/python executions, custom messages, branch summaries, compaction summaries, file mentions)
- Changed `/dump` format to use YAML instead of JSON for tool schemas and arguments (more readable)
@@ -21,6 +28,7 @@
- Fixed external editor to work properly on Unix systems by correctly handling terminal I/O
- Fixed external editor to show warning message when it fails to open instead of silently failing
- Fixed find tool to properly handle no matches case without treating as error
- Fixed find tool to wait for fd exit so error messages no longer report exit null
- Fixed read tool to properly handle no matches case without treating as error
- Fixed orphaned Python kernel gateway processes not being killed on process exit
@@ -13,22 +13,31 @@
* Modes use this class and add their own I/O layer on top.
*/
import { existsSync, readFileSync } from "node:fs";
import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, TextContent, ToolCall, Usage } from "@oh-my-pi/pi-ai";
import type {
AssistantMessage,
ImageContent,
Message,
Model,
TextContent,
ToolCall,
Usage,
UsageReport,
} from "@oh-my-pi/pi-ai";
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
import { abortableSleep, logger } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { existsSync, readFileSync } from "node:fs";
import type { Rule } from "../capability/rule";
import { getAgentDbPath } from "../config";
import { theme } from "../modes/interactive/theme/theme";
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
import {
type CompactionResult,
calculateContextTokens,
collectEntriesForBranchSummary,
compact,
type CompactionResult,
estimateTokens,
generateBranchSummary,
prepareCompaction,
@@ -53,8 +62,8 @@ import { extractFileMentions, generateFileMentionMessages } from "./file-mention
import type { HookCommandContext } from "./hooks/types";
import {
type BashExecutionMessage,
bashExecutionToText,
type BranchSummaryMessage,
bashExecutionToText,
type CompactionSummaryMessage,
type CustomMessage,
type FileMentionMessage,
@@ -64,7 +73,7 @@ import {
} from "./messages";
import type { ModelRegistry } from "./model-registry";
import { parseModelString } from "./model-resolver";
import { expandPromptTemplate, parseCommandArgs, type PromptTemplate, renderPromptTemplate } from "./prompt-templates";
import { expandPromptTemplate, type PromptTemplate, parseCommandArgs, renderPromptTemplate } from "./prompt-templates";
import { executePython as executePythonCommand, type PythonResult } from "./python-executor";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
import type { SettingsManager, SkillsSettings } from "./settings-manager";
@@ -3172,6 +3181,14 @@ export class AgentSession {
};
}
async fetchUsageReports(): Promise<UsageReport[] | null> {
const authStorage = this._modelRegistry.authStorage;
if (!authStorage.fetchUsageReports) return null;
return authStorage.fetchUsageReports({
baseUrlResolver: (provider) => this._modelRegistry.getProviderBaseUrl?.(provider),
});
}
/**
* Estimate context tokens from messages, using the last assistant usage when available.
*/
+384 -211
View File
@@ -3,10 +3,15 @@
* Handles loading, saving, and refreshing credentials from agent.db.
*/
import { Buffer } from "node:buffer";
import { dirname, join } from "node:path";
import {
antigravityUsageProvider,
claudeUsageProvider,
getEnvApiKey,
getOAuthApiKey,
githubCopilotUsageProvider,
googleGeminiCliUsageProvider,
loginAnthropic,
loginAntigravity,
loginCursor,
@@ -16,6 +21,16 @@ import {
type OAuthController,
type OAuthCredentials,
type OAuthProvider,
openaiCodexUsageProvider,
type Provider,
type UsageCache,
type UsageCacheEntry,
type UsageCredential,
type UsageLimit,
type UsageLogger,
type UsageProvider,
type UsageReport,
zaiUsageProvider,
} from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { getAgentDbPath, getAuthPath } from "../config";
@@ -61,43 +76,64 @@ export interface SerializedAuthStorage {
*/
type StoredCredential = { id: number; credential: AuthCredential };
/** Rate limit window from Codex usage API (primary or secondary quota). */
type CodexUsageWindow = {
usedPercent?: number;
limitWindowSeconds?: number;
resetAt?: number; // Unix timestamp (seconds)
export type AuthStorageOptions = {
usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
usageCache?: UsageCache;
usageFetch?: typeof fetch;
usageNow?: () => number;
usageLogger?: UsageLogger;
};
/** Parsed usage data from Codex /wham/usage endpoint. */
type CodexUsage = {
allowed?: boolean;
limitReached?: boolean;
primary?: CodexUsageWindow;
secondary?: CodexUsageWindow;
};
const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
openaiCodexUsageProvider,
antigravityUsageProvider,
googleGeminiCliUsageProvider,
claudeUsageProvider,
zaiUsageProvider,
githubCopilotUsageProvider,
];
/** Cached usage entry with TTL for avoiding redundant API calls. */
type CodexUsageCacheEntry = {
fetchedAt: number;
expiresAt: number;
usage?: CodexUsage;
};
const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
DEFAULT_USAGE_PROVIDERS.map((provider) => [provider.id, provider]),
);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
const USAGE_CACHE_PREFIX = "usage_cache:";
function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
}
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;
function parseUsageCacheEntry(raw: string): UsageCacheEntry | undefined {
try {
const parsed = JSON.parse(raw) as { value?: UsageReport | null; expiresAt?: unknown };
const expiresAt = typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined;
if (!expiresAt || !Number.isFinite(expiresAt)) return undefined;
return { value: parsed.value ?? null, expiresAt };
} catch {
return undefined;
}
return undefined;
}
function toBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
class AuthStorageUsageCache implements UsageCache {
constructor(private storage: AgentStorage) {}
get(key: string): UsageCacheEntry | undefined {
const raw = this.storage.getCache(`${USAGE_CACHE_PREFIX}${key}`);
if (!raw) return undefined;
const entry = parseUsageCacheEntry(raw);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) return undefined;
return entry;
}
set(key: string, entry: UsageCacheEntry): void {
const payload = JSON.stringify({ value: entry.value ?? null, expiresAt: entry.expiresAt });
this.storage.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(entry.expiresAt / 1000));
}
cleanup(): void {
this.storage.cleanExpiredCache();
}
}
/**
@@ -105,14 +141,11 @@ function toBoolean(value: unknown): boolean | undefined {
* Reads from SQLite and migrates legacy auth.json paths.
*/
export class AuthStorage {
private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
private static readonly cacheCleanupIntervalMs = 300_000; // Clean expired cache every 5 minutes
/** Provider -> credentials cache, populated from agent.db on reload(). */
private data: Map<string, StoredCredential[]> = new Map();
private storage: AgentStorage;
private lastCacheCleanup = 0;
/** Resolved path to agent.db (derived from authPath or used directly if .db). */
private dbPath: string;
private runtimeOverrides: Map<string, string> = new Map();
@@ -122,8 +155,11 @@ export class AuthStorage {
private sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
private credentialBackoff: Map<string, Map<number, number>> = new Map();
/** Cached usage info for providers that expose usage endpoints. */
private codexUsageCache: Map<string, CodexUsageCacheEntry> = new Map();
private usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
private usageCache?: UsageCache;
private usageFetch: typeof fetch;
private usageNow: () => number;
private usageLogger?: UsageLogger;
private fallbackResolver?: (provider: string) => string | undefined;
/**
@@ -133,16 +169,27 @@ export class AuthStorage {
constructor(
private authPath: string,
private fallbackPaths: string[] = [],
options: AuthStorageOptions = {},
) {
this.dbPath = AuthStorage.resolveDbPath(authPath);
this.storage = AgentStorage.open(this.dbPath);
this.usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
this.usageCache = options.usageCache ?? new AuthStorageUsageCache(this.storage);
this.usageFetch = options.usageFetch ?? fetch;
this.usageNow = options.usageNow ?? Date.now;
this.usageLogger =
options.usageLogger ??
({
debug: (message, meta) => logger.debug(message, meta),
warn: (message, meta) => logger.warn(message, meta),
} satisfies UsageLogger);
}
/**
* Create an in-memory AuthStorage instance from serialized data.
* Used by subagent workers to bypass discovery and use parent's credentials.
*/
static fromSerialized(data: SerializedAuthStorage): AuthStorage {
static fromSerialized(data: SerializedAuthStorage, options: AuthStorageOptions = {}): AuthStorage {
const instance = Object.create(AuthStorage.prototype) as AuthStorage;
const authPath = data.authPath ?? data.dbPath ?? getAuthPath();
instance.authPath = authPath;
@@ -154,8 +201,16 @@ export class AuthStorage {
instance.providerRoundRobinIndex = new Map();
instance.sessionLastCredential = new Map();
instance.credentialBackoff = new Map();
instance.codexUsageCache = new Map();
instance.lastCacheCleanup = 0;
instance.usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
instance.usageCache = options.usageCache ?? new AuthStorageUsageCache(instance.storage);
instance.usageFetch = options.usageFetch ?? fetch;
instance.usageNow = options.usageNow ?? Date.now;
instance.usageLogger =
options.usageLogger ??
({
debug: (message, meta) => logger.debug(message, meta),
warn: (message, meta) => logger.warn(message, meta),
} satisfies UsageLogger);
for (const [provider, creds] of Object.entries(data.credentials)) {
instance.data.set(
@@ -257,7 +312,15 @@ export class AuthStorage {
list.push({ id: record.id, credential: record.credential });
grouped.set(record.provider, list);
}
this.data = grouped;
const dedupedGrouped = new Map<string, StoredCredential[]>();
for (const [provider, entries] of grouped.entries()) {
const deduped = this.pruneDuplicateStoredCredentials(provider, entries);
if (deduped.length > 0) {
dedupedGrouped.set(provider, deduped);
}
}
this.data = dedupedGrouped;
}
/**
@@ -283,6 +346,115 @@ export class AuthStorage {
}
}
private getOAuthIdentifiers(credential: OAuthCredential): string[] {
const identifiers: string[] = [];
const accountId = credential.accountId?.trim();
if (accountId) identifiers.push(`account:${accountId}`);
const email = credential.email?.trim().toLowerCase();
if (email) identifiers.push(`email:${email}`);
if (identifiers.length > 0) return identifiers;
const tokenIdentifiers = this.getOAuthIdentifiersFromToken(credential.access) ?? [];
for (const identifier of tokenIdentifiers) {
identifiers.push(identifier);
}
if (identifiers.length > 0) return identifiers;
const refreshIdentifiers = this.getOAuthIdentifiersFromToken(credential.refresh) ?? [];
for (const identifier of refreshIdentifiers) {
identifiers.push(identifier);
}
return identifiers;
}
private getOAuthIdentifiersFromToken(token: string | undefined): string[] | undefined {
if (!token) return undefined;
const parts = token.split(".");
if (parts.length !== 3) return undefined;
const payloadRaw = parts[1];
try {
const payload = JSON.parse(
Buffer.from(payloadRaw.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
) as Record<string, unknown>;
if (!payload || typeof payload !== "object") return undefined;
const identifiers: string[] = [];
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : undefined;
if (email) identifiers.push(`email:${email}`);
const accountId =
typeof payload.account_id === "string"
? payload.account_id
: typeof payload.accountId === "string"
? payload.accountId
: typeof payload.user_id === "string"
? payload.user_id
: typeof payload.sub === "string"
? payload.sub
: undefined;
const trimmedAccountId = accountId?.trim();
if (trimmedAccountId) identifiers.push(`account:${trimmedAccountId}`);
return identifiers.length > 0 ? identifiers : undefined;
} catch {
return undefined;
}
}
private dedupeOAuthCredentials(credentials: AuthCredential[]): AuthCredential[] {
const seen = new Set<string>();
const deduped: AuthCredential[] = [];
for (let index = credentials.length - 1; index >= 0; index -= 1) {
const credential = credentials[index];
if (credential.type !== "oauth") {
deduped.push(credential);
continue;
}
const identifiers = this.getOAuthIdentifiers(credential);
if (identifiers.length === 0) {
deduped.push(credential);
continue;
}
if (identifiers.some((identifier) => seen.has(identifier))) {
continue;
}
for (const identifier of identifiers) {
seen.add(identifier);
}
deduped.push(credential);
}
return deduped.reverse();
}
private pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] {
const seen = new Set<string>();
const kept: StoredCredential[] = [];
const removed: StoredCredential[] = [];
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
const credential = entry.credential;
if (credential.type !== "oauth") {
kept.push(entry);
continue;
}
const identifiers = this.getOAuthIdentifiers(credential);
if (identifiers.length === 0) {
kept.push(entry);
continue;
}
if (identifiers.some((identifier) => seen.has(identifier))) {
removed.push(entry);
continue;
}
for (const identifier of identifiers) {
seen.add(identifier);
}
kept.push(entry);
}
if (removed.length > 0) {
for (const entry of removed) {
this.storage.deleteAuthCredential(entry.id);
}
this.resetProviderAssignments(provider);
}
return kept.reverse();
}
/** Returns all credentials for a provider as an array */
private getCredentialsForProvider(provider: string): AuthCredential[] {
return this.getStoredCredentials(provider).map((entry) => entry.credential);
@@ -469,7 +641,8 @@ export class AuthStorage {
*/
async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
const normalized = Array.isArray(credential) ? credential : [credential];
const stored = this.storage.replaceAuthCredentialsForProvider(provider, normalized);
const deduped = this.dedupeOAuthCredentials(normalized);
const stored = this.storage.replaceAuthCredentialsForProvider(provider, deduped);
this.setStoredCredentials(
provider,
stored.map((record) => ({ id: record.id, credential: record.credential })),
@@ -611,202 +784,195 @@ export class AuthStorage {
}
// ─────────────────────────────────────────────────────────────────────────────
// Codex Usage API Integration
// Queries ChatGPT/Codex usage endpoints to detect rate limits before they occur.
// Usage API Integration
// Queries provider usage endpoints to detect rate limits before they occur.
// ─────────────────────────────────────────────────────────────────────────────
/** Normalizes Codex base URL to include /backend-api path. */
private normalizeCodexBaseUrl(baseUrl?: string): string {
const fallback = "https://chatgpt.com/backend-api";
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;
}
private getCodexUsagePath(baseUrl: string): string {
return baseUrl.includes("/backend-api") ? "wham/usage" : "api/codex/usage";
}
private buildCodexUsageUrl(baseUrl: string, path: string): string {
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${normalized}${path.replace(/^\/+/, "")}`;
}
private getCodexUsageCacheKey(accountId: string, baseUrl: string): string {
return `${baseUrl}|${accountId}`;
}
private extractCodexUsageWindow(window: unknown): CodexUsageWindow | undefined {
if (!isRecord(window)) return undefined;
const usedPercent = toNumber(window.used_percent);
const limitWindowSeconds = toNumber(window.limit_window_seconds);
const resetAt = toNumber(window.reset_at);
if (usedPercent === undefined && limitWindowSeconds === undefined && resetAt === undefined) return undefined;
return { usedPercent, limitWindowSeconds, resetAt };
}
private extractCodexUsage(payload: unknown): CodexUsage | undefined {
if (!isRecord(payload)) return undefined;
const rateLimit = isRecord(payload.rate_limit) ? payload.rate_limit : undefined;
if (!rateLimit) return undefined;
const primary = this.extractCodexUsageWindow(rateLimit.primary_window);
const secondary = this.extractCodexUsageWindow(rateLimit.secondary_window);
const usage: CodexUsage = {
allowed: toBoolean(rateLimit.allowed),
limitReached: toBoolean(rateLimit.limit_reached),
primary,
secondary,
private buildUsageCredential(credential: OAuthCredential): UsageCredential {
return {
type: "oauth",
accessToken: credential.access,
refreshToken: credential.refresh,
expiresAt: credential.expires,
accountId: credential.accountId,
projectId: credential.projectId,
email: credential.email,
enterpriseUrl: credential.enterpriseUrl,
};
if (!primary && !secondary && usage.allowed === undefined && usage.limitReached === undefined) return undefined;
return usage;
}
/** Returns true if usage indicates rate limit has been reached. */
private isCodexUsageLimitReached(usage: CodexUsage): boolean {
if (usage.allowed === false || usage.limitReached === true) return true;
if (usage.primary?.usedPercent !== undefined && usage.primary.usedPercent >= 100) return true;
if (usage.secondary?.usedPercent !== undefined && usage.secondary.usedPercent >= 100) return true;
private isUsageLimitExhausted(limit: UsageLimit): boolean {
if (limit.status === "exhausted") return true;
const amount = limit.amount;
if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true;
if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true;
if (amount.used !== undefined && amount.limit !== undefined && amount.used >= amount.limit) return true;
if (amount.remaining !== undefined && amount.remaining <= 0) return true;
if (amount.unit === "percent" && amount.used !== undefined && amount.used >= 100) return true;
return false;
}
/** Extracts the earliest reset timestamp from usage windows (in ms). */
private getCodexResetAtMs(usage: CodexUsage): number | undefined {
const now = Date.now();
/** Returns true if usage indicates rate limit has been reached. */
private isUsageLimitReached(report: UsageReport): boolean {
return report.limits.some((limit) => this.isUsageLimitExhausted(limit));
}
/** Extracts the earliest reset timestamp from exhausted windows (in ms). */
private getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
const candidates: number[] = [];
const addCandidate = (value: number | undefined) => {
if (!value) return;
const ms = value > 1_000_000_000_000 ? value : value * 1000;
if (Number.isFinite(ms) && ms > now) {
candidates.push(ms);
for (const limit of report.limits) {
if (!this.isUsageLimitExhausted(limit)) continue;
const window = limit.window;
if (window?.resetsAt && window.resetsAt > nowMs) {
candidates.push(window.resetsAt);
}
};
const useAll = usage.limitReached === true || usage.allowed === false;
if (useAll) {
addCandidate(usage.primary?.resetAt);
addCandidate(usage.secondary?.resetAt);
} else {
if (usage.primary?.usedPercent !== undefined && usage.primary.usedPercent >= 100) {
addCandidate(usage.primary.resetAt);
}
if (usage.secondary?.usedPercent !== undefined && usage.secondary.usedPercent >= 100) {
addCandidate(usage.secondary.resetAt);
if (window?.resetInMs && window.resetInMs > 0) {
const resetAt = nowMs + window.resetInMs;
if (resetAt > nowMs) candidates.push(resetAt);
}
}
if (candidates.length === 0) return undefined;
return Math.min(...candidates);
}
private getCodexUsageExpiryMs(usage: CodexUsage, nowMs: number): number {
const resetAtMs = this.getCodexResetAtMs(usage);
if (this.isCodexUsageLimitReached(usage)) {
if (resetAtMs) return resetAtMs;
return nowMs + AuthStorage.defaultBackoffMs;
}
const defaultExpiry = nowMs + AuthStorage.codexUsageCacheTtlMs;
if (!resetAtMs) return defaultExpiry;
return Math.min(defaultExpiry, resetAtMs);
}
private async getUsageReport(
provider: Provider,
credential: OAuthCredential,
options?: { baseUrl?: string },
): Promise<UsageReport | null> {
const resolver = this.usageProviderResolver;
const cache = this.usageCache;
if (!resolver || !cache) return null;
/** Fetches usage data from Codex API. */
private async fetchCodexUsage(credential: OAuthCredential, baseUrl?: string): Promise<CodexUsage | undefined> {
const accountId = credential.accountId;
if (!accountId) return undefined;
const providerImpl = resolver(provider);
if (!providerImpl) return null;
const normalizedBase = this.normalizeCodexBaseUrl(baseUrl);
const url = this.buildCodexUsageUrl(normalizedBase, this.getCodexUsagePath(normalizedBase));
const headers = {
authorization: `Bearer ${credential.access}`,
"chatgpt-account-id": accountId,
"openai-beta": "responses=experimental",
originator: "codex_cli_rs",
const params = {
provider,
credential: this.buildUsageCredential(credential),
baseUrl: options?.baseUrl,
};
try {
const response = await fetch(url, { headers });
if (!response.ok) {
logger.debug("AuthStorage codex usage fetch failed", {
status: response.status,
statusText: response.statusText,
});
return undefined;
}
if (providerImpl.supports && !providerImpl.supports(params)) return null;
const payload = (await response.json()) as unknown;
return this.extractCodexUsage(payload);
try {
return await providerImpl.fetchUsage(params, {
cache,
fetch: this.usageFetch,
now: this.usageNow,
logger: this.usageLogger,
});
} catch (error) {
logger.debug("AuthStorage codex usage fetch error", { error: String(error) });
return undefined;
logger.debug("AuthStorage usage fetch failed", {
provider,
error: String(error),
});
return null;
}
}
/** Gets usage data with caching to avoid redundant API calls. */
private async getCodexUsage(credential: OAuthCredential, baseUrl?: string): Promise<CodexUsage | undefined> {
const accountId = credential.accountId;
if (!accountId) return undefined;
async fetchUsageReports(options?: {
baseUrlResolver?: (provider: Provider) => string | undefined;
}): Promise<UsageReport[] | null> {
const resolver = this.usageProviderResolver;
const cache = this.usageCache;
if (!resolver || !cache) return null;
const normalizedBase = this.normalizeCodexBaseUrl(baseUrl);
const cacheKey = this.getCodexUsageCacheKey(accountId, normalizedBase);
const now = Date.now();
const tasks: Array<Promise<UsageReport | null>> = [];
const providers = new Set<string>([
...this.data.keys(),
...DEFAULT_USAGE_PROVIDERS.map((provider) => provider.id),
]);
for (const provider of providers) {
const providerImpl = resolver(provider as Provider);
if (!providerImpl) continue;
const baseUrl = options?.baseUrlResolver?.(provider as Provider);
let entries = this.getStoredCredentials(provider);
if (entries.length > 0) {
const dedupedEntries = this.pruneDuplicateStoredCredentials(provider, entries);
if (dedupedEntries.length !== entries.length) {
this.setStoredCredentials(provider, dedupedEntries);
}
entries = dedupedEntries;
}
if (now - this.lastCacheCleanup > AuthStorage.cacheCleanupIntervalMs) {
this.lastCacheCleanup = now;
this.storage.cleanExpiredCache();
}
if (entries.length === 0) {
const runtimeKey = this.runtimeOverrides.get(provider);
const envKey = getEnvApiKey(provider);
const apiKey = runtimeKey ?? envKey;
if (!apiKey) {
continue;
}
const params = {
provider: provider as Provider,
credential: { type: "api_key", apiKey } satisfies UsageCredential,
baseUrl,
};
if (providerImpl.supports && !providerImpl.supports(params)) {
continue;
}
tasks.push(
providerImpl
.fetchUsage(params, {
cache,
fetch: this.usageFetch,
now: this.usageNow,
logger: this.usageLogger,
})
.catch((error) => {
logger.debug("AuthStorage usage fetch failed", {
provider,
error: String(error),
});
return null;
}),
);
continue;
}
// Check in-memory cache first (fastest)
const memCached = this.codexUsageCache.get(cacheKey);
if (memCached && memCached.expiresAt > now) {
return memCached.usage;
}
for (const entry of entries) {
const credential = entry.credential;
const usageCredential: UsageCredential =
credential.type === "api_key"
? { type: "api_key", apiKey: credential.key }
: this.buildUsageCredential(credential);
const params = {
provider: provider as Provider,
credential: usageCredential,
baseUrl,
};
// Check DB cache (survives restarts)
const dbCached = this.storage.getCache(`codex_usage:${cacheKey}`);
if (dbCached) {
try {
const parsed = JSON.parse(dbCached) as CodexUsage;
// Store in memory for faster subsequent access
this.codexUsageCache.set(cacheKey, {
fetchedAt: now,
expiresAt: now + AuthStorage.codexUsageCacheTtlMs,
usage: parsed,
});
return parsed;
} catch {
// Invalid cache, continue to fetch
if (providerImpl.supports && !providerImpl.supports(params)) {
continue;
}
tasks.push(
providerImpl
.fetchUsage(params, {
cache,
fetch: this.usageFetch,
now: this.usageNow,
logger: this.usageLogger,
})
.catch((error) => {
logger.debug("AuthStorage usage fetch failed", {
provider,
error: String(error),
});
return null;
}),
);
}
}
// Fetch from API
const usage = await this.fetchCodexUsage(credential, normalizedBase);
if (usage) {
const expiresAt = this.getCodexUsageExpiryMs(usage, now);
this.codexUsageCache.set(cacheKey, { fetchedAt: now, expiresAt, usage });
// Store in DB with 60s TTL
this.storage.setCache(
`codex_usage:${cacheKey}`,
JSON.stringify(usage),
Math.floor((now + AuthStorage.codexUsageCacheTtlMs) / 1000),
);
return usage;
}
this.codexUsageCache.set(cacheKey, {
fetchedAt: now,
expiresAt: now + AuthStorage.defaultBackoffMs,
});
return undefined;
if (tasks.length === 0) return [];
const results = await Promise.all(tasks);
return results.filter((report): report is UsageReport => report !== null);
}
/**
* Marks the current session's credential as temporarily blocked due to usage limits.
* Queries the Codex usage API to determine accurate reset time.
* Uses usage reports to determine accurate reset time when available.
* Returns true if a credential was blocked, enabling automatic fallback to the next credential.
*/
async markUsageLimitReached(
@@ -818,15 +984,15 @@ export class AuthStorage {
if (!sessionCredential) return false;
const providerKey = this.getProviderTypeKey(provider, sessionCredential.type);
const now = Date.now();
const now = this.usageNow();
let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.defaultBackoffMs);
if (provider === "openai-codex" && sessionCredential.type === "oauth") {
const credential = this.getCredentialsForProvider(provider)[sessionCredential.index];
if (credential?.type === "oauth") {
const usage = await this.getCodexUsage(credential, options?.baseUrl);
if (usage) {
const resetAtMs = this.getCodexResetAtMs(usage);
const report = await this.getUsageReport(provider, credential, options);
if (report && this.isUsageLimitReached(report)) {
const resetAtMs = this.getUsageResetAtMs(report, this.usageNow());
if (resetAtMs && resetAtMs > blockedUntil) {
blockedUntil = resetAtMs;
}
@@ -848,7 +1014,7 @@ export class AuthStorage {
/**
* Resolves an OAuth API key, trying credentials in priority order.
* Skips blocked credentials and checks usage limits for Codex accounts.
* Skips blocked credentials and checks usage limits for providers with usage data.
* Falls back to earliest-unblocking credential if all are blocked.
*/
private async resolveOAuthApiKey(
@@ -902,14 +1068,18 @@ export class AuthStorage {
return undefined;
}
let usage: UsageReport | null = null;
let usageChecked = false;
if (checkUsage) {
const usage = await this.getCodexUsage(selection.credential, options?.baseUrl);
if (usage && this.isCodexUsageLimitReached(usage)) {
const resetAtMs = this.getCodexResetAtMs(usage);
usage = await this.getUsageReport(provider, selection.credential, options);
usageChecked = true;
if (usage && this.isUsageLimitReached(usage)) {
const resetAtMs = this.getUsageResetAtMs(usage, this.usageNow());
this.markCredentialBlocked(
providerKey,
selection.index,
resetAtMs ?? Date.now() + AuthStorage.defaultBackoffMs,
resetAtMs ?? this.usageNow() + AuthStorage.defaultBackoffMs,
);
return undefined;
}
@@ -927,13 +1097,16 @@ export class AuthStorage {
this.replaceCredentialAt(provider, selection.index, updated);
if (checkUsage) {
const usage = await this.getCodexUsage(updated, options?.baseUrl);
if (usage && this.isCodexUsageLimitReached(usage)) {
const resetAtMs = this.getCodexResetAtMs(usage);
const sameAccount = selection.credential.accountId === updated.accountId;
if (!usageChecked || !sameAccount) {
usage = await this.getUsageReport(provider, updated, options);
}
if (usage && this.isUsageLimitReached(usage)) {
const resetAtMs = this.getUsageResetAtMs(usage, this.usageNow());
this.markCredentialBlocked(
providerKey,
selection.index,
resetAtMs ?? Date.now() + AuthStorage.defaultBackoffMs,
resetAtMs ?? this.usageNow() + AuthStorage.defaultBackoffMs,
);
return undefined;
}
@@ -432,6 +432,13 @@ export class ModelRegistry {
return this.models.find((m) => m.provider === provider && m.id === modelId);
}
/**
* Get the base URL associated with a provider, if any model defines one.
*/
getProviderBaseUrl(provider: string): string | undefined {
return this.models.find((m) => m.provider === provider && m.baseUrl)?.baseUrl;
}
/**
* Get API key for a model.
*/
+13 -1
View File
@@ -87,10 +87,22 @@ export async function runFd(fdPath: string, args: string[], signal?: AbortSignal
throw err;
}
let exitError: unknown;
try {
await child.exited;
} catch (err) {
exitError = err;
if (err instanceof ptree.Exception && err.aborted) {
throw new Error("Operation aborted");
}
}
const exitCode = child.exitCode ?? (exitError instanceof ptree.Exception ? exitError.exitCode : null);
return {
stdout,
stderr: child.peekStderr(),
exitCode: child.exitCode,
exitCode,
};
}
@@ -1,6 +1,7 @@
import { mkdir, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
import { $ } from "bun";
import { nanoid } from "nanoid";
@@ -284,6 +285,33 @@ export class CommandController {
this.ctx.ui.requestRender();
}
async handleUsageCommand(reports?: UsageReport[] | null): Promise<void> {
let usageReports = reports ?? null;
if (!usageReports) {
const provider = this.ctx.session as { fetchUsageReports?: () => Promise<UsageReport[] | null> };
if (!provider.fetchUsageReports) {
this.ctx.showWarning("Usage reporting is not configured for this session.");
return;
}
try {
usageReports = await provider.fetchUsageReports();
} catch (error) {
this.ctx.showError(`Failed to fetch usage data: ${error instanceof Error ? error.message : String(error)}`);
return;
}
}
if (!usageReports || usageReports.length === 0) {
this.ctx.showWarning("No usage data available.");
return;
}
const output = renderUsageReports(usageReports, theme, Date.now());
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new Text(output, 1, 0));
this.ctx.ui.requestRender();
}
handleChangelogCommand(): void {
const changelogPath = getChangelogPath();
const allEntries = parseChangelog(changelogPath);
@@ -598,3 +626,307 @@ export class CommandController {
await this.ctx.flushCompactionQueue({ willRetry: false });
}
}
const BAR_WIDTH = 24;
const COLUMN_WIDTH = BAR_WIDTH + 2;
function formatProviderName(provider: string): string {
return provider
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : ""))
.join(" ");
}
function formatNumber(value: number, maxFractionDigits = 1): string {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: maxFractionDigits }).format(value);
}
function formatUsedAccounts(value: number): string {
return `${value.toFixed(2)} used`;
}
function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const days = Math.floor(hours / 24);
const hrs = hours % 24;
if (days > 0) return `${days}d ${hrs}h`;
if (hours > 0) return `${hours}h ${mins}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
function formatDurationShort(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const days = Math.floor(hours / 24);
const hrs = hours % 24;
if (days > 0) return `${days}d${hrs > 0 ? ` ${hrs}h` : ""}`;
if (hours > 0) return `${hours}h${mins > 0 ? ` ${mins}m` : ""}`;
if (minutes > 0) return `${minutes}m`;
return `${totalSeconds}s`;
}
function resolveFraction(limit: UsageLimit): number | undefined {
const amount = limit.amount;
if (amount.usedFraction !== undefined) return amount.usedFraction;
if (amount.used !== undefined && amount.limit !== undefined && amount.limit > 0) {
return amount.used / amount.limit;
}
if (amount.unit === "percent" && amount.used !== undefined) {
return amount.used / 100;
}
return undefined;
}
function formatLimitTitle(limit: UsageLimit): string {
const tier = limit.scope.tier;
if (tier && !limit.label.toLowerCase().includes(tier.toLowerCase())) {
return `${limit.label} (${tier})`;
}
return limit.label;
}
function formatWindowSuffix(label: string, windowLabel: string, uiTheme: typeof theme): string {
const normalizedLabel = label.toLowerCase();
const normalizedWindow = windowLabel.toLowerCase();
if (normalizedWindow === "quota window") return "";
if (normalizedLabel.includes(normalizedWindow)) return "";
return uiTheme.fg("dim", `(${windowLabel})`);
}
function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: number): string {
const email = (report.metadata?.email as string | undefined) ?? limit.scope.accountId;
if (email) return email;
const accountId = (report.metadata?.accountId as string | undefined) ?? limit.scope.accountId;
if (accountId) return accountId;
return `account ${index + 1}`;
}
function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined {
if (limit.window?.resetInMs !== undefined) {
return formatDurationShort(limit.window.resetInMs);
}
if (limit.window?.resetsAt !== undefined) {
return formatDurationShort(limit.window.resetsAt - nowMs);
}
return undefined;
}
function formatAccountHeader(limit: UsageLimit, report: UsageReport, index: number, nowMs: number): string {
const label = formatAccountLabel(limit, report, index);
const reset = formatResetShort(limit, nowMs);
if (!reset) return label;
return `${label} (${reset})`;
}
function padColumn(text: string, width: number): string {
const visible = visibleWidth(text);
if (visible >= width) return text;
return `${text}${" ".repeat(width - visible)}`;
}
function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
const hasOk = limits.some((limit) => limit.status === "ok");
const hasWarning = limits.some((limit) => limit.status === "warning");
const hasExhausted = limits.some((limit) => limit.status === "exhausted");
if (!hasOk && !hasWarning && !hasExhausted) return "unknown";
if (hasOk) {
return hasWarning || hasExhausted ? "warning" : "ok";
}
if (hasWarning) return "warning";
return "exhausted";
}
function isZeroUsage(limit: UsageLimit): boolean {
const amount = limit.amount;
if (amount.usedFraction !== undefined) return amount.usedFraction <= 0;
if (amount.used !== undefined) return amount.used <= 0;
if (amount.unit === "percent" && amount.used !== undefined) return amount.used <= 0;
if (amount.remainingFraction !== undefined) return amount.remainingFraction >= 1;
return false;
}
function isZeroUsageGroup(limits: UsageLimit[]): boolean {
return limits.length > 0 && limits.every((limit) => isZeroUsage(limit));
}
function formatAggregateAmount(limits: UsageLimit[]): string {
const fractions = limits
.map((limit) => resolveFraction(limit))
.filter((value): value is number => value !== undefined);
if (fractions.length === limits.length && fractions.length > 0) {
const sum = fractions.reduce((total, value) => total + value, 0);
const usedPct = Math.max(sum * 100, 0);
const remainingPct = Math.max(0, limits.length * 100 - usedPct);
const avgRemaining = limits.length > 0 ? remainingPct / limits.length : remainingPct;
return `${formatUsedAccounts(sum)} (${formatNumber(avgRemaining)}% left)`;
}
const amounts = limits
.map((limit) => limit.amount)
.filter((amount) => amount.used !== undefined && amount.limit !== undefined && amount.limit > 0);
if (amounts.length === limits.length && amounts.length > 0) {
const totalUsed = amounts.reduce((sum, amount) => sum + (amount.used ?? 0), 0);
const totalLimit = amounts.reduce((sum, amount) => sum + (amount.limit ?? 0), 0);
const usedPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
const remainingPct = Math.max(0, 100 - usedPct);
const usedAccounts = totalLimit > 0 ? (usedPct / 100) * limits.length : 0;
return `${formatUsedAccounts(usedAccounts)} (${formatNumber(remainingPct)}% left)`;
}
return `Accounts: ${limits.length}`;
}
function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
const resets = limits
.map((limit) => limit.window?.resetInMs ?? undefined)
.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > 0);
if (resets.length === 0) {
const absolute = limits
.map((limit) => limit.window?.resetsAt)
.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
if (absolute.length === 0) return null;
const earliest = Math.min(...absolute);
return `resets at ${new Date(earliest).toLocaleString()}`;
}
const minReset = Math.min(...resets);
const maxReset = Math.max(...resets);
if (maxReset - minReset > 60_000) {
return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
}
return `resets in ${formatDuration(minReset)}`;
}
function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error);
if (status === "warning") return uiTheme.fg("warning", uiTheme.status.warning);
if (status === "ok") return uiTheme.fg("success", uiTheme.status.success);
return uiTheme.fg("dim", uiTheme.status.pending);
}
function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning" | "error" | "dim" {
if (status === "exhausted") return "error";
if (status === "warning") return "warning";
if (status === "ok") return "success";
return "dim";
}
function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme): string {
const fraction = resolveFraction(limit);
if (fraction === undefined) {
return uiTheme.fg("dim", `[${"·".repeat(BAR_WIDTH)}]`);
}
const clamped = Math.min(Math.max(fraction, 0), 1);
const filled = Math.round(clamped * BAR_WIDTH);
const filledBar = "█".repeat(filled);
const emptyBar = "░".repeat(Math.max(0, BAR_WIDTH - filled));
const color = resolveStatusColor(limit.status);
return `${uiTheme.fg("dim", "[")}${uiTheme.fg(color, filledBar)}${uiTheme.fg("dim", emptyBar)}${uiTheme.fg("dim", "]")}`;
}
function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs: number): string {
const lines: string[] = [];
const latestFetchedAt = Math.max(...reports.map((report) => report.fetchedAt ?? 0));
const headerSuffix = latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : "";
lines.push(uiTheme.bold(uiTheme.fg("accent", `Usage${headerSuffix}`)));
const grouped = new Map<string, UsageReport[]>();
for (const report of reports) {
const list = grouped.get(report.provider) ?? [];
list.push(report);
grouped.set(report.provider, list);
}
for (const [provider, providerReports] of grouped.entries()) {
lines.push("");
const providerName = formatProviderName(provider);
const limitGroups = new Map<
string,
{ label: string; windowLabel: string; limits: UsageLimit[]; reports: UsageReport[] }
>();
for (const report of providerReports) {
for (const limit of report.limits) {
const windowId = limit.window?.id ?? limit.scope.windowId ?? "default";
const key = `${formatLimitTitle(limit)}|${windowId}`;
const windowLabel = limit.window?.label ?? windowId;
const entry = limitGroups.get(key) ?? {
label: formatLimitTitle(limit),
windowLabel,
limits: [],
reports: [],
};
entry.limits.push(limit);
entry.reports.push(report);
limitGroups.set(key, entry);
}
}
const providerAllZero = isZeroUsageGroup(Array.from(limitGroups.values()).flatMap((group) => group.limits));
if (providerAllZero) {
const providerTitle = `${resolveStatusIcon("ok", uiTheme)} ${uiTheme.fg("accent", `${providerName} (0%)`)}`;
lines.push(uiTheme.bold(providerTitle));
continue;
}
lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
for (const group of limitGroups.values()) {
const entries = group.limits.map((limit, index) => ({
limit,
report: group.reports[index],
fraction: resolveFraction(limit),
index,
}));
entries.sort((a, b) => {
const aFraction = a.fraction ?? -1;
const bFraction = b.fraction ?? -1;
if (aFraction !== bFraction) return bFraction - aFraction;
return a.index - b.index;
});
const sortedLimits = entries.map((entry) => entry.limit);
const sortedReports = entries.map((entry) => entry.report);
const status = resolveAggregateStatus(sortedLimits);
const statusIcon = resolveStatusIcon(status, uiTheme);
if (isZeroUsageGroup(sortedLimits)) {
const resetText = resolveResetRange(sortedLimits, nowMs);
const resetSuffix = resetText ? ` | ${resetText}` : "";
const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
lines.push(
`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix} ${uiTheme.fg(
"dim",
`0%${resetSuffix}`,
)}`.trim(),
);
continue;
}
const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim());
const accountLabels = sortedLimits.map((limit, index) =>
padColumn(formatAccountHeader(limit, sortedReports[index], index, nowMs), COLUMN_WIDTH),
);
lines.push(` ${accountLabels.join(" ")}`.trimEnd());
const bars = sortedLimits.map((limit) => padColumn(renderUsageBar(limit, uiTheme), COLUMN_WIDTH));
lines.push(` ${bars.join(" ")} ${formatAggregateAmount(sortedLimits)}`.trimEnd());
const resetText = sortedLimits.length <= 1 ? resolveResetRange(sortedLimits, nowMs) : null;
if (resetText) {
lines.push(` ${uiTheme.fg("dim", resetText)}`.trimEnd());
}
const notes = sortedLimits.flatMap((limit) => limit.notes ?? []);
if (notes.length > 0) {
lines.push(` ${uiTheme.fg("dim", notes.join(" • "))}`.trimEnd());
}
}
// No per-provider footer; global header shows last check.
}
return lines.join("\n");
}
@@ -211,6 +211,11 @@ export class InputController {
this.ctx.editor.setText("");
return;
}
if (text === "/usage") {
await this.ctx.handleUsageCommand();
this.ctx.editor.setText("");
return;
}
if (text === "/changelog") {
this.ctx.handleChangelogCommand();
this.ctx.editor.setText("");
@@ -5,7 +5,7 @@
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui";
import {
CombinedAutocompleteProvider,
@@ -187,6 +187,7 @@ export class InteractiveMode implements InteractiveModeContext {
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "session", description: "Show session info and stats" },
{ name: "usage", description: "Show provider usage and limits" },
{ name: "extensions", description: "Open Extension Control Center dashboard" },
{ name: "status", description: "Alias for /extensions" },
{ name: "changelog", description: "Show changelog entries" },
@@ -613,6 +614,10 @@ export class InteractiveMode implements InteractiveModeContext {
this.commandController.handleSessionCommand();
}
handleUsageCommand(reports?: UsageReport[] | null): Promise<void> {
return this.commandController.handleUsageCommand(reports);
}
handleChangelogCommand(): void {
this.commandController.handleChangelogCommand();
}
@@ -1,5 +1,5 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
import type { ExtensionUIContext } from "../../core/extensions/index";
@@ -134,6 +134,7 @@ export interface InteractiveModeContext {
handleShareCommand(): Promise<void>;
handleCopyCommand(): Promise<void>;
handleSessionCommand(): void;
handleUsageCommand(reports?: UsageReport[] | null): Promise<void>;
handleChangelogCommand(): void;
handleHotkeysCommand(): void;
handleDumpCommand(): Promise<void>;
+2
View File
@@ -4,6 +4,8 @@
"noEmit": true,
"paths": {
"*": ["./*"],
"@oh-my-pi/pi-ai": ["./packages/ai/src/index.ts"],
"@oh-my-pi/pi-ai/*": ["./packages/ai/src/*"],
"@oh-my-pi/pi-agent-core": ["./packages/agent/src/index.ts"],
"@oh-my-pi/pi-agent-core/*": ["./packages/agent/src/*"],
"@oh-my-pi/pi-utils": ["./packages/pi-utils/src/index.ts"],