@oh-my-pi/pi-ai 6.9.69 → 7.0.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.
@@ -0,0 +1,355 @@
1
+ import type {
2
+ UsageAmount,
3
+ UsageFetchContext,
4
+ UsageFetchParams,
5
+ UsageLimit,
6
+ UsageProvider,
7
+ UsageReport,
8
+ UsageStatus,
9
+ UsageWindow,
10
+ } from "../usage";
11
+
12
+ const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
13
+ const DEFAULT_CACHE_TTL_MS = 60_000;
14
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
15
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const MAX_RETRIES = 3;
17
+ const BASE_RETRY_DELAY_MS = 500;
18
+
19
+ const CLAUDE_HEADERS = {
20
+ accept: "application/json, text/plain, */*",
21
+ "accept-encoding": "gzip, compress, deflate, br",
22
+ "anthropic-beta": "oauth-2025-04-20",
23
+ "content-type": "application/json",
24
+ "user-agent": "claude-code/2.0.20",
25
+ connection: "keep-alive",
26
+ } as const;
27
+
28
+ function normalizeClaudeBaseUrl(baseUrl?: string): string {
29
+ if (!baseUrl || !baseUrl.trim()) return DEFAULT_ENDPOINT;
30
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
31
+ const lower = trimmed.toLowerCase();
32
+ if (lower.endsWith("/api/oauth")) return trimmed;
33
+ let url: URL;
34
+ try {
35
+ url = new URL(trimmed);
36
+ } catch {
37
+ return DEFAULT_ENDPOINT;
38
+ }
39
+ let path = url.pathname.replace(/\/+$/, "");
40
+ if (path === "/") path = "";
41
+ if (path.toLowerCase().endsWith("/v1")) {
42
+ path = path.slice(0, -3);
43
+ }
44
+ if (!path) return `${url.origin}/api/oauth`;
45
+ return `${url.origin}${path}/api/oauth`;
46
+ }
47
+
48
+ interface ClaudeUsageBucket {
49
+ utilization?: number;
50
+ resets_at?: string;
51
+ }
52
+
53
+ interface ParsedUsageBucket {
54
+ utilization?: number;
55
+ resetsAt?: number;
56
+ }
57
+
58
+ interface ClaudeUsageResponse {
59
+ five_hour?: ClaudeUsageBucket | null;
60
+ seven_day?: ClaudeUsageBucket | null;
61
+ seven_day_opus?: ClaudeUsageBucket | null;
62
+ seven_day_sonnet?: ClaudeUsageBucket | null;
63
+ }
64
+
65
+ type ClaudeUsagePayload = {
66
+ payload: ClaudeUsageResponse;
67
+ orgId?: string;
68
+ };
69
+
70
+ function isRecord(value: unknown): value is Record<string, unknown> {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+
74
+ function toNumber(value: unknown): number | undefined {
75
+ if (typeof value === "number" && Number.isFinite(value)) return value;
76
+ if (typeof value === "string" && value.trim()) {
77
+ const parsed = Number(value);
78
+ return Number.isFinite(parsed) ? parsed : undefined;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ function parseIsoTime(value: string | undefined): number | undefined {
84
+ if (!value) return undefined;
85
+ const parsed = Date.parse(value);
86
+ return Number.isFinite(parsed) ? parsed : undefined;
87
+ }
88
+
89
+ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
90
+ if (!isRecord(bucket)) return undefined;
91
+ const utilization = toNumber(bucket.utilization);
92
+ const resetsAt = parseIsoTime(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined);
93
+ if (utilization === undefined && resetsAt === undefined) {
94
+ if ("utilization" in bucket || "resets_at" in bucket) {
95
+ return { utilization: 0, resetsAt: undefined };
96
+ }
97
+ return undefined;
98
+ }
99
+ return { utilization, resetsAt };
100
+ }
101
+
102
+ function getPayloadString(payload: Record<string, unknown>, key: string): string | undefined {
103
+ const value = payload[key];
104
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
105
+ }
106
+
107
+ function extractUsageIdentity(payload: ClaudeUsageResponse, orgId?: string): { accountId?: string; email?: string } {
108
+ if (!isRecord(payload)) return { accountId: orgId };
109
+ const accountId =
110
+ getPayloadString(payload, "account_id") ??
111
+ getPayloadString(payload, "accountId") ??
112
+ getPayloadString(payload, "user_id") ??
113
+ getPayloadString(payload, "userId") ??
114
+ getPayloadString(payload, "org_id") ??
115
+ getPayloadString(payload, "orgId") ??
116
+ orgId;
117
+ const email =
118
+ getPayloadString(payload, "email") ??
119
+ getPayloadString(payload, "user_email") ??
120
+ getPayloadString(payload, "userEmail");
121
+ return { accountId, email };
122
+ }
123
+
124
+ function hasUsageData(payload: ClaudeUsageResponse): boolean {
125
+ return Boolean(payload.five_hour || payload.seven_day || payload.seven_day_opus || payload.seven_day_sonnet);
126
+ }
127
+
128
+ async function fetchUsagePayload(
129
+ url: string,
130
+ headers: Record<string, string>,
131
+ ctx: UsageFetchContext,
132
+ signal?: AbortSignal,
133
+ ): Promise<ClaudeUsagePayload | null> {
134
+ let lastPayload: ClaudeUsageResponse | null = null;
135
+ let lastOrgId: string | undefined;
136
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
137
+ try {
138
+ const response = await ctx.fetch(url, { headers, signal });
139
+ if (!response.ok) {
140
+ ctx.logger?.warn("Claude usage fetch failed", { status: response.status, statusText: response.statusText });
141
+ return null;
142
+ }
143
+ const payload = (await response.json()) as ClaudeUsageResponse;
144
+ lastPayload = payload;
145
+ const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
146
+ lastOrgId = orgId ?? lastOrgId;
147
+ if (payload && isRecord(payload) && hasUsageData(payload)) {
148
+ return { payload, orgId };
149
+ }
150
+ } catch (error) {
151
+ ctx.logger?.warn("Claude usage fetch error", { error: String(error) });
152
+ return null;
153
+ }
154
+
155
+ if (attempt < MAX_RETRIES - 1) {
156
+ await Bun.sleep(BASE_RETRY_DELAY_MS * 2 ** attempt);
157
+ }
158
+ }
159
+
160
+ return lastPayload ? { payload: lastPayload, orgId: lastOrgId } : null;
161
+ }
162
+
163
+ function buildUsageAmount(utilization: number | undefined): UsageAmount | undefined {
164
+ if (utilization === undefined) return undefined;
165
+ const clamped = Math.min(Math.max(utilization, 0), 100);
166
+ const usedFraction = clamped / 100;
167
+ return {
168
+ used: clamped,
169
+ limit: 100,
170
+ remaining: Math.max(0, 100 - clamped),
171
+ usedFraction,
172
+ remainingFraction: Math.max(0, 1 - usedFraction),
173
+ unit: "percent",
174
+ };
175
+ }
176
+
177
+ function buildUsageWindow(
178
+ id: string,
179
+ label: string,
180
+ durationMs: number,
181
+ resetsAt: number | undefined,
182
+ now: number,
183
+ ): UsageWindow {
184
+ const resolvedResetAt = resetsAt ?? now + durationMs;
185
+ const resetInMs = Math.max(0, resolvedResetAt - now);
186
+ return {
187
+ id,
188
+ label,
189
+ durationMs,
190
+ resetsAt: resolvedResetAt,
191
+ resetInMs,
192
+ };
193
+ }
194
+
195
+ function buildUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
196
+ if (usedFraction === undefined) return undefined;
197
+ if (usedFraction >= 1) return "exhausted";
198
+ if (usedFraction >= 0.9) return "warning";
199
+ return "ok";
200
+ }
201
+
202
+ function buildUsageLimit(args: {
203
+ id: string;
204
+ label: string;
205
+ windowId: string;
206
+ windowLabel: string;
207
+ durationMs: number;
208
+ bucket: ParsedUsageBucket | undefined;
209
+ provider: "anthropic";
210
+ tier?: string;
211
+ shared?: boolean;
212
+ now: number;
213
+ }): UsageLimit | null {
214
+ if (!args.bucket) return null;
215
+ const amount = buildUsageAmount(args.bucket.utilization);
216
+ if (!amount) return null;
217
+ const window = buildUsageWindow(args.windowId, args.windowLabel, args.durationMs, args.bucket.resetsAt, args.now);
218
+ return {
219
+ id: args.id,
220
+ label: args.label,
221
+ scope: {
222
+ provider: args.provider,
223
+ windowId: args.windowId,
224
+ tier: args.tier,
225
+ shared: args.shared,
226
+ },
227
+ window,
228
+ amount,
229
+ status: buildUsageStatus(amount.usedFraction),
230
+ };
231
+ }
232
+
233
+ function buildCacheKey(params: UsageFetchParams): string {
234
+ const credential = params.credential;
235
+ const account = credential.accountId ?? credential.email ?? "unknown";
236
+ const token = credential.accessToken ?? credential.refreshToken;
237
+ const fingerprint = token && typeof token === "string" ? Bun.hash(token).toString(16) : "anonymous";
238
+ const baseUrl = params.baseUrl ?? DEFAULT_ENDPOINT;
239
+ return `usage:${params.provider}:${account}:${fingerprint}:${baseUrl}`;
240
+ }
241
+
242
+ function resolveCacheExpiry(now: number, limits: UsageLimit[]): number {
243
+ const earliestReset = limits
244
+ .map((limit) => limit.window?.resetsAt)
245
+ .filter((value): value is number => typeof value === "number" && Number.isFinite(value))
246
+ .reduce((min, value) => (min === undefined ? value : Math.min(min, value)), undefined as number | undefined);
247
+ const exhausted = limits.some((limit) => limit.status === "exhausted");
248
+ if (earliestReset === undefined) return now + DEFAULT_CACHE_TTL_MS;
249
+ if (exhausted) return earliestReset;
250
+ return Math.min(now + DEFAULT_CACHE_TTL_MS, earliestReset);
251
+ }
252
+
253
+ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
254
+ if (params.provider !== "anthropic") return null;
255
+ const credential = params.credential;
256
+ if (credential.type !== "oauth" || !credential.accessToken) return null;
257
+
258
+ const cacheKey = buildCacheKey(params);
259
+ const cachedEntry = await ctx.cache.get(cacheKey);
260
+ const now = ctx.now();
261
+ if (cachedEntry && cachedEntry.expiresAt > now) {
262
+ return cachedEntry.value;
263
+ }
264
+ const cachedValue = cachedEntry?.value ?? null;
265
+
266
+ const baseUrl = normalizeClaudeBaseUrl(params.baseUrl);
267
+ const url = `${baseUrl}/usage`;
268
+ const headers: Record<string, string> = {
269
+ ...CLAUDE_HEADERS,
270
+ authorization: `Bearer ${credential.accessToken}`,
271
+ };
272
+
273
+ const payloadResult = await fetchUsagePayload(url, headers, ctx, params.signal);
274
+ if (!payloadResult || !isRecord(payloadResult.payload)) return cachedValue;
275
+ const { payload, orgId } = payloadResult;
276
+
277
+ const fiveHour = parseBucket(payload.five_hour);
278
+ const sevenDay = parseBucket(payload.seven_day);
279
+ const sevenDayOpus = parseBucket(payload.seven_day_opus);
280
+ const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
281
+
282
+ const limits = [
283
+ buildUsageLimit({
284
+ id: "anthropic:5h",
285
+ label: "Claude 5 Hour",
286
+ windowId: "5h",
287
+ windowLabel: "5 Hour",
288
+ durationMs: FIVE_HOURS_MS,
289
+ bucket: fiveHour,
290
+ provider: "anthropic",
291
+ shared: true,
292
+ now,
293
+ }),
294
+ buildUsageLimit({
295
+ id: "anthropic:7d",
296
+ label: "Claude 7 Day",
297
+ windowId: "7d",
298
+ windowLabel: "7 Day",
299
+ durationMs: SEVEN_DAYS_MS,
300
+ bucket: sevenDay,
301
+ provider: "anthropic",
302
+ shared: true,
303
+ now,
304
+ }),
305
+ buildUsageLimit({
306
+ id: "anthropic:7d:opus",
307
+ label: "Claude 7 Day (Opus)",
308
+ windowId: "7d",
309
+ windowLabel: "7 Day",
310
+ durationMs: SEVEN_DAYS_MS,
311
+ bucket: sevenDayOpus,
312
+ provider: "anthropic",
313
+ tier: "opus",
314
+ now,
315
+ }),
316
+ buildUsageLimit({
317
+ id: "anthropic:7d:sonnet",
318
+ label: "Claude 7 Day (Sonnet)",
319
+ windowId: "7d",
320
+ windowLabel: "7 Day",
321
+ durationMs: SEVEN_DAYS_MS,
322
+ bucket: sevenDaySonnet,
323
+ provider: "anthropic",
324
+ tier: "sonnet",
325
+ now,
326
+ }),
327
+ ].filter((limit): limit is UsageLimit => limit !== null);
328
+
329
+ if (limits.length === 0) return cachedValue;
330
+ const identity = extractUsageIdentity(payload, orgId);
331
+ const accountId = identity.accountId ?? credential.accountId;
332
+ const email = identity.email ?? credential.email;
333
+
334
+ const report: UsageReport = {
335
+ provider: params.provider,
336
+ fetchedAt: now,
337
+ limits,
338
+ metadata: {
339
+ accountId,
340
+ email,
341
+ endpoint: url,
342
+ },
343
+ raw: payload,
344
+ };
345
+
346
+ const expiresAt = resolveCacheExpiry(now, limits);
347
+ await ctx.cache.set(cacheKey, { value: report, expiresAt });
348
+ return report;
349
+ }
350
+
351
+ export const claudeUsageProvider: UsageProvider = {
352
+ id: "anthropic",
353
+ fetchUsage: fetchClaudeUsage,
354
+ supports: (params) => params.provider === "anthropic" && params.credential.type === "oauth",
355
+ };