pi-myusage 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.
@@ -0,0 +1,167 @@
1
+ import { windowsSummary, type QuotaWindowMetric } from "../format.ts";
2
+ import { fetchJson, HttpError, safeError, urlOnDomain } from "../http.ts";
3
+ import { snapshot, type Metric, type UsageAdapter, type UsageSnapshot } from "../types.ts";
4
+
5
+ const OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
6
+
7
+ interface OAuthWindow {
8
+ utilization?: number;
9
+ resets_at?: string;
10
+ }
11
+
12
+ interface StructuredLimit {
13
+ kind?: string;
14
+ percent?: number;
15
+ resets_at?: string;
16
+ scope?: { model?: { display_name?: string; id?: string } };
17
+ }
18
+
19
+ interface OAuthUsageResponse {
20
+ five_hour?: OAuthWindow | null;
21
+ seven_day?: OAuthWindow | null;
22
+ seven_day_sonnet?: OAuthWindow | null;
23
+ seven_day_opus?: OAuthWindow | null;
24
+ seven_day_oauth_apps?: OAuthWindow | null;
25
+ limits?: StructuredLimit[];
26
+ extra_usage?: {
27
+ is_enabled?: boolean;
28
+ monthly_limit?: number | null;
29
+ used_credits?: number | null;
30
+ utilization?: number | null;
31
+ } | null;
32
+ }
33
+
34
+ type QuotaMetric = Extract<Metric, { kind: "quota-window" }>;
35
+
36
+ function quotaMetric(id: string, label: string, utilization: unknown, resetAt?: string): QuotaMetric | undefined {
37
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) return undefined;
38
+ const used = Math.max(0, Math.min(100, utilization));
39
+ const parsedReset = resetAt && Number.isFinite(Date.parse(resetAt)) ? new Date(resetAt).toISOString() : undefined;
40
+ return {
41
+ kind: "quota-window",
42
+ id,
43
+ label,
44
+ remainingFraction: (100 - used) / 100,
45
+ ...(parsedReset ? { resetAt: parsedReset } : {}),
46
+ };
47
+ }
48
+
49
+ function parseOAuthMetrics(data: OAuthUsageResponse): Metric[] {
50
+ const metrics: Metric[] = [];
51
+ const flat = [
52
+ quotaMetric("claude-5h", "Claude 5h", data.five_hour?.utilization, data.five_hour?.resets_at),
53
+ quotaMetric("claude-7d", "Claude 7d", data.seven_day?.utilization, data.seven_day?.resets_at),
54
+ quotaMetric("claude-7d-sonnet", "Claude 7d Sonnet", data.seven_day_sonnet?.utilization, data.seven_day_sonnet?.resets_at),
55
+ quotaMetric("claude-7d-opus", "Claude 7d Opus", data.seven_day_opus?.utilization, data.seven_day_opus?.resets_at),
56
+ quotaMetric("claude-7d-oauth", "Claude 7d OAuth", data.seven_day_oauth_apps?.utilization, data.seven_day_oauth_apps?.resets_at),
57
+ ].filter((metric): metric is QuotaMetric => Boolean(metric));
58
+ metrics.push(...flat);
59
+
60
+ if (Array.isArray(data.limits)) {
61
+ for (const [index, limit] of data.limits.entries()) {
62
+ const kind = (limit.kind ?? "").toLowerCase();
63
+ const modelName = limit.scope?.model?.display_name || limit.scope?.model?.id;
64
+ const id = kind === "session"
65
+ ? "claude-session"
66
+ : kind === "weekly_all"
67
+ ? "claude-7d"
68
+ : `claude-weekly-${modelName?.toLowerCase().replace(/[^a-z0-9]+/g, "-") || index}`;
69
+ if (metrics.some((metric) => metric.kind === "quota-window" && metric.id === id)) continue;
70
+ const label = kind === "session"
71
+ ? "Claude Session"
72
+ : kind === "weekly_all"
73
+ ? "Claude 7d"
74
+ : modelName
75
+ ? `Claude 7d ${modelName}`
76
+ : "Claude Weekly";
77
+ const metric = quotaMetric(id, label, limit.percent, limit.resets_at);
78
+ if (metric) metrics.push(metric);
79
+ }
80
+ }
81
+
82
+ const extra = data.extra_usage;
83
+ if (extra?.is_enabled && typeof extra.utilization === "number") {
84
+ const metric = quotaMetric("claude-extra-monthly", "Claude Extra Monthly", extra.utilization);
85
+ if (metric) metrics.push(metric);
86
+ }
87
+ if (extra?.is_enabled && typeof extra.monthly_limit === "number" && typeof extra.used_credits === "number") {
88
+ metrics.push({
89
+ kind: "usage-limit",
90
+ id: "claude-extra-credits",
91
+ label: "Extra usage",
92
+ used: extra.used_credits,
93
+ limit: extra.monthly_limit,
94
+ unit: "credits",
95
+ });
96
+ }
97
+
98
+ return metrics;
99
+ }
100
+
101
+ function quotaSummary(metrics: Metric[]): string | undefined {
102
+ const quotas = metrics.filter((metric): metric is QuotaWindowMetric => metric.kind === "quota-window");
103
+ const session = quotas.find((metric) => metric.id === "claude-5h" || metric.id === "claude-session");
104
+ const weekly = quotas.find((metric) => metric.id === "claude-7d")
105
+ ?? quotas.find((metric) => metric.id.startsWith("claude-7d-") || metric.id.startsWith("claude-weekly-"));
106
+ return windowsSummary([session, weekly].filter((metric): metric is QuotaWindowMetric => Boolean(metric)));
107
+ }
108
+
109
+ export const anthropicAdapter: UsageAdapter = {
110
+ id: "anthropic",
111
+ label: "Anthropic Claude",
112
+ canHandle(target) {
113
+ const pid = target.providerId.toLowerCase();
114
+ if (pid === "anthropic" || pid === "claude") return true;
115
+ return urlOnDomain(target.baseUrl, "anthropic.com");
116
+ },
117
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
118
+ try {
119
+ const token = target.credentials?.apiKey;
120
+ if (!token) return snapshot(this, target, "unauthorized", { error: "No API key" });
121
+
122
+ try {
123
+ const payload = await fetchJson<OAuthUsageResponse>(
124
+ OAUTH_USAGE_URL,
125
+ {
126
+ headers: {
127
+ Authorization: `Bearer ${token}`,
128
+ Accept: "application/json",
129
+ "anthropic-version": "2023-06-01",
130
+ "anthropic-beta": "oauth-2025-04-20",
131
+ "User-Agent": "claude-cli (external, cli)",
132
+ "x-app": "cli",
133
+ },
134
+ },
135
+ signal,
136
+ );
137
+
138
+ const metrics = parseOAuthMetrics(payload);
139
+ if (!metrics.length) return snapshot(this, target, "empty");
140
+ return snapshot(this, target, "ok", {
141
+ accounts: [{ id: "claude-subscription", label: "Claude Subscription", metrics }],
142
+ summary: quotaSummary(metrics),
143
+ });
144
+ } catch (oauthError) {
145
+ if (signal.aborted) throw oauthError;
146
+ const rejected = oauthError instanceof HttpError && (oauthError.status === 401 || oauthError.status === 403);
147
+ if (!rejected) throw oauthError;
148
+ }
149
+
150
+ return snapshot(this, target, "ok", {
151
+ accounts: [
152
+ {
153
+ id: "claude-api-key",
154
+ label: "Claude API Account",
155
+ metrics: [
156
+ { kind: "status", id: "claude-billing", label: "Billing", value: "API key (pay-as-you-go)" },
157
+ ],
158
+ },
159
+ ],
160
+ summary: "Claude API key · PAYG",
161
+ });
162
+ } catch (error) {
163
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
164
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
165
+ }
166
+ },
167
+ };
@@ -0,0 +1,97 @@
1
+ import { fetchJson, HttpError, safeError, urlOnDomain } from "../http.ts";
2
+ import { snapshot, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ const USAGE_URL = "https://api.baseten.co/v1/billing/usage_summary";
5
+
6
+ interface UsageSummaryResponse {
7
+ model_apis_usage?: {
8
+ total?: string | number;
9
+ credits_used?: string | number;
10
+ subtotal?: string | number;
11
+ };
12
+ }
13
+
14
+ function amount(value: string | number | undefined): number | undefined {
15
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
16
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
17
+ }
18
+
19
+ export const basetenAdapter: UsageAdapter = {
20
+ id: "baseten",
21
+ label: "Baseten",
22
+ canHandle(target) {
23
+ if (target.providerId.toLowerCase() === "baseten") return true;
24
+ return urlOnDomain(target.baseUrl, "baseten.co");
25
+ },
26
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
27
+ try {
28
+ const apiKey = target.credentials?.apiKey;
29
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
30
+
31
+ const end = new Date();
32
+ const start = new Date(end.getTime() - 30 * 86_400_000);
33
+ const payload = await fetchJson<UsageSummaryResponse>(
34
+ `${USAGE_URL}?start_date=${start.toISOString()}&end_date=${end.toISOString()}`,
35
+ { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" } },
36
+ signal,
37
+ );
38
+
39
+ const usage = payload.model_apis_usage;
40
+ if (!usage) return snapshot(this, target, "empty", { error: "No Model APIs usage returned" });
41
+ const total = amount(usage.total);
42
+ const credits = amount(usage.credits_used);
43
+ const subtotal = amount(usage.subtotal);
44
+ if (total === undefined && credits === undefined && subtotal === undefined) {
45
+ return snapshot(this, target, "empty", { error: "No usage amounts returned" });
46
+ }
47
+
48
+ const metrics = [
49
+ ...(total !== undefined && subtotal !== undefined
50
+ ? [
51
+ {
52
+ kind: "usage-limit" as const,
53
+ id: "baseten-model-apis",
54
+ label: "Model APIs (30d)",
55
+ used: subtotal,
56
+ limit: total,
57
+ unit: "USD",
58
+ detail:
59
+ credits !== undefined
60
+ ? `net after ${credits.toFixed(2)} credits · gross ${total.toFixed(2)}`
61
+ : `gross ${total.toFixed(2)}`,
62
+ },
63
+ ]
64
+ : []),
65
+ ...(credits !== undefined && (total === undefined || subtotal === undefined)
66
+ ? [
67
+ {
68
+ kind: "status" as const,
69
+ id: "baseten-credits",
70
+ label: "Credits used (30d)",
71
+ value: `$${credits.toFixed(2)}`,
72
+ },
73
+ ]
74
+ : []),
75
+ ];
76
+ if (!metrics.length) {
77
+ metrics.push({
78
+ kind: "status",
79
+ id: "baseten-model-apis",
80
+ label: "Model APIs (30d)",
81
+ value: `$${(total ?? subtotal ?? 0).toFixed(2)}`,
82
+ });
83
+ }
84
+ const spend = subtotal ?? total ?? 0;
85
+ return snapshot(this, target, "ok", {
86
+ accounts: [{ id: "baseten-org", label: "Baseten (org)", metrics }],
87
+ summary: `$${spend.toFixed(2)} spend (30d) · Baseten`,
88
+ });
89
+ } catch (error) {
90
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
91
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
92
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
93
+ }
94
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
95
+ }
96
+ },
97
+ };
@@ -0,0 +1,135 @@
1
+ import { windowsSummary, type QuotaWindowMetric } from "../format.ts";
2
+ import { fetchJson, HttpError, safeError, urlOnDomain } from "../http.ts";
3
+ import { snapshot, type Metric, type UsageAdapter, type UsageSnapshot } from "../types.ts";
4
+
5
+ const ORIGIN = "https://api.cline.bot";
6
+
7
+ interface Envelope<T> {
8
+ success?: boolean;
9
+ error?: string;
10
+ data?: T;
11
+ }
12
+
13
+ interface MeResponse {
14
+ id?: string | number;
15
+ email?: string;
16
+ name?: string;
17
+ }
18
+
19
+ interface UsageLimitItem {
20
+ type?: string;
21
+ percentUsed?: number;
22
+ resetsAt?: string;
23
+ }
24
+
25
+ interface UsageLimitsResponse {
26
+ limits?: UsageLimitItem[];
27
+ }
28
+
29
+ interface BalanceResponse {
30
+ balance?: number;
31
+ }
32
+
33
+ const MICRO_USD = 1_000_000;
34
+
35
+ async function clineGet<T>(path: string, apiKey: string, signal: AbortSignal): Promise<T | undefined> {
36
+ const payload = await fetchJson<Envelope<T>>(
37
+ `${ORIGIN}${path}`,
38
+ { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" } },
39
+ signal,
40
+ );
41
+ if (typeof payload.error === "string" && payload.error) throw new Error(payload.error);
42
+ return payload.data;
43
+ }
44
+
45
+ function normalizeReset(raw: string | undefined): string | undefined {
46
+ if (!raw || Number.isNaN(Date.parse(raw))) return undefined;
47
+ return new Date(raw).toISOString();
48
+ }
49
+
50
+ function parseWindows(data: UsageLimitsResponse | undefined): Metric[] {
51
+ if (!data || !Array.isArray(data.limits)) return [];
52
+ const metrics: Metric[] = [];
53
+ for (const limit of data.limits) {
54
+ if (typeof limit.percentUsed !== "number" || !Number.isFinite(limit.percentUsed)) continue;
55
+ const type = (limit.type ?? "").toLowerCase();
56
+ const label =
57
+ type === "five_hour" ? "Cline 5h"
58
+ : type === "weekly" ? "Cline Weekly"
59
+ : type === "monthly" ? "Cline Monthly"
60
+ : `Cline ${limit.type ?? "limit"}`;
61
+ const used = Math.min(100, Math.max(0, limit.percentUsed));
62
+ const resetAt = normalizeReset(limit.resetsAt);
63
+ metrics.push({
64
+ kind: "quota-window",
65
+ id: `cline-${type || metrics.length}`,
66
+ label,
67
+ remainingFraction: Math.max(0, (100 - used) / 100),
68
+ ...(resetAt ? { resetAt } : {}),
69
+ });
70
+ }
71
+ return metrics;
72
+ }
73
+
74
+ export const clineAdapter: UsageAdapter = {
75
+ id: "cline",
76
+ label: "Cline",
77
+ canHandle(target) {
78
+ if (target.providerId.toLowerCase() === "cline") return true;
79
+ return urlOnDomain(target.baseUrl, "cline.bot");
80
+ },
81
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
82
+ try {
83
+ const apiKey = target.credentials?.apiKey;
84
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
85
+
86
+ const [me, plan] = await Promise.all([
87
+ clineGet<MeResponse>("/api/v1/users/me", apiKey, signal),
88
+ clineGet<unknown>("/api/v1/users/me/plan", apiKey, signal),
89
+ ]);
90
+ const userId = me?.id;
91
+
92
+ const metrics: Metric[] = [];
93
+ const [limits, balance] = await Promise.all([
94
+ plan !== null && plan !== undefined
95
+ ? clineGet<UsageLimitsResponse>("/api/v1/users/me/plan/usage-limits", apiKey, signal)
96
+ : undefined,
97
+ userId !== undefined
98
+ ? clineGet<BalanceResponse>(`/api/v1/users/${userId}/balance`, apiKey, signal)
99
+ : undefined,
100
+ ]);
101
+ if (limits) metrics.push(...parseWindows(limits));
102
+
103
+ let balanceAmount: number | undefined;
104
+ if (typeof balance?.balance === "number" && Number.isFinite(balance.balance)) {
105
+ balanceAmount = balance.balance / MICRO_USD;
106
+ metrics.push({
107
+ kind: "balance",
108
+ id: "cline-balance",
109
+ label: "Balance",
110
+ amount: balanceAmount,
111
+ currency: "USD",
112
+ });
113
+ }
114
+
115
+ if (!metrics.length) {
116
+ return snapshot(this, target, "empty", {
117
+ error: plan === null || plan === undefined ? "No ClinePass plan" : "No usage or balance data",
118
+ });
119
+ }
120
+
121
+ const windows = metrics.filter((m): m is QuotaWindowMetric => m.kind === "quota-window");
122
+ const summary = windowsSummary(windows) ?? `$${(balanceAmount ?? 0).toFixed(2)} · Cline`;
123
+ return snapshot(this, target, "ok", {
124
+ accounts: [{ id: "cline-account", label: "Cline", metrics }],
125
+ summary,
126
+ });
127
+ } catch (error) {
128
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
129
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
130
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
131
+ }
132
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
133
+ }
134
+ },
135
+ };
@@ -0,0 +1,69 @@
1
+ import { fetchJson, safeError, urlOrigin, urlOnDomain } from "../http.ts";
2
+ import { snapshot, type Metric, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ interface ApiUser {
5
+ name?: string;
6
+ requests?: number;
7
+ expires?: string;
8
+ }
9
+
10
+ function isLocal(url: string | undefined): boolean {
11
+ return urlOnDomain(url, "localhost") || urlOnDomain(url, "127.0.0.1");
12
+ }
13
+
14
+ export const cliproxyAdapter: UsageAdapter = {
15
+ id: "cliproxyapi",
16
+ label: "CLIProxyAPI",
17
+ canHandle(target) {
18
+ if (!target.providerId.toLowerCase().includes("cliproxy")) return false;
19
+ return !target.baseUrl || isLocal(target.baseUrl);
20
+ },
21
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
22
+ try {
23
+ const origin = urlOrigin(target.baseUrl);
24
+ const statusOnly = (): UsageSnapshot =>
25
+ snapshot(this, target, "ok", {
26
+ accounts: [
27
+ {
28
+ id: "cliproxy-local",
29
+ label: "CLIProxyAPI",
30
+ metrics: [{ kind: "status", id: "cliproxy-proxy", label: "Proxy", value: `configured ${origin ?? "local"}` }],
31
+ },
32
+ ],
33
+ summary: "CLIProxy · local proxy",
34
+ });
35
+
36
+ const mgmtKey = process.env.CLIPROXY_MANAGEMENT_KEY;
37
+ if (!mgmtKey || !origin) return statusOnly();
38
+
39
+ let users: ApiUser[];
40
+ try {
41
+ users = await fetchJson<ApiUser[]>(
42
+ `${origin}/v0/management/api-users`,
43
+ { headers: { Authorization: `Bearer ${mgmtKey}`, Accept: "application/json" } },
44
+ signal,
45
+ );
46
+ } catch {
47
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
48
+ return statusOnly();
49
+ }
50
+ if (!Array.isArray(users)) return statusOnly();
51
+
52
+ const metrics: Metric[] = users.slice(0, 5).map((user, index) => ({
53
+ kind: "status",
54
+ id: `cliproxy-${user.name ?? index}`,
55
+ label: user.name ?? `user ${index + 1}`,
56
+ value: `${typeof user.requests === "number" ? user.requests : 0} requests`,
57
+ ...(user.expires ? { detail: `expires ${user.expires}` } : {}),
58
+ }));
59
+ if (!metrics.length) return statusOnly();
60
+ return snapshot(this, target, "ok", {
61
+ accounts: [{ id: "cliproxy-local", label: "CLIProxyAPI", metrics }],
62
+ summary: `CLIProxy · ${users.length} api users`,
63
+ });
64
+ } catch (error) {
65
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
66
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
67
+ }
68
+ },
69
+ };
@@ -0,0 +1,76 @@
1
+ import { fetchJson, HttpError, safeError, urlOrigin } from "../http.ts";
2
+ import { snapshot, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ const BALANCE_URL = "https://api.deepseek.com/user/balance";
5
+
6
+ interface BalanceResponse {
7
+ is_available?: boolean;
8
+ balance_infos?: Array<{
9
+ currency?: string;
10
+ total_balance?: string;
11
+ granted_balance?: string;
12
+ topped_up_balance?: string;
13
+ }>;
14
+ }
15
+
16
+ export const deepseekAdapter: UsageAdapter = {
17
+ id: "deepseek",
18
+ label: "DeepSeek",
19
+ canHandle(target) {
20
+ if (target.providerId.toLowerCase() === "deepseek") return true;
21
+ return urlOrigin(target.baseUrl) === "https://api.deepseek.com";
22
+ },
23
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
24
+ try {
25
+ const creds = target.credentials;
26
+ const apiKey = creds?.apiKey;
27
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
28
+
29
+ const payload = await fetchJson<BalanceResponse>(
30
+ BALANCE_URL,
31
+ { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" } },
32
+ signal,
33
+ );
34
+
35
+ const accounts = (payload.balance_infos ?? []).map((info, index) => ({
36
+ id: `deepseek-${info.currency ?? index}`,
37
+ label: info.currency === "CNY" ? "Account (CNY)" : `Account (${info.currency ?? "?"})`,
38
+ metrics: [
39
+ ...(info.total_balance !== undefined
40
+ ? [
41
+ {
42
+ kind: "balance" as const,
43
+ id: "total",
44
+ label: "Balance",
45
+ amount: Number(info.total_balance) || 0,
46
+ currency: info.currency ?? "CNY",
47
+ detail:
48
+ info.granted_balance !== undefined || info.topped_up_balance !== undefined
49
+ ? `granted ${info.granted_balance ?? "0"} · topped up ${info.topped_up_balance ?? "0"}`
50
+ : undefined,
51
+ },
52
+ ]
53
+ : []),
54
+ ...(payload.is_available === false
55
+ ? [{ kind: "status" as const, id: "availability", label: "Status", value: "insufficient balance" }]
56
+ : []),
57
+ ],
58
+ }));
59
+ if (!accounts.length) return snapshot(this, target, "empty");
60
+ const primary = accounts[0].metrics[0];
61
+ return snapshot(this, target, "ok", {
62
+ accounts,
63
+ summary:
64
+ primary?.kind === "balance"
65
+ ? `${primary.currency === "CNY" ? "¥" : "$"}${primary.amount.toFixed(2)} · DeepSeek`
66
+ : undefined,
67
+ });
68
+ } catch (error) {
69
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
70
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
71
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
72
+ }
73
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
74
+ }
75
+ },
76
+ };
@@ -0,0 +1,105 @@
1
+ import { fetchJson, HttpError, safeError, urlOnDomain } from "../http.ts";
2
+ import { snapshot, type Metric, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ const ORIGIN = "https://api.fireworks.ai";
5
+ const WINDOW_DAYS = 30;
6
+
7
+ interface AccountsResponse {
8
+ accounts?: Array<{ name?: string }>;
9
+ }
10
+
11
+ interface LineItem {
12
+ series?: string;
13
+ totalCost?: { currencyCode?: string; units?: string | number; nanos?: string | number };
14
+ }
15
+
16
+ interface BillingSummaryResponse {
17
+ lineItems?: LineItem[];
18
+ }
19
+
20
+ const SERIES_LABELS: Record<string, string> = {
21
+ serverless: "Serverless",
22
+ dedicated: "Dedicated",
23
+ training: "Training",
24
+ other: "Other",
25
+ };
26
+
27
+ function money(cost: LineItem["totalCost"]): { currency: string; amount: number } | undefined {
28
+ if (!cost?.currencyCode) return undefined;
29
+ const units = Number(cost.units ?? 0);
30
+ const nanos = Number(cost.nanos ?? 0);
31
+ if (!Number.isFinite(units) || !Number.isFinite(nanos)) return undefined;
32
+ return { currency: cost.currencyCode, amount: Math.round((units + nanos / 1e9) * 100) / 100 };
33
+ }
34
+
35
+ export const fireworksAdapter: UsageAdapter = {
36
+ id: "fireworks",
37
+ label: "Fireworks",
38
+ canHandle(target) {
39
+ if (target.providerId.toLowerCase() === "fireworks") return true;
40
+ return urlOnDomain(target.baseUrl, "fireworks.ai");
41
+ },
42
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
43
+ try {
44
+ const apiKey = target.credentials?.apiKey;
45
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
46
+ const headers = { Authorization: `Bearer ${apiKey}`, Accept: "application/json" };
47
+
48
+ const accountsPayload = await fetchJson<AccountsResponse>(`${ORIGIN}/v1/accounts`, { headers }, signal);
49
+ const first = accountsPayload.accounts?.[0]?.name?.match(/^accounts\/([^/]+)$/)?.[1];
50
+ if (!first) return snapshot(this, target, "empty", { error: "No accounts returned" });
51
+
52
+ const dayMs = 86_400_000;
53
+ const day = (t: number) => new Date(t).toISOString().slice(0, 10);
54
+ const now = Date.now();
55
+ const url =
56
+ `${ORIGIN}/v1/accounts/${first}/billing/summary` +
57
+ `?startTime=${day(now - (WINDOW_DAYS - 1) * dayMs)}T00:00:00Z&endTime=${day(now + dayMs)}T00:00:00Z`;
58
+ const summary = await fetchJson<BillingSummaryResponse>(url, { headers }, signal);
59
+
60
+ const totals = new Map<string, Map<string, number>>();
61
+ for (const item of summary.lineItems ?? []) {
62
+ const m = money(item.totalCost);
63
+ if (!m) continue;
64
+ const series = item.series === "SERVERLESS" ? "serverless"
65
+ : item.series === "DEDICATED_DEPLOYMENT" ? "dedicated"
66
+ : item.series === "TRAINING" ? "training"
67
+ : "other";
68
+ const bySeries = totals.get(m.currency) ?? new Map<string, number>();
69
+ bySeries.set(series, Math.round(((bySeries.get(series) ?? 0) + m.amount) * 100) / 100);
70
+ totals.set(m.currency, bySeries);
71
+ }
72
+
73
+ const metrics: Metric[] = [];
74
+ let primaryValue = "";
75
+ for (const [currency, bySeries] of totals) {
76
+ const total = Math.round([...bySeries.values()].reduce((a, b) => a + b, 0) * 100) / 100;
77
+ const detail = Object.entries(bySeries)
78
+ .map(([series, v]) => `${SERIES_LABELS[series] ?? series} ${v.toFixed(2)}`)
79
+ .join(" · ");
80
+ const value = `${currency === "USD" ? "$" : ""}${total.toFixed(2)}${currency === "USD" ? "" : ` ${currency}`}`;
81
+ if (!primaryValue) primaryValue = value;
82
+ metrics.push({
83
+ kind: "status",
84
+ id: `fireworks-${currency.toLowerCase()}`,
85
+ label: "Spend (30d)",
86
+ value,
87
+ detail: detail || undefined,
88
+ });
89
+ }
90
+ if (!metrics.length) {
91
+ return snapshot(this, target, "empty", { error: "No rated line items for the last 30 days" });
92
+ }
93
+ return snapshot(this, target, "ok", {
94
+ accounts: [{ id: `fireworks-${first}`, label: `Fireworks (${first})`, metrics }],
95
+ summary: `${primaryValue} spend (30d) · Fireworks`,
96
+ });
97
+ } catch (error) {
98
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
99
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
100
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
101
+ }
102
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
103
+ }
104
+ },
105
+ };