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,122 @@
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 USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
6
+
7
+ interface WhamWindow {
8
+ used_percent?: number;
9
+ limit_window_seconds?: number;
10
+ reset_at?: number;
11
+ reset_after_seconds?: number;
12
+ }
13
+
14
+ interface WhamUsageResponse {
15
+ rate_limit?: {
16
+ primary_window?: WhamWindow | null;
17
+ secondary_window?: WhamWindow | null;
18
+ } | null;
19
+ credits?: {
20
+ has_credits?: boolean;
21
+ balance?: string;
22
+ } | null;
23
+ }
24
+
25
+ function windowLabel(window: WhamWindow | null | undefined, fallback: string): string {
26
+ const seconds = window?.limit_window_seconds;
27
+ if (seconds === 18_000) return "Codex 5h";
28
+ if (seconds === 604_800) return "Codex 7d";
29
+ if (typeof seconds === "number" && seconds > 0) {
30
+ if (seconds % 86_400 === 0) return `Codex ${seconds / 86_400}d`;
31
+ if (seconds % 3_600 === 0) return `Codex ${seconds / 3_600}h`;
32
+ }
33
+ return fallback;
34
+ }
35
+
36
+ function parseWindow(
37
+ window: WhamWindow | null | undefined,
38
+ id: string,
39
+ fallbackLabel: string,
40
+ ): Metric | undefined {
41
+ if (!window || typeof window.used_percent !== "number") return undefined;
42
+ const used = Math.min(100, Math.max(0, window.used_percent));
43
+ const remainingFraction = Math.min(1, Math.max(0, (100 - used) / 100));
44
+
45
+ let resetAt: string | undefined;
46
+ if (typeof window.reset_at === "number" && window.reset_at > 0) {
47
+ resetAt = new Date(window.reset_at * 1000).toISOString();
48
+ } else if (typeof window.reset_after_seconds === "number" && window.reset_after_seconds > 0) {
49
+ resetAt = new Date(Date.now() + window.reset_after_seconds * 1000).toISOString();
50
+ }
51
+
52
+ return {
53
+ kind: "quota-window",
54
+ id,
55
+ label: windowLabel(window, fallbackLabel),
56
+ remainingFraction,
57
+ ...(resetAt ? { resetAt } : {}),
58
+ };
59
+ }
60
+
61
+ export const openaiCodexAdapter: UsageAdapter = {
62
+ id: "openai-codex",
63
+ label: "OpenAI Codex",
64
+ canHandle(target) {
65
+ if (target.providerId.toLowerCase() === "openai-codex") return true;
66
+ return urlOnDomain(target.baseUrl, "chatgpt.com");
67
+ },
68
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
69
+ try {
70
+ const creds = target.credentials;
71
+ const apiKey = creds?.apiKey;
72
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
73
+
74
+ const headers: Record<string, string> = {
75
+ Authorization: `Bearer ${apiKey}`,
76
+ Accept: "application/json",
77
+ "User-Agent": "pi-myusage",
78
+ };
79
+ if (creds?.accountId) headers["chatgpt-account-id"] = creds.accountId;
80
+
81
+ const payload = await fetchJson<WhamUsageResponse>(USAGE_URL, { headers }, signal);
82
+
83
+ const metrics: Metric[] = [];
84
+ const primary = parseWindow(payload.rate_limit?.primary_window, "primary", "Codex 5h");
85
+ if (primary) metrics.push(primary);
86
+ const secondary = parseWindow(payload.rate_limit?.secondary_window, "secondary", "Codex 7d");
87
+ if (secondary) metrics.push(secondary);
88
+
89
+ const credits = payload.credits;
90
+ if (credits?.has_credits === true) {
91
+ metrics.push({
92
+ kind: "status",
93
+ id: "codex-credits",
94
+ label: "Credits",
95
+ value: credits.balance ?? "enabled",
96
+ });
97
+ }
98
+
99
+ if (!metrics.length) return snapshot(this, target, "empty");
100
+
101
+ const windows = metrics.filter(
102
+ (metric): metric is QuotaWindowMetric => metric.kind === "quota-window",
103
+ );
104
+ const creditsMetric = metrics.find((metric) => metric.kind === "status");
105
+ let summary = windowsSummary(windows);
106
+ if (!summary && creditsMetric && creditsMetric.kind === "status") {
107
+ summary = `Codex · credits ${creditsMetric.value}`;
108
+ }
109
+
110
+ return snapshot(this, target, "ok", {
111
+ accounts: [{ id: "openai-codex", label: "Codex", metrics }],
112
+ summary,
113
+ });
114
+ } catch (error) {
115
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
116
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
117
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
118
+ }
119
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
120
+ }
121
+ },
122
+ };
@@ -0,0 +1,83 @@
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 USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
6
+
7
+ interface UsageWindow {
8
+ status?: string;
9
+ percent?: number;
10
+ resetsAt?: string;
11
+ }
12
+
13
+ interface UsageResponse {
14
+ usage?: {
15
+ rolling?: UsageWindow;
16
+ weekly?: UsageWindow;
17
+ monthly?: UsageWindow;
18
+ };
19
+ }
20
+
21
+ function windowMetric(id: string, label: string, w: UsageWindow | undefined): Metric | undefined {
22
+ if (w?.status && w.status !== "ok" && w.status !== "rate-limited") return undefined;
23
+ if (!w || typeof w.percent !== "number" || !Number.isFinite(w.percent)) return undefined;
24
+ const used = Math.min(100, Math.max(0, w.percent));
25
+ const resetAt =
26
+ w.resetsAt && !Number.isNaN(Date.parse(w.resetsAt)) ? new Date(w.resetsAt).toISOString() : undefined;
27
+ return {
28
+ kind: "quota-window",
29
+ id,
30
+ label,
31
+ remainingFraction: Math.max(0, (100 - used) / 100),
32
+ ...(resetAt ? { resetAt } : {}),
33
+ };
34
+ }
35
+
36
+ export const opencodeGoAdapter: UsageAdapter = {
37
+ id: "opencode-go",
38
+ label: "OpenCode Zen",
39
+ canHandle(target) {
40
+ const pid = target.providerId.toLowerCase();
41
+ if (["opencode-go", "opencode", "opencode-zen"].includes(pid)) return true;
42
+ return urlOnDomain(target.baseUrl, "opencode.ai");
43
+ },
44
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
45
+ try {
46
+ const apiKey = target.credentials?.apiKey;
47
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
48
+
49
+ let payload: UsageResponse;
50
+ try {
51
+ payload = await fetchJson<UsageResponse>(
52
+ USAGE_URL,
53
+ { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" } },
54
+ signal,
55
+ );
56
+ } catch (error) {
57
+ if (
58
+ error instanceof HttpError &&
59
+ (error.status === 401 || error.status === 403)
60
+ ) {
61
+ return snapshot(this, target, "unauthorized", { error: safeError(error) });
62
+ }
63
+ throw error;
64
+ }
65
+
66
+ const metrics = [
67
+ windowMetric("opencode-go-5h", "OpenCode 5h", payload.usage?.rolling),
68
+ windowMetric("opencode-go-weekly", "OpenCode Weekly", payload.usage?.weekly),
69
+ windowMetric("opencode-go-monthly", "OpenCode Monthly", payload.usage?.monthly),
70
+ ].filter((m): m is QuotaWindowMetric => m !== undefined);
71
+ if (!metrics.length) return snapshot(this, target, "empty", { error: "No usage windows returned" });
72
+
73
+ const summary = windowsSummary(metrics);
74
+ return snapshot(this, target, "ok", {
75
+ accounts: [{ id: "opencode-go-account", label: "OpenCode Zen", metrics }],
76
+ summary: summary ? `${summary} · Zen` : "Zen",
77
+ });
78
+ } catch (error) {
79
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
80
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
81
+ }
82
+ },
83
+ };
@@ -0,0 +1,101 @@
1
+ import { fetchJson, HttpError, safeError, urlOnDomain } from "../http.ts";
2
+ import { snapshot, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ const KEY_URL = "https://openrouter.ai/api/v1/key";
5
+ const CREDITS_URL = "https://openrouter.ai/api/v1/credits";
6
+
7
+ interface KeyResponse {
8
+ data?: {
9
+ label?: string;
10
+ usage?: number;
11
+ limit?: number | null;
12
+ };
13
+ }
14
+
15
+ interface CreditsResponse {
16
+ data?: {
17
+ total_credits?: number;
18
+ total_usage?: number;
19
+ };
20
+ }
21
+
22
+ export const openrouterAdapter: UsageAdapter = {
23
+ id: "openrouter",
24
+ label: "OpenRouter",
25
+ canHandle(target) {
26
+ if (target.providerId.toLowerCase() === "openrouter") return true;
27
+ return urlOnDomain(target.baseUrl, "openrouter.ai");
28
+ },
29
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
30
+ try {
31
+ const apiKey = target.credentials?.apiKey;
32
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
33
+ const headers = { Authorization: `Bearer ${apiKey}`, Accept: "application/json" };
34
+
35
+ const [keyResult, creditsResult] = await Promise.allSettled([
36
+ fetchJson<KeyResponse>(KEY_URL, { headers }, signal),
37
+ fetchJson<CreditsResponse>(CREDITS_URL, { headers }, signal),
38
+ ]);
39
+ if (keyResult.status === "rejected") throw keyResult.reason;
40
+ const keyInfo = keyResult.value.data;
41
+ const usage = typeof keyInfo?.usage === "number" && Number.isFinite(keyInfo.usage) ? keyInfo.usage : 0;
42
+ const limit = typeof keyInfo?.limit === "number" && Number.isFinite(keyInfo.limit) ? keyInfo.limit : undefined;
43
+ const credits = creditsResult.status === "fulfilled" ? creditsResult.value.data : undefined;
44
+
45
+ const balance =
46
+ typeof credits?.total_credits === "number" && typeof credits?.total_usage === "number"
47
+ ? Math.max(0, credits.total_credits - credits.total_usage)
48
+ : undefined;
49
+
50
+ const metrics = [
51
+ ...(limit !== undefined
52
+ ? [
53
+ {
54
+ kind: "usage-limit" as const,
55
+ id: "openrouter-usage",
56
+ label: "Usage",
57
+ used: usage,
58
+ limit,
59
+ unit: "USD",
60
+ },
61
+ ]
62
+ : []),
63
+ ...(balance !== undefined
64
+ ? [
65
+ {
66
+ kind: "balance" as const,
67
+ id: "openrouter-balance",
68
+ label: "Balance",
69
+ amount: balance,
70
+ currency: "USD",
71
+ detail:
72
+ credits && typeof credits.total_usage === "number"
73
+ ? `credits ${credits.total_credits} · used ${credits.total_usage.toFixed(2)}`
74
+ : undefined,
75
+ },
76
+ ]
77
+ : []),
78
+ ];
79
+ if (!metrics.length) return snapshot(this, target, "empty");
80
+
81
+ const summary =
82
+ limit !== undefined
83
+ ? `$${usage.toFixed(2)}/$${limit % 1 === 0 ? limit.toString() : limit.toFixed(2)} · OpenRouter`
84
+ : balance !== undefined
85
+ ? `$${balance.toFixed(2)} · OpenRouter`
86
+ : `Used $${usage.toFixed(2)} · OpenRouter`;
87
+ return snapshot(this, target, "ok", {
88
+ accounts: [
89
+ { id: "openrouter-account", label: keyInfo?.label ? `OpenRouter (${keyInfo.label})` : "OpenRouter", metrics },
90
+ ],
91
+ summary,
92
+ });
93
+ } catch (error) {
94
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
95
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
96
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
97
+ }
98
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
99
+ }
100
+ },
101
+ };
@@ -0,0 +1,80 @@
1
+ import { fetchJson, HttpError, safeError, urlOnDomain } from "../http.ts";
2
+ import { snapshot, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ const CREDITS_URL = "https://ai-gateway.vercel.sh/v1/credits";
5
+
6
+ interface CreditsResponse {
7
+ balance?: string | number;
8
+ total_used?: string | number;
9
+ }
10
+
11
+ function amount(value: string | number | undefined): number | undefined {
12
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
13
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
14
+ }
15
+
16
+ export const vercelGatewayAdapter: UsageAdapter = {
17
+ id: "vercel-ai-gateway",
18
+ label: "Vercel AI Gateway",
19
+ canHandle(target) {
20
+ if (target.providerId.toLowerCase() === "vercel-ai-gateway") return true;
21
+ return urlOnDomain(target.baseUrl, "vercel.sh") || urlOnDomain(target.baseUrl, "ai-gateway.vercel.sh");
22
+ },
23
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
24
+ try {
25
+ const apiKey = target.credentials?.apiKey;
26
+ if (!apiKey) return snapshot(this, target, "unauthorized", { error: "No API key" });
27
+
28
+ const payload = await fetchJson<CreditsResponse>(
29
+ CREDITS_URL,
30
+ { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" } },
31
+ signal,
32
+ );
33
+
34
+ const balance = amount(payload.balance);
35
+ const lifetime = amount(payload.total_used);
36
+ if (balance === undefined && lifetime === undefined) {
37
+ return snapshot(this, target, "empty", { error: "No credits data returned" });
38
+ }
39
+
40
+ const metrics = [
41
+ ...(balance !== undefined
42
+ ? [
43
+ {
44
+ kind: "balance" as const,
45
+ id: "vercel-credits",
46
+ label: "Credits remaining",
47
+ amount: balance,
48
+ currency: "USD",
49
+ detail: lifetime !== undefined ? `lifetime spend ${lifetime.toFixed(2)}` : undefined,
50
+ },
51
+ ]
52
+ : []),
53
+ ...(balance !== undefined && lifetime !== undefined && balance + lifetime > 0
54
+ ? [
55
+ {
56
+ kind: "usage-limit" as const,
57
+ id: "vercel-lifetime",
58
+ label: "Lifetime spend",
59
+ used: lifetime,
60
+ limit: balance + lifetime,
61
+ unit: "USD",
62
+ detail: "lifetime credits issued",
63
+ },
64
+ ]
65
+ : []),
66
+ ];
67
+ const primary = balance ?? lifetime ?? 0;
68
+ return snapshot(this, target, "ok", {
69
+ accounts: [{ id: "vercel-gateway-account", label: "Vercel AI Gateway", metrics }],
70
+ summary: `${balance !== undefined ? `$${balance.toFixed(2)} left` : `$${primary.toFixed(2)} used`} · Vercel AI Gateway`,
71
+ });
72
+ } catch (error) {
73
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
74
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) {
75
+ return snapshot(this, target, "unauthorized", { error: `HTTP ${error.status}` });
76
+ }
77
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
78
+ }
79
+ },
80
+ };
@@ -0,0 +1,23 @@
1
+ import { urlOnDomain } from "../http.ts";
2
+ import { snapshot, type UsageAdapter, type UsageSnapshot } from "../types.ts";
3
+
4
+ export const vertexAdapter: UsageAdapter = {
5
+ id: "google-vertex",
6
+ label: "Google Vertex AI",
7
+ canHandle(target) {
8
+ if (["vertex", "google-vertex", "vertex-ai"].includes(target.providerId.toLowerCase())) return true;
9
+ return urlOnDomain(target.baseUrl, "aiplatform.googleapis.com") || urlOnDomain(target.baseUrl, "geminivertexai");
10
+ },
11
+ async fetch({ target }): Promise<UsageSnapshot> {
12
+ return snapshot(this, target, "ok", {
13
+ accounts: [
14
+ {
15
+ id: "vertex-billing",
16
+ label: "Google Vertex AI",
17
+ metrics: [{ kind: "status", id: "vertex-billing", label: "Billing", value: "pay-as-you-go (GCP billing)" }],
18
+ },
19
+ ],
20
+ summary: "Vertex · PAYG",
21
+ });
22
+ },
23
+ };
@@ -0,0 +1,208 @@
1
+ import { shortReset, windowsSummary, type QuotaWindowMetric } from "../format.ts";
2
+ import { fetchJson, safeError, urlOnDomain, HttpError } from "../http.ts";
3
+ import { snapshot, type Metric, type UsageAdapter, type UsageSnapshot } from "../types.ts";
4
+
5
+ const USER_URL = "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
6
+ const BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
7
+ const USERINFO_URL = "https://auth.x.ai/oauth2/userinfo";
8
+
9
+ const CLI_HEADERS: Record<string, string> = {
10
+ "X-XAI-Token-Auth": "xai-grok-cli",
11
+ "x-grok-client-version": "1.0.10",
12
+ "x-grok-client-mode": "interactive",
13
+ };
14
+
15
+ interface CentWrapper {
16
+ val?: number;
17
+ }
18
+
19
+ interface BillingConfig {
20
+ creditUsagePercent?: number | null;
21
+ monthlyLimit?: CentWrapper | null;
22
+ used?: CentWrapper | null;
23
+ onDemandCap?: CentWrapper | null;
24
+ onDemandUsed?: CentWrapper | null;
25
+ prepaidBalance?: CentWrapper | null;
26
+ currentPeriod?: { type?: string; start?: string; end?: string } | null;
27
+ billingPeriodStart?: string | null;
28
+ billingPeriodEnd?: string | null;
29
+ }
30
+
31
+ interface UserResponse {
32
+ userId?: string;
33
+ subscriptionTier?: string;
34
+ }
35
+
36
+ interface UserinfoResponse {
37
+ sub?: string;
38
+ name?: string;
39
+ email?: string;
40
+ }
41
+
42
+ function cents(value: CentWrapper | null | undefined): number | undefined {
43
+ if (!value || typeof value.val !== "number" || !Number.isFinite(value.val)) return undefined;
44
+ return value.val / 100;
45
+ }
46
+
47
+ function isoOrNull(value: unknown): string | undefined {
48
+ if (typeof value !== "string") return undefined;
49
+ const ms = Date.parse(value);
50
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
51
+ }
52
+
53
+ function isAuthError(error: unknown): boolean {
54
+ return error instanceof HttpError && (error.status === 401 || error.status === 403);
55
+ }
56
+
57
+ async function fetchConsumer(
58
+ adapter: UsageAdapter,
59
+ target: Parameters<UsageAdapter["fetch"]>[0]["target"],
60
+ token: string,
61
+ signal: AbortSignal,
62
+ ): Promise<UsageSnapshot> {
63
+ const authHeaders = { Authorization: `Bearer ${token}`, ...CLI_HEADERS };
64
+ const user = await fetchJson<UserResponse>(USER_URL, { headers: authHeaders }, signal);
65
+
66
+ const billingHeaders: Record<string, string> = { ...authHeaders };
67
+ if (user.userId) billingHeaders["x-userid"] = user.userId;
68
+ const payload = await fetchJson<{ config?: BillingConfig | null }>(
69
+ BILLING_URL,
70
+ { headers: billingHeaders },
71
+ signal,
72
+ );
73
+ const config = payload.config ?? undefined;
74
+
75
+ const metrics: Metric[] = [];
76
+ const resetAt = isoOrNull(config?.currentPeriod?.end) ?? isoOrNull(config?.billingPeriodEnd);
77
+ const percent = config?.creditUsagePercent;
78
+ const usedUsd = cents(config?.used);
79
+ const limitUsd = cents(config?.monthlyLimit);
80
+
81
+ if (typeof percent === "number" && Number.isFinite(percent) && percent >= 0 && percent <= 100) {
82
+ metrics.push({
83
+ kind: "quota-window",
84
+ id: "grok-allowance",
85
+ label: "Included allowance",
86
+ remainingFraction: (100 - percent) / 100,
87
+ ...(resetAt ? { resetAt } : {}),
88
+ });
89
+ } else if (usedUsd !== undefined || limitUsd !== undefined) {
90
+ metrics.push({
91
+ kind: "usage-limit",
92
+ id: "grok-allowance",
93
+ label: "Included allowance",
94
+ used: usedUsd ?? 0,
95
+ limit: limitUsd ?? 0,
96
+ unit: "USD",
97
+ ...(resetAt ? { detail: `resets ${shortReset(resetAt)}` } : {}),
98
+ });
99
+ }
100
+
101
+ const onDemandUsed = cents(config?.onDemandUsed);
102
+ const onDemandCap = cents(config?.onDemandCap);
103
+ if (onDemandUsed !== undefined || onDemandCap !== undefined) {
104
+ metrics.push({
105
+ kind: "usage-limit",
106
+ id: "grok-on-demand",
107
+ label: "On-demand usage",
108
+ used: onDemandUsed ?? 0,
109
+ limit: onDemandCap ?? 0,
110
+ unit: "USD",
111
+ });
112
+ }
113
+
114
+ const prepaid = cents(config?.prepaidBalance);
115
+ if (prepaid !== undefined) {
116
+ metrics.push({
117
+ kind: "balance",
118
+ id: "grok-prepaid",
119
+ label: "Prepaid balance",
120
+ amount: prepaid,
121
+ currency: "USD",
122
+ });
123
+ }
124
+
125
+ if (user.subscriptionTier) {
126
+ metrics.push({ kind: "status", id: "grok-plan", label: "Plan", value: user.subscriptionTier });
127
+ }
128
+
129
+ if (!metrics.length) return snapshot(adapter, target, "empty");
130
+
131
+ const windows = metrics.filter(
132
+ (metric): metric is QuotaWindowMetric => metric.kind === "quota-window",
133
+ );
134
+ let summary: string | undefined;
135
+ if (windows.length) {
136
+ summary = windowsSummary(windows);
137
+ } else if (prepaid !== undefined) {
138
+ summary = `Grok · $${prepaid.toFixed(2)}`;
139
+ } else if (user.subscriptionTier) {
140
+ summary = `Grok · ${user.subscriptionTier}`;
141
+ }
142
+
143
+ return snapshot(adapter, target, "ok", {
144
+ accounts: [{ id: user.userId ?? "xai-user", label: "Grok Account", metrics }],
145
+ summary,
146
+ });
147
+ }
148
+
149
+ async function fetchUserinfo(
150
+ adapter: UsageAdapter,
151
+ target: Parameters<UsageAdapter["fetch"]>[0]["target"],
152
+ token: string,
153
+ signal: AbortSignal,
154
+ ): Promise<UsageSnapshot> {
155
+ const info = await fetchJson<UserinfoResponse>(
156
+ USERINFO_URL,
157
+ { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } },
158
+ signal,
159
+ );
160
+
161
+ const label = info.email || info.name || "Grok Account";
162
+ const metrics: Metric[] = [
163
+ { kind: "status", id: "grok-identity", label: "Account", value: label.slice(0, 40) },
164
+ { kind: "status", id: "grok-subscription", label: "Subscription", value: "Active" },
165
+ ];
166
+
167
+ return snapshot(adapter, target, "ok", {
168
+ accounts: [{ id: info.sub ?? "xai-user", label, metrics }],
169
+ summary: `Grok · ${label === "Grok Account" ? "Active" : label.slice(0, 40)}`,
170
+ });
171
+ }
172
+
173
+ export const xaiAdapter: UsageAdapter = {
174
+ id: "xai",
175
+ label: "xAI Grok",
176
+ canHandle(target) {
177
+ const pid = target.providerId.toLowerCase();
178
+ if (pid === "xai" || pid === "grok") return true;
179
+ return urlOnDomain(target.baseUrl, "x.ai") || urlOnDomain(target.baseUrl, "grok.com");
180
+ },
181
+ async fetch({ target, signal }): Promise<UsageSnapshot> {
182
+ try {
183
+ const token = target.credentials?.apiKey;
184
+ if (!token) return snapshot(this, target, "unauthorized", { error: "No API key" });
185
+
186
+ let consumerFailure: unknown;
187
+ try {
188
+ return await fetchConsumer(this, target, token, signal);
189
+ } catch (error) {
190
+ if (signal.aborted) throw error;
191
+ consumerFailure = error;
192
+ }
193
+
194
+ try {
195
+ return await fetchUserinfo(this, target, token, signal);
196
+ } catch (error) {
197
+ if (signal.aborted) throw error;
198
+ if (isAuthError(error) || isAuthError(consumerFailure)) {
199
+ return snapshot(this, target, "unauthorized", { error: safeError(error) });
200
+ }
201
+ throw error;
202
+ }
203
+ } catch (error) {
204
+ if (signal.aborted) return snapshot(this, target, "unavailable", { error: "aborted" });
205
+ return snapshot(this, target, "unavailable", { error: safeError(error) });
206
+ }
207
+ },
208
+ };