@oh-my-pi/pi-ai 6.9.69 → 8.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.
@@ -8,8 +8,8 @@ import type {
8
8
  ChatCompletionMessageParam,
9
9
  ChatCompletionToolMessageParam,
10
10
  } from "openai/resources/chat/completions";
11
- import { calculateCost } from "../models";
12
- import { getEnvApiKey } from "../stream";
11
+ import { calculateCost } from "$ai/models";
12
+ import { getEnvApiKey } from "$ai/stream";
13
13
  import type {
14
14
  AssistantMessage,
15
15
  Context,
@@ -23,11 +23,12 @@ import type {
23
23
  ThinkingContent,
24
24
  Tool,
25
25
  ToolCall,
26
- } from "../types";
27
- import { AssistantMessageEventStream } from "../utils/event-stream";
28
- import { parseStreamingJson } from "../utils/json-parse";
29
- import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
30
- import { sanitizeSurrogates } from "../utils/sanitize-unicode";
26
+ ToolResultMessage,
27
+ } from "$ai/types";
28
+ import { AssistantMessageEventStream } from "$ai/utils/event-stream";
29
+ import { parseStreamingJson } from "$ai/utils/json-parse";
30
+ import { formatErrorMessageWithRetryAfter } from "$ai/utils/retry-after";
31
+ import { sanitizeSurrogates } from "$ai/utils/sanitize-unicode";
31
32
  import { transformMessages } from "./transform-messages";
32
33
 
33
34
  /**
@@ -464,7 +465,7 @@ function maybeAddOpenRouterAnthropicCacheControl(
464
465
  }
465
466
  }
466
467
 
467
- function convertMessages(
468
+ export function convertMessages(
468
469
  model: Model<"openai-completions">,
469
470
  context: Context,
470
471
  compat: Required<OpenAICompat>,
@@ -481,7 +482,8 @@ function convertMessages(
481
482
 
482
483
  let lastRole: string | null = null;
483
484
 
484
- for (const msg of transformedMessages) {
485
+ for (let i = 0; i < transformedMessages.length; i++) {
486
+ const msg = transformedMessages[i];
485
487
  // Some providers (e.g. Mistral/Devstral) don't allow user messages directly after tool results
486
488
  // Insert a synthetic assistant message to bridge the gap
487
489
  if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") {
@@ -605,55 +607,73 @@ function convertMessages(
605
607
  }
606
608
  params.push(assistantMsg);
607
609
  } else if (msg.role === "toolResult") {
608
- // Extract text and image content
609
- const textResult = msg.content
610
- .filter((c) => c.type === "text")
611
- .map((c) => (c as any).text)
612
- .join("\n");
613
- const hasImages = msg.content.some((c) => c.type === "image");
614
-
615
- // Always send tool result with text (or placeholder if only images)
616
- const hasText = textResult.length > 0;
617
- // Some providers (e.g. Mistral) require the 'name' field in tool results
618
- const toolResultMsg: ChatCompletionToolMessageParam = {
619
- role: "tool",
620
- content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
621
- tool_call_id: normalizeMistralToolId(msg.toolCallId, compat.requiresMistralToolIds),
622
- };
623
- if (compat.requiresToolResultName && msg.toolName) {
624
- (toolResultMsg as any).name = msg.toolName;
610
+ // Batch consecutive tool results and collect all images
611
+ const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = [];
612
+ let j = i;
613
+
614
+ for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) {
615
+ const toolMsg = transformedMessages[j] as ToolResultMessage;
616
+
617
+ // Extract text and image content
618
+ const textResult = toolMsg.content
619
+ .filter((c) => c.type === "text")
620
+ .map((c) => (c as any).text)
621
+ .join("\n");
622
+ const hasImages = toolMsg.content.some((c) => c.type === "image");
623
+
624
+ // Always send tool result with text (or placeholder if only images)
625
+ const hasText = textResult.length > 0;
626
+ // Some providers (e.g. Mistral) require the 'name' field in tool results
627
+ const toolResultMsg: ChatCompletionToolMessageParam = {
628
+ role: "tool",
629
+ content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
630
+ tool_call_id: normalizeMistralToolId(toolMsg.toolCallId, compat.requiresMistralToolIds),
631
+ };
632
+ if (compat.requiresToolResultName && toolMsg.toolName) {
633
+ (toolResultMsg as any).name = toolMsg.toolName;
634
+ }
635
+ params.push(toolResultMsg);
636
+
637
+ if (hasImages && model.input.includes("image")) {
638
+ for (const block of toolMsg.content) {
639
+ if (block.type === "image") {
640
+ imageBlocks.push({
641
+ type: "image_url",
642
+ image_url: {
643
+ url: `data:${(block as any).mimeType};base64,${(block as any).data}`,
644
+ },
645
+ });
646
+ }
647
+ }
648
+ }
625
649
  }
626
- params.push(toolResultMsg);
627
-
628
- // If there are images and model supports them, send a follow-up user message with images
629
- if (hasImages && model.input.includes("image")) {
630
- const contentBlocks: Array<
631
- { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
632
- > = [];
633
-
634
- // Add text prefix
635
- contentBlocks.push({
636
- type: "text",
637
- text: "Attached image(s) from tool result:",
638
- });
639
650
 
640
- // Add images
641
- for (const block of msg.content) {
642
- if (block.type === "image") {
643
- contentBlocks.push({
644
- type: "image_url",
645
- image_url: {
646
- url: `data:${(block as any).mimeType};base64,${(block as any).data}`,
647
- },
648
- });
649
- }
651
+ i = j - 1;
652
+
653
+ // After all consecutive tool results, add a single user message with all images
654
+ if (imageBlocks.length > 0) {
655
+ if (compat.requiresAssistantAfterToolResult) {
656
+ params.push({
657
+ role: "assistant",
658
+ content: "I have processed the tool results.",
659
+ });
650
660
  }
651
661
 
652
662
  params.push({
653
663
  role: "user",
654
- content: contentBlocks,
664
+ content: [
665
+ {
666
+ type: "text",
667
+ text: "Attached image(s) from tool result:",
668
+ },
669
+ ...imageBlocks,
670
+ ],
655
671
  });
672
+ lastRole = "user";
673
+ } else {
674
+ lastRole = "toolResult";
656
675
  }
676
+ continue;
657
677
  }
658
678
 
659
679
  lastRole = msg.role;
@@ -10,8 +10,8 @@ import type {
10
10
  ResponseOutputMessage,
11
11
  ResponseReasoningItem,
12
12
  } from "openai/resources/responses/responses";
13
- import { calculateCost } from "../models";
14
- import { getEnvApiKey } from "../stream";
13
+ import { calculateCost } from "$ai/models";
14
+ import { getEnvApiKey } from "$ai/stream";
15
15
  import type {
16
16
  Api,
17
17
  AssistantMessage,
@@ -24,11 +24,11 @@ import type {
24
24
  ThinkingContent,
25
25
  Tool,
26
26
  ToolCall,
27
- } from "../types";
28
- import { AssistantMessageEventStream } from "../utils/event-stream";
29
- import { parseStreamingJson } from "../utils/json-parse";
30
- import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
31
- import { sanitizeSurrogates } from "../utils/sanitize-unicode";
27
+ } from "$ai/types";
28
+ import { AssistantMessageEventStream } from "$ai/utils/event-stream";
29
+ import { parseStreamingJson } from "$ai/utils/json-parse";
30
+ import { formatErrorMessageWithRetryAfter } from "$ai/utils/retry-after";
31
+ import { sanitizeSurrogates } from "$ai/utils/sanitize-unicode";
32
32
  import { transformMessages } from "./transform-messages";
33
33
 
34
34
  /** Fast deterministic hash to shorten long strings */
@@ -468,6 +468,8 @@ function convertMessages(
468
468
  for (const msg of transformedMessages) {
469
469
  if (msg.role === "user") {
470
470
  if (typeof msg.content === "string") {
471
+ // Skip empty user messages
472
+ if (!msg.content || msg.content.trim() === "") continue;
471
473
  messages.push({
472
474
  role: "user",
473
475
  content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
@@ -487,9 +489,16 @@ function convertMessages(
487
489
  } satisfies ResponseInputImage;
488
490
  }
489
491
  });
490
- const filteredContent = !model.input.includes("image")
492
+ // Filter out images if model doesn't support them, and empty text blocks
493
+ let filteredContent = !model.input.includes("image")
491
494
  ? content.filter((c) => c.type !== "input_image")
492
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
+ });
493
502
  if (filteredContent.length === 0) continue;
494
503
  messages.push({
495
504
  role: "user",
@@ -498,6 +507,15 @@ function convertMessages(
498
507
  }
499
508
  } else if (msg.role === "assistant") {
500
509
  const output: ResponseInput = [];
510
+ const assistantMsg = msg as AssistantMessage;
511
+
512
+ // Check if this message is from a different model (same provider, different model ID).
513
+ // For such messages, tool call IDs with fc_ prefix need to be stripped to avoid
514
+ // OpenAI's reasoning/function_call pairing validation errors.
515
+ const isDifferentModel =
516
+ assistantMsg.model !== model.id &&
517
+ assistantMsg.provider === model.provider &&
518
+ assistantMsg.api === model.api;
501
519
 
502
520
  for (const block of msg.content) {
503
521
  // Do not submit thinking blocks if the completion had an error (i.e. abort)
@@ -526,11 +544,19 @@ function convertMessages(
526
544
  } else if (block.type === "toolCall" && msg.stopReason !== "error") {
527
545
  const toolCall = block as ToolCall;
528
546
  const normalized = normalizeResponsesToolCallId(toolCall.id);
547
+ const callId = normalized.callId;
548
+ // For different-model messages, set id to undefined to avoid pairing validation.
549
+ // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
550
+ // By omitting the id, we avoid triggering that validation (like cross-provider does).
551
+ let itemId: string | undefined = normalized.itemId;
552
+ if (isDifferentModel && itemId?.startsWith("fc_")) {
553
+ itemId = undefined;
554
+ }
529
555
  knownCallIds.add(normalized.callId);
530
556
  output.push({
531
557
  type: "function_call",
532
- id: normalized.itemId,
533
- call_id: normalized.callId,
558
+ id: itemId,
559
+ call_id: callId,
534
560
  name: toolCall.name,
535
561
  arguments: JSON.stringify(toolCall.arguments),
536
562
  });
@@ -1,4 +1,4 @@
1
- import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types";
1
+ import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "$ai/types";
2
2
 
3
3
  /**
4
4
  * Normalize tool call ID for cross-provider compatibility.
@@ -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 "$ai/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
+ };