@aaroncarry/pi-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh-CN.md +152 -0
- package/package.json +48 -0
- package/src/config.ts +156 -0
- package/src/credentials.ts +217 -0
- package/src/format.ts +35 -0
- package/src/index.ts +168 -0
- package/src/parse.ts +21 -0
- package/src/providers/anthropic.ts +76 -0
- package/src/providers/auto-detect.ts +343 -0
- package/src/providers/codex.ts +81 -0
- package/src/providers/custom.ts +100 -0
- package/src/providers/deepseek.ts +68 -0
- package/src/providers/github-copilot.ts +101 -0
- package/src/providers/index.ts +11 -0
- package/src/providers/openrouter.ts +52 -0
- package/src/providers/zai.ts +200 -0
- package/src/service.ts +275 -0
- package/src/session-usage.ts +57 -0
- package/src/types.ts +83 -0
- package/src/ui/card.ts +82 -0
- package/src/ui/statusline.ts +77 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolution for pi-usage.
|
|
3
|
+
*
|
|
4
|
+
* Prefers pi's model registry (`ctx.modelRegistry.getProviderAuth`), which
|
|
5
|
+
* refreshes OAuth tokens before they expire and persists them to auth.json.
|
|
6
|
+
* The auth.json fallback covers providers the registry does not know; it
|
|
7
|
+
* mirrors pi's config value interpolation rules ($ENV / ${ENV}, $$ and $!
|
|
8
|
+
* escapes, !command).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Location of pi's agent directory (mirrors pi's config.ts rules). */
|
|
17
|
+
export function getAgentDir(): string {
|
|
18
|
+
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StoredApiKeyCredential {
|
|
22
|
+
type: "api_key";
|
|
23
|
+
key?: string;
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StoredOAuthCredential {
|
|
28
|
+
type: "oauth";
|
|
29
|
+
access?: string;
|
|
30
|
+
refresh?: string;
|
|
31
|
+
expires?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type StoredCredential = StoredApiKeyCredential | StoredOAuthCredential;
|
|
35
|
+
|
|
36
|
+
/** Every provider id that has a recognizable credential in auth.json. */
|
|
37
|
+
export function readStoredCredentialIds(agentDir: string): string[] {
|
|
38
|
+
let raw: unknown;
|
|
39
|
+
try {
|
|
40
|
+
raw = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
|
|
45
|
+
return Object.entries(raw as Record<string, unknown>)
|
|
46
|
+
.filter(([, value]) => typeof value === "object" && value !== null && !Array.isArray(value))
|
|
47
|
+
.map(([id]) => id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readStoredCredential(agentDir: string, providerId: string): StoredCredential | undefined {
|
|
51
|
+
let raw: unknown;
|
|
52
|
+
try {
|
|
53
|
+
raw = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
58
|
+
const entries = raw as Record<string, unknown>;
|
|
59
|
+
const credential = entries[providerId];
|
|
60
|
+
if (typeof credential !== "object" || credential === null) return undefined;
|
|
61
|
+
const record = credential as Record<string, unknown>;
|
|
62
|
+
if (record.type === "api_key") {
|
|
63
|
+
return {
|
|
64
|
+
type: "api_key",
|
|
65
|
+
key: typeof record.key === "string" ? record.key : undefined,
|
|
66
|
+
env: readEnvRecord(record.env),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (record.type === "oauth") {
|
|
70
|
+
return {
|
|
71
|
+
type: "oauth",
|
|
72
|
+
access: typeof record.access === "string" ? record.access : undefined,
|
|
73
|
+
refresh: typeof record.refresh === "string" ? record.refresh : undefined,
|
|
74
|
+
expires: typeof record.expires === "number" ? record.expires : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Subset of pi-ai's AuthResult that pi-usage relies on. */
|
|
81
|
+
export interface RegistryAuth {
|
|
82
|
+
auth: { apiKey?: string; headers?: Record<string, string> };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ProviderAuthLookup = (providerId: string) => Promise<RegistryAuth | undefined>;
|
|
86
|
+
|
|
87
|
+
const BEARER_RE = /^Bearer\s+(.+)$/iu;
|
|
88
|
+
|
|
89
|
+
/** Extract the bearer token/api key from a registry auth result. */
|
|
90
|
+
export function tokenFromRegistryAuth(auth: RegistryAuth | undefined): string | undefined {
|
|
91
|
+
if (!auth) return undefined;
|
|
92
|
+
if (auth.auth.apiKey) return auth.auth.apiKey;
|
|
93
|
+
const authorization = Object.entries(auth.auth.headers ?? {}).find(
|
|
94
|
+
([name]) => name.toLowerCase() === "authorization",
|
|
95
|
+
)?.[1];
|
|
96
|
+
return typeof authorization === "string" ? BEARER_RE.exec(authorization)?.[1] : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** OAuth tokens this close to expiry are considered stale in the auth.json fallback. */
|
|
100
|
+
const OAUTH_FALLBACK_MIN_VALIDITY_MS = 60_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a usable token for a provider. Throws with an actionable message
|
|
104
|
+
* when no credential is configured or the fallback OAuth token is expired;
|
|
105
|
+
* the service turns the message into the account's `error`.
|
|
106
|
+
*/
|
|
107
|
+
export async function resolveProviderToken(
|
|
108
|
+
providerId: string,
|
|
109
|
+
lookup: ProviderAuthLookup | undefined,
|
|
110
|
+
agentDir: string,
|
|
111
|
+
): Promise<string> {
|
|
112
|
+
try {
|
|
113
|
+
const fromRegistry = tokenFromRegistryAuth(await lookup?.(providerId));
|
|
114
|
+
if (fromRegistry) return fromRegistry;
|
|
115
|
+
} catch {
|
|
116
|
+
// Registry lookup failed (unknown provider, refresh error); fall through.
|
|
117
|
+
}
|
|
118
|
+
const credential = readStoredCredential(agentDir, providerId);
|
|
119
|
+
if (!credential) {
|
|
120
|
+
throw new Error(`No credential configured for "${providerId}" (run /login or pi auth)`);
|
|
121
|
+
}
|
|
122
|
+
if (credential.type === "api_key") {
|
|
123
|
+
const key = credential.key ? resolveConfigValue(credential.key, credential.env) : undefined;
|
|
124
|
+
if (key) return key;
|
|
125
|
+
throw new Error(`Could not resolve API key for "${providerId}"`);
|
|
126
|
+
}
|
|
127
|
+
if (!credential.access) {
|
|
128
|
+
throw new Error(`OAuth credential for "${providerId}" has no access token`);
|
|
129
|
+
}
|
|
130
|
+
if (credential.expires !== undefined && Date.now() > credential.expires - OAUTH_FALLBACK_MIN_VALIDITY_MS) {
|
|
131
|
+
throw new Error(`OAuth token for "${providerId}" is expired; run /login or "pi auth check" to refresh`);
|
|
132
|
+
}
|
|
133
|
+
return credential.access;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a config value following pi's resolve-config-value rules:
|
|
138
|
+
* "!command" runs through the shell, "$ENV"/"${ENV}" interpolate, "$$"/"$!"
|
|
139
|
+
* escape literals, anything else is literal.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveConfigValue(config: string, env?: Record<string, string>): string | undefined {
|
|
142
|
+
if (config.startsWith("!")) return runCommand(config.slice(1));
|
|
143
|
+
return interpolateTemplate(config, env);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
147
|
+
const ENV_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
148
|
+
|
|
149
|
+
function interpolateTemplate(config: string, env?: Record<string, string>): string | undefined {
|
|
150
|
+
let out = "";
|
|
151
|
+
let index = 0;
|
|
152
|
+
while (index < config.length) {
|
|
153
|
+
const dollar = config.indexOf("$", index);
|
|
154
|
+
if (dollar < 0) {
|
|
155
|
+
out += config.slice(index);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
out += config.slice(index, dollar);
|
|
159
|
+
const next = config[dollar + 1];
|
|
160
|
+
if (next === "$" || next === "!") {
|
|
161
|
+
out += next;
|
|
162
|
+
index = dollar + 2;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (next === "{") {
|
|
166
|
+
const end = config.indexOf("}", dollar + 2);
|
|
167
|
+
if (end < 0) {
|
|
168
|
+
out += "$";
|
|
169
|
+
index = dollar + 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const name = config.slice(dollar + 2, end);
|
|
173
|
+
const value = ENV_NAME_RE.test(name) ? envValue(name, env) : undefined;
|
|
174
|
+
if (value === undefined) return undefined;
|
|
175
|
+
out += value;
|
|
176
|
+
index = end + 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const match = ENV_NAME_PREFIX_RE.exec(config.slice(dollar + 1));
|
|
180
|
+
if (match?.[0]) {
|
|
181
|
+
const value = envValue(match[0], env);
|
|
182
|
+
if (value === undefined) return undefined;
|
|
183
|
+
out += value;
|
|
184
|
+
index = dollar + 1 + match[0].length;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
out += "$";
|
|
188
|
+
index = dollar + 1;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function envValue(name: string, env?: Record<string, string>): string | undefined {
|
|
194
|
+
return env?.[name] || process.env[name] || undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runCommand(command: string): string | undefined {
|
|
198
|
+
try {
|
|
199
|
+
const output = execSync(command, {
|
|
200
|
+
encoding: "utf8",
|
|
201
|
+
timeout: 10_000,
|
|
202
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
203
|
+
});
|
|
204
|
+
return output.trim() || undefined;
|
|
205
|
+
} catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readEnvRecord(value: unknown): Record<string, string> | undefined {
|
|
211
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
212
|
+
const out: Record<string, string> = {};
|
|
213
|
+
for (const [name, envValueEntry] of Object.entries(value)) {
|
|
214
|
+
if (typeof envValueEntry === "string") out[name] = envValueEntry;
|
|
215
|
+
}
|
|
216
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
217
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Shared display formatting for panel and status line. */
|
|
2
|
+
|
|
3
|
+
import type { MoneyBalance } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const CURRENCY_SYMBOLS: Record<string, string> = { CNY: "¥", USD: "$", EUR: "€" };
|
|
6
|
+
|
|
7
|
+
export function formatMoney(balance: MoneyBalance): string {
|
|
8
|
+
const symbol = CURRENCY_SYMBOLS[balance.currency] ?? (balance.currency ? `${balance.currency} ` : "");
|
|
9
|
+
return `${symbol}${balance.amount.toFixed(2)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatBar(percent: number, cells = 10): string {
|
|
13
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
14
|
+
const filled = Math.round((clamped / 100) * cells);
|
|
15
|
+
return "█".repeat(filled) + "░".repeat(cells - filled);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Human-readable reset countdown: "now", "16m", "5h 12m", "6d 18h". */
|
|
19
|
+
export function formatResetDuration(resetsAt: number, now = Date.now()): string {
|
|
20
|
+
if (resetsAt - now <= 0) return "now";
|
|
21
|
+
const totalMinutes = Math.max(1, Math.ceil((resetsAt - now) / 60_000));
|
|
22
|
+
const days = Math.floor(totalMinutes / 1_440);
|
|
23
|
+
const hours = Math.floor(totalMinutes / 60) % 24;
|
|
24
|
+
const minutes = totalMinutes % 60;
|
|
25
|
+
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
26
|
+
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
27
|
+
return `${minutes}m`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** " · resets in 5h 12m" / " · resets now" / "" when unknown. */
|
|
31
|
+
export function formatResetSuffix(resetsAt: number | undefined, now = Date.now()): string {
|
|
32
|
+
if (resetsAt === undefined) return "";
|
|
33
|
+
const duration = formatResetDuration(resetsAt, now);
|
|
34
|
+
return duration === "now" ? " · resets now" : ` · resets in ${duration}`;
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-usage extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Registers the /usage command (prints an inline usage card into the session),
|
|
5
|
+
* keeps a footer status line updated with the active account's quota plus
|
|
6
|
+
* session consumption, and runs a session-scoped background refresh.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { isRecord, loadBalanceConfig, saveStatusMode, type StatusMode } from "./config.ts";
|
|
12
|
+
import { getAgentDir, tokenFromRegistryAuth } from "./credentials.ts";
|
|
13
|
+
import { sumSessionUsage, type SessionUsageTotals } from "./session-usage.ts";
|
|
14
|
+
import { BalanceService } from "./service.ts";
|
|
15
|
+
import { buildUsageCard, type UsageCardData } from "./ui/card.ts";
|
|
16
|
+
import { formatStatusLine } from "./ui/statusline.ts";
|
|
17
|
+
import type { CredentialResolver } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
const USAGE_ENTRY_TYPE = "usage-report";
|
|
20
|
+
/** Bound /usage fetches so a hanging provider API cannot stall the command. */
|
|
21
|
+
const USAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
const STATUS_MODES = ["active", "all", "off"] as const;
|
|
24
|
+
|
|
25
|
+
function isStatusMode(value: string): value is StatusMode {
|
|
26
|
+
return (STATUS_MODES as readonly string[]).includes(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function (pi: ExtensionAPI) {
|
|
30
|
+
let service: BalanceService | undefined;
|
|
31
|
+
let ui: ExtensionUIContext | undefined;
|
|
32
|
+
let activeProviderId: string | undefined;
|
|
33
|
+
let consumption: SessionUsageTotals | undefined;
|
|
34
|
+
/** `/usage <mode>` override for this session; wins over flag and usage.json. */
|
|
35
|
+
let statusOverride: StatusMode | undefined;
|
|
36
|
+
/** `--usage-status` CLI flag; wins over usage.json. */
|
|
37
|
+
let flagStatus: StatusMode | undefined;
|
|
38
|
+
|
|
39
|
+
pi.registerFlag("usage-status", {
|
|
40
|
+
description: "Footer status line mode: active | all | off (overrides usage.json)",
|
|
41
|
+
type: "string",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Registry-backed credential resolution: the token comes from
|
|
46
|
+
* getProviderAuth (which refreshes OAuth tokens before expiry), the base
|
|
47
|
+
* URL from the registered provider — the auto-detect adapter needs it to
|
|
48
|
+
* probe relay billing endpoints. Throwing sends the caller to the
|
|
49
|
+
* auth.json fallback.
|
|
50
|
+
*/
|
|
51
|
+
function credentialResolverFor(ctx: ExtensionContext): CredentialResolver {
|
|
52
|
+
return async (providerId) => {
|
|
53
|
+
const baseUrl = ctx.modelRegistry.getProvider(providerId)?.baseUrl;
|
|
54
|
+
const auth = await ctx.modelRegistry.getProviderAuth(providerId).catch(() => undefined);
|
|
55
|
+
if (!auth) {
|
|
56
|
+
throw new Error(`Provider "${providerId}" not found in model registry`);
|
|
57
|
+
}
|
|
58
|
+
const headers: Record<string, string> = {};
|
|
59
|
+
for (const [name, value] of Object.entries(auth.auth.headers ?? {})) {
|
|
60
|
+
if (typeof value === "string") headers[name] = value;
|
|
61
|
+
}
|
|
62
|
+
const token = tokenFromRegistryAuth({ auth: { apiKey: auth.auth.apiKey, headers } });
|
|
63
|
+
if (!token) throw new Error(`No usable credential for "${providerId}"`);
|
|
64
|
+
return { token, baseUrl };
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updateStatus(): void {
|
|
69
|
+
if (!service || !ui) return;
|
|
70
|
+
const mode = statusOverride ?? flagStatus ?? service.config.status ?? "active";
|
|
71
|
+
const text = formatStatusLine({
|
|
72
|
+
balances: service.getAll(),
|
|
73
|
+
mode,
|
|
74
|
+
activeProviderId,
|
|
75
|
+
theme: ui.theme,
|
|
76
|
+
consumption,
|
|
77
|
+
});
|
|
78
|
+
ui.setStatus("usage", text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureService(ctx: ExtensionContext): BalanceService {
|
|
82
|
+
if (service) return service;
|
|
83
|
+
const agentDir = getAgentDir();
|
|
84
|
+
const next = new BalanceService({ agentDir, config: loadBalanceConfig(agentDir) });
|
|
85
|
+
next.setCredentialResolver(credentialResolverFor(ctx));
|
|
86
|
+
service = next;
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pi.registerEntryRenderer(USAGE_ENTRY_TYPE, (entry, _options, theme) => {
|
|
91
|
+
if (!isRecord(entry.data) || !Array.isArray(entry.data.balances)) {
|
|
92
|
+
return buildUsageCard({ balances: [], generatedAt: Date.now() }, theme);
|
|
93
|
+
}
|
|
94
|
+
const data: UsageCardData = {
|
|
95
|
+
balances: entry.data.balances as UsageCardData["balances"],
|
|
96
|
+
activeProviderId: typeof entry.data.activeProviderId === "string" ? entry.data.activeProviderId : undefined,
|
|
97
|
+
generatedAt: typeof entry.data.generatedAt === "number" ? entry.data.generatedAt : Date.now(),
|
|
98
|
+
};
|
|
99
|
+
return buildUsageCard(data, theme);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.registerCommand("usage", {
|
|
103
|
+
description: "Show usage card, or set the footer status line mode",
|
|
104
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
105
|
+
const matches = STATUS_MODES.filter((mode) => mode.startsWith(prefix.toLowerCase()));
|
|
106
|
+
return matches.length > 0 ? matches.map((mode) => ({ value: mode, label: mode })) : null;
|
|
107
|
+
},
|
|
108
|
+
handler: async (args, ctx) => {
|
|
109
|
+
const argument = args?.trim().toLowerCase();
|
|
110
|
+
if (!argument) {
|
|
111
|
+
const current = ensureService(ctx);
|
|
112
|
+
await current.refreshAll({ signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS) });
|
|
113
|
+
pi.appendEntry(USAGE_ENTRY_TYPE, {
|
|
114
|
+
balances: current.getAll(),
|
|
115
|
+
activeProviderId,
|
|
116
|
+
generatedAt: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!isStatusMode(argument)) {
|
|
121
|
+
ctx.ui.notify(`/usage: unknown mode "${argument}" — use active, all, or off`, "warning");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
statusOverride = argument;
|
|
125
|
+
saveStatusMode(getAgentDir(), argument);
|
|
126
|
+
updateStatus();
|
|
127
|
+
ctx.ui.notify(`Footer status line: ${argument} (saved to usage.json)`, "info");
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
132
|
+
// Session replacement tears the old service down via session_shutdown;
|
|
133
|
+
// build a fresh one bound to the new session context.
|
|
134
|
+
const cliFlag = pi.getFlag("usage-status");
|
|
135
|
+
flagStatus = typeof cliFlag === "string" && isStatusMode(cliFlag) ? cliFlag : undefined;
|
|
136
|
+
statusOverride = undefined;
|
|
137
|
+
const agentDir = getAgentDir();
|
|
138
|
+
const next = new BalanceService({ agentDir, config: loadBalanceConfig(agentDir) });
|
|
139
|
+
next.setCredentialResolver(credentialResolverFor(ctx));
|
|
140
|
+
next.onChange(updateStatus);
|
|
141
|
+
service?.stop();
|
|
142
|
+
service = next;
|
|
143
|
+
ui = ctx.ui;
|
|
144
|
+
activeProviderId = ctx.model?.provider;
|
|
145
|
+
consumption = sumSessionUsage(ctx.sessionManager.getEntries());
|
|
146
|
+
next.start();
|
|
147
|
+
updateStatus();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Session tokens/cost are local data: refresh immediately after every turn
|
|
151
|
+
// instead of waiting for the provider quota polling cycle.
|
|
152
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
153
|
+
consumption = sumSessionUsage(ctx.sessionManager.getEntries());
|
|
154
|
+
updateStatus();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("model_select", async (event) => {
|
|
158
|
+
activeProviderId = event.model.provider;
|
|
159
|
+
updateStatus();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
pi.on("session_shutdown", async () => {
|
|
163
|
+
service?.stop();
|
|
164
|
+
service = undefined;
|
|
165
|
+
ui = undefined;
|
|
166
|
+
consumption = undefined;
|
|
167
|
+
});
|
|
168
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Shared response-field parsing helpers for provider adapters. */
|
|
2
|
+
|
|
3
|
+
/** Accept numbers or numeric strings ("0.00" style). */
|
|
4
|
+
export function toNumber(value: unknown): number | undefined {
|
|
5
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
6
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalize timestamps across providers: epoch seconds, epoch milliseconds,
|
|
12
|
+
* or ISO-8601 strings. Returns epoch milliseconds, or undefined.
|
|
13
|
+
*/
|
|
14
|
+
export function parseTimestamp(value: unknown): number | undefined {
|
|
15
|
+
if (typeof value === "number" && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value;
|
|
16
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
17
|
+
const parsed = Date.parse(value);
|
|
18
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Claude (subscription OAuth) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Query method borrowed from CodexBar: the Claude Code OAuth access token
|
|
5
|
+
* stored by pi in auth.json is accepted by the OAuth usage endpoint with the
|
|
6
|
+
* matching beta header.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseTimestamp, toNumber } from "../parse.ts";
|
|
10
|
+
import type { AccountBalance, ProviderAdapter, ProviderFetchArgs, UsageWindow } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
const ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
|
|
13
|
+
const BETA_HEADER = "oauth-2025-04-20";
|
|
14
|
+
|
|
15
|
+
interface AnthropicUsageWindow {
|
|
16
|
+
utilization?: unknown;
|
|
17
|
+
resets_at?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AnthropicUsageResponse {
|
|
21
|
+
five_hour?: unknown;
|
|
22
|
+
seven_day?: unknown;
|
|
23
|
+
extra_usage?: {
|
|
24
|
+
is_enabled?: unknown;
|
|
25
|
+
monthly_limit?: unknown;
|
|
26
|
+
used_credits?: unknown;
|
|
27
|
+
} | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseWindow(label: string, raw: unknown): UsageWindow | undefined {
|
|
31
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
32
|
+
const window = raw as AnthropicUsageWindow;
|
|
33
|
+
const usedPercent = toNumber(window.utilization);
|
|
34
|
+
if (usedPercent === undefined) return undefined;
|
|
35
|
+
return { label, usedPercent, resetsAt: parseTimestamp(window.resets_at) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const anthropicAdapter: ProviderAdapter = {
|
|
39
|
+
id: "anthropic",
|
|
40
|
+
label: "Claude",
|
|
41
|
+
async fetch({ token, signal, fetchImpl }: ProviderFetchArgs): Promise<AccountBalance> {
|
|
42
|
+
const response = await fetchImpl(ENDPOINT, {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
"anthropic-beta": BETA_HEADER,
|
|
46
|
+
Accept: "application/json",
|
|
47
|
+
},
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Claude usage API returned HTTP ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
const body: unknown = await response.json();
|
|
54
|
+
if (typeof body !== "object" || body === null) {
|
|
55
|
+
throw new Error("Claude usage API returned an unexpected response");
|
|
56
|
+
}
|
|
57
|
+
const usage = body as AnthropicUsageResponse;
|
|
58
|
+
const windows: UsageWindow[] = [];
|
|
59
|
+
const fiveHour = parseWindow("5h", usage.five_hour);
|
|
60
|
+
if (fiveHour) windows.push(fiveHour);
|
|
61
|
+
const sevenDay = parseWindow("weekly", usage.seven_day);
|
|
62
|
+
if (sevenDay) windows.push(sevenDay);
|
|
63
|
+
if (windows.length === 0) {
|
|
64
|
+
throw new Error("Claude usage API returned no usage windows");
|
|
65
|
+
}
|
|
66
|
+
const notes: string[] = [];
|
|
67
|
+
const extra = usage.extra_usage;
|
|
68
|
+
const monthlyLimit = toNumber(extra?.monthly_limit);
|
|
69
|
+
const usedCredits = toNumber(extra?.used_credits);
|
|
70
|
+
if (extra?.is_enabled === true && monthlyLimit !== undefined) {
|
|
71
|
+
const used = usedCredits !== undefined ? ` ${usedCredits}` : "";
|
|
72
|
+
notes.push(`extra usage:${used} of ${monthlyLimit}`);
|
|
73
|
+
}
|
|
74
|
+
return { providerId: "anthropic", label: "Claude", windows, notes, fetchedAt: Date.now() };
|
|
75
|
+
},
|
|
76
|
+
};
|