@oh-my-pi/pi-ai 6.9.0 → 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.
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/providers/amazon-bedrock.ts +42 -16
- package/src/providers/anthropic.ts +8 -0
- package/src/providers/cursor.ts +27 -4
- package/src/providers/google-gemini-cli-usage.ts +271 -0
- package/src/providers/google-gemini-cli.ts +8 -0
- package/src/providers/google-shared.ts +10 -1
- package/src/providers/google-vertex.ts +8 -0
- package/src/providers/google.ts +8 -0
- package/src/providers/openai-codex/request-transformer.ts +4 -0
- package/src/providers/openai-codex-responses.ts +18 -1
- package/src/providers/openai-completions.ts +8 -0
- package/src/providers/openai-responses.ts +18 -1
- package/src/types.ts +2 -0
- package/src/usage/claude.ts +355 -0
- package/src/usage/github-copilot.ts +479 -0
- package/src/usage/google-antigravity.ts +218 -0
- package/src/usage/openai-codex.ts +393 -0
- package/src/usage/zai.ts +292 -0
- package/src/usage.ts +133 -0
|
@@ -50,6 +50,10 @@ function clampReasoningEffort(model: string, effort: ReasoningConfig["effort"]):
|
|
|
50
50
|
return "high";
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
if ((modelId === "gpt-5.2" || modelId === "gpt-5.2-codex") && effort === "minimal") {
|
|
54
|
+
return "low";
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
// gpt-5.1-codex-mini only supports medium/high.
|
|
54
58
|
if (modelId === "gpt-5.1-codex-mini") {
|
|
55
59
|
return effort === "high" || effort === "xhigh" ? "high" : "medium";
|
|
@@ -105,6 +105,9 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
|
|
105
105
|
const stream = new AssistantMessageEventStream();
|
|
106
106
|
|
|
107
107
|
(async () => {
|
|
108
|
+
const startTime = Date.now();
|
|
109
|
+
let firstTokenTime: number | undefined;
|
|
110
|
+
|
|
108
111
|
const output: AssistantMessage = {
|
|
109
112
|
role: "assistant",
|
|
110
113
|
content: [],
|
|
@@ -225,6 +228,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
|
|
225
228
|
if (!eventType) continue;
|
|
226
229
|
|
|
227
230
|
if (eventType === "response.output_item.added") {
|
|
231
|
+
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
228
232
|
const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall;
|
|
229
233
|
if (item.type === "reasoning") {
|
|
230
234
|
currentItem = item;
|
|
@@ -412,12 +416,16 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
|
|
412
416
|
throw new Error("Codex response failed");
|
|
413
417
|
}
|
|
414
418
|
|
|
419
|
+
output.duration = Date.now() - startTime;
|
|
420
|
+
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
415
421
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
416
422
|
stream.end();
|
|
417
423
|
} catch (error) {
|
|
418
424
|
for (const block of output.content) delete (block as { index?: number }).index;
|
|
419
425
|
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
420
426
|
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
|
427
|
+
output.duration = Date.now() - startTime;
|
|
428
|
+
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
421
429
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
422
430
|
stream.end();
|
|
423
431
|
}
|
|
@@ -563,6 +571,8 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|
|
563
571
|
for (const msg of transformedMessages) {
|
|
564
572
|
if (msg.role === "user") {
|
|
565
573
|
if (typeof msg.content === "string") {
|
|
574
|
+
// Skip empty user messages
|
|
575
|
+
if (!msg.content || msg.content.trim() === "") continue;
|
|
566
576
|
messages.push({
|
|
567
577
|
role: "user",
|
|
568
578
|
content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
|
|
@@ -581,9 +591,16 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|
|
581
591
|
image_url: `data:${item.mimeType};base64,${item.data}`,
|
|
582
592
|
} satisfies ResponseInputImage;
|
|
583
593
|
});
|
|
584
|
-
|
|
594
|
+
// Filter out images if model doesn't support them, and empty text blocks
|
|
595
|
+
let filteredContent = !model.input.includes("image")
|
|
585
596
|
? content.filter((c) => c.type !== "input_image")
|
|
586
597
|
: content;
|
|
598
|
+
filteredContent = filteredContent.filter((c) => {
|
|
599
|
+
if (c.type === "input_text") {
|
|
600
|
+
return c.text.trim().length > 0;
|
|
601
|
+
}
|
|
602
|
+
return true; // Keep non-text content (images)
|
|
603
|
+
});
|
|
587
604
|
if (filteredContent.length === 0) continue;
|
|
588
605
|
messages.push({
|
|
589
606
|
role: "user",
|
|
@@ -81,6 +81,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
81
81
|
const stream = new AssistantMessageEventStream();
|
|
82
82
|
|
|
83
83
|
(async () => {
|
|
84
|
+
const startTime = Date.now();
|
|
85
|
+
let firstTokenTime: number | undefined;
|
|
86
|
+
|
|
84
87
|
const output: AssistantMessage = {
|
|
85
88
|
role: "assistant",
|
|
86
89
|
content: [],
|
|
@@ -178,6 +181,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
178
181
|
choice.delta.content !== undefined &&
|
|
179
182
|
choice.delta.content.length > 0
|
|
180
183
|
) {
|
|
184
|
+
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
181
185
|
if (!currentBlock || currentBlock.type !== "text") {
|
|
182
186
|
finishCurrentBlock(currentBlock);
|
|
183
187
|
currentBlock = { type: "text", text: "" };
|
|
@@ -303,6 +307,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
303
307
|
throw new Error("An unkown error ocurred");
|
|
304
308
|
}
|
|
305
309
|
|
|
310
|
+
output.duration = Date.now() - startTime;
|
|
311
|
+
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
306
312
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
307
313
|
stream.end();
|
|
308
314
|
} catch (error) {
|
|
@@ -312,6 +318,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
312
318
|
// Some providers via OpenRouter include extra details here.
|
|
313
319
|
const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
|
|
314
320
|
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
|
|
321
|
+
output.duration = Date.now() - startTime;
|
|
322
|
+
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
315
323
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
316
324
|
stream.end();
|
|
317
325
|
}
|
|
@@ -69,6 +69,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
69
69
|
|
|
70
70
|
// Start async processing
|
|
71
71
|
(async () => {
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
let firstTokenTime: number | undefined;
|
|
74
|
+
|
|
72
75
|
const output: AssistantMessage = {
|
|
73
76
|
role: "assistant",
|
|
74
77
|
content: [],
|
|
@@ -107,6 +110,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
107
110
|
for await (const event of openaiStream) {
|
|
108
111
|
// Handle output item start
|
|
109
112
|
if (event.type === "response.output_item.added") {
|
|
113
|
+
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
110
114
|
const item = event.item;
|
|
111
115
|
if (item.type === "reasoning") {
|
|
112
116
|
currentItem = item;
|
|
@@ -309,12 +313,16 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
309
313
|
throw new Error("An unkown error ocurred");
|
|
310
314
|
}
|
|
311
315
|
|
|
316
|
+
output.duration = Date.now() - startTime;
|
|
317
|
+
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
312
318
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
313
319
|
stream.end();
|
|
314
320
|
} catch (error) {
|
|
315
321
|
for (const block of output.content) delete (block as any).index;
|
|
316
322
|
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
317
323
|
output.errorMessage = formatErrorMessageWithRetryAfter(error);
|
|
324
|
+
output.duration = Date.now() - startTime;
|
|
325
|
+
if (firstTokenTime) output.ttft = firstTokenTime - startTime;
|
|
318
326
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
319
327
|
stream.end();
|
|
320
328
|
}
|
|
@@ -460,6 +468,8 @@ function convertMessages(
|
|
|
460
468
|
for (const msg of transformedMessages) {
|
|
461
469
|
if (msg.role === "user") {
|
|
462
470
|
if (typeof msg.content === "string") {
|
|
471
|
+
// Skip empty user messages
|
|
472
|
+
if (!msg.content || msg.content.trim() === "") continue;
|
|
463
473
|
messages.push({
|
|
464
474
|
role: "user",
|
|
465
475
|
content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
|
|
@@ -479,9 +489,16 @@ function convertMessages(
|
|
|
479
489
|
} satisfies ResponseInputImage;
|
|
480
490
|
}
|
|
481
491
|
});
|
|
482
|
-
|
|
492
|
+
// Filter out images if model doesn't support them, and empty text blocks
|
|
493
|
+
let filteredContent = !model.input.includes("image")
|
|
483
494
|
? content.filter((c) => c.type !== "input_image")
|
|
484
495
|
: content;
|
|
496
|
+
filteredContent = filteredContent.filter((c) => {
|
|
497
|
+
if (c.type === "input_text") {
|
|
498
|
+
return c.text.trim().length > 0;
|
|
499
|
+
}
|
|
500
|
+
return true; // Keep non-text content (images)
|
|
501
|
+
});
|
|
485
502
|
if (filteredContent.length === 0) continue;
|
|
486
503
|
messages.push({
|
|
487
504
|
role: "user",
|
package/src/types.ts
CHANGED
|
@@ -193,6 +193,8 @@ export interface AssistantMessage {
|
|
|
193
193
|
stopReason: StopReason;
|
|
194
194
|
errorMessage?: string;
|
|
195
195
|
timestamp: number; // Unix timestamp in milliseconds
|
|
196
|
+
duration?: number; // Request duration in milliseconds
|
|
197
|
+
ttft?: number; // Time to first token in milliseconds
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
export interface ToolResultMessage<TDetails = any, TInput = unknown> {
|
|
@@ -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
|
+
};
|