@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.
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/providers/amazon-bedrock.ts +38 -21
- package/src/providers/anthropic.ts +7 -7
- package/src/providers/cursor.ts +15 -9
- package/src/providers/google-gemini-cli-usage.ts +271 -0
- package/src/providers/google-gemini-cli.ts +4 -4
- package/src/providers/google-shared.ts +12 -3
- package/src/providers/google-vertex.ts +5 -5
- package/src/providers/google.ts +6 -6
- package/src/providers/openai-codex/request-transformer.ts +4 -0
- package/src/providers/openai-codex-responses.ts +18 -9
- package/src/providers/openai-completions.ts +70 -50
- package/src/providers/openai-responses.ts +36 -10
- package/src/providers/transform-messages.ts +1 -1
- 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
- package/src/utils/event-stream.ts +1 -1
- package/src/utils/oauth/github-copilot.ts +1 -1
- package/src/utils/overflow.ts +1 -1
- package/src/utils/validation.ts +1 -1
|
@@ -8,8 +8,8 @@ import type {
|
|
|
8
8
|
ChatCompletionMessageParam,
|
|
9
9
|
ChatCompletionToolMessageParam,
|
|
10
10
|
} from "openai/resources/chat/completions";
|
|
11
|
-
import { calculateCost } from "
|
|
12
|
-
import { getEnvApiKey } from "
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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 (
|
|
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
|
-
//
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
content
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
(
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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:
|
|
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 "
|
|
14
|
-
import { getEnvApiKey } from "
|
|
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 "
|
|
28
|
-
import { AssistantMessageEventStream } from "
|
|
29
|
-
import { parseStreamingJson } from "
|
|
30
|
-
import { formatErrorMessageWithRetryAfter } from "
|
|
31
|
-
import { sanitizeSurrogates } from "
|
|
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
|
-
|
|
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:
|
|
533
|
-
call_id:
|
|
558
|
+
id: itemId,
|
|
559
|
+
call_id: callId,
|
|
534
560
|
name: toolCall.name,
|
|
535
561
|
arguments: JSON.stringify(toolCall.arguments),
|
|
536
562
|
});
|
|
@@ -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
|
+
};
|