@oh-my-pi/pi-ai 17.0.8 → 17.0.9

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/CHANGELOG.md CHANGED
@@ -2,9 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.9] - 2026-07-23
6
+
7
+ ### Added
8
+
9
+ - Added Synthetic (synthetic.new) usage provider: `/usage` now reports the rolling 5-hour request limit and weekly credit quota via `GET /v2/quotas`, including per-tick regeneration rates in the window labels.
10
+ - Added optional `UsageWindow.resetLabel` so rolling windows can render their countdown with an accurate verb (e.g. "tick in 12m" / "regen in 51m" instead of "resets in") — both quota windows on Synthetic regenerate incrementally rather than hard-resetting.
11
+
5
12
  ### Fixed
6
13
 
7
14
  - Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)).
15
+ - Fixed OpenAI Responses token-cap truncations suppressing fully streamed function and custom tool calls whose inputs are complete.
16
+ - Added SuperGrok (`xai-oauth`) usage tracking for weekly credits, product limits, and positive on-demand caps.
8
17
 
9
18
  ## [17.0.8] - 2026-07-22
10
19
 
@@ -39,6 +39,8 @@ export * from "./usage/ollama.js";
39
39
  export * from "./usage/openai-codex.js";
40
40
  export * from "./usage/openai-codex-reset.js";
41
41
  export * from "./usage/opencode-go.js";
42
+ export * from "./usage/synthetic.js";
43
+ export * from "./usage/xai-oauth.js";
42
44
  export * from "./usage/zai.js";
43
45
  export * from "./utils/anthropic-auth.js";
44
46
  export * from "./utils/event-stream.js";
@@ -491,7 +491,7 @@ export declare function finalizePendingResponsesToolCalls(output: AssistantMessa
491
491
  * re-samples instead of ending. Callers set `output.stopReason` from the wire
492
492
  * status first via {@link mapOpenAIResponsesStopReason}.
493
493
  */
494
- export declare function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined): void;
494
+ export declare function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined, promoteIncompleteToolUse?: boolean): void;
495
495
  /** Initial empty `AssistantMessage` that streaming providers accumulate into. */
496
496
  export declare function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage;
497
497
  /** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */
@@ -1,16 +1,13 @@
1
1
  import type { FetchImpl } from "../../types.js";
2
2
  import type { OAuthController, OAuthCredentials } from "./types.js";
3
+ export declare function validateXAIEndpoint(url: string, field: string): string;
3
4
  /**
4
- * Validate an xAI OIDC endpoint against its scheme and host.
5
- *
6
- * The discovery response is long-lived and its token endpoint receives every
7
- * future refresh token. Rejecting non-HTTPS or non-`x.ai` / `*.x.ai` hosts
8
- * pins that endpoint to the xAI auth origin.
9
- *
10
- * @throws Error with message `Invalid xAI <field>: <url>` when the URL fails
11
- * either scheme or host validation.
5
+ * Pin SuperGrok billing URLs to HTTPS `grok.com` / `*.grok.com`.
6
+ * The CLI billing proxy is intentionally not on `*.x.ai`.
12
7
  */
13
- export declare function validateXAIEndpoint(url: string, field: string): string;
8
+ export declare function validateXAIBillingEndpoint(url: string, field?: string): string;
9
+ /** Decode an xAI access-token JWT payload without verifying its signature. */
10
+ export declare function parseXAIAccessTokenPayload(jwt: string): Record<string, unknown> | null;
14
11
  /**
15
12
  * Check whether a JWT access token is at or past its `exp` claim (with an
16
13
  * optional refresh-skew margin).
@@ -19,6 +16,25 @@ export declare function validateXAIEndpoint(url: string, field: string): string;
19
16
  * not token validation.
20
17
  */
21
18
  export declare function isXAIAccessTokenExpiring(jwt: string, skewSeconds?: number): boolean;
19
+ /** Extract the stable xAI subject UUID from an access token. */
20
+ export declare function extractXAIAccessTokenSubject(jwt: string): string | undefined;
21
+ export interface XAIOAuthIdentity {
22
+ accountId?: string;
23
+ email?: string;
24
+ name?: string;
25
+ }
26
+ /** Fetch optional OIDC userinfo for a valid xAI access token. */
27
+ export declare function fetchXAIOAuthIdentity(accessToken: string, fetchOverride?: FetchImpl, signal?: AbortSignal): Promise<XAIOAuthIdentity | null>;
28
+ /** Build the SuperGrok CLI billing URL. */
29
+ export declare function buildXAICliBillingUrl(format?: string): string;
30
+ /**
31
+ * Headers for SuperGrok CLI billing (`cli-chat-proxy.grok.com`).
32
+ * Official Grok CLI also sends `X-XAI-Token-Auth: xai-grok-cli` on this host;
33
+ * include it so billing stays on the same product gate as chat inference.
34
+ */
35
+ export declare function getXAICliBillingHeaders(options: {
36
+ accessToken: string;
37
+ }): Record<string, string>;
22
38
  /** Log in to xAI Grok with the RFC 8628 device authorization grant. */
23
39
  export declare function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials>;
24
40
  /**
@@ -0,0 +1,2 @@
1
+ import type { UsageProvider } from "../usage.js";
2
+ export declare const syntheticUsageProvider: UsageProvider;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SuperGrok (`xai-oauth`) subscription usage provider.
3
+ *
4
+ * Reads weekly credit and product utilization from the Grok CLI billing
5
+ * endpoint. Only OAuth access credentials are accepted; paid API keys are a
6
+ * separate product and must never be sent here.
7
+ */
8
+ import type { UsageProvider } from "../usage.js";
9
+ export declare const xaiOauthUsageProvider: UsageProvider;
@@ -11,6 +11,12 @@ export interface UsageWindow {
11
11
  durationMs?: number;
12
12
  /** Absolute reset timestamp in milliseconds since epoch. */
13
13
  resetsAt?: number;
14
+ /**
15
+ * Verb rendered before the {@link resetsAt} countdown (e.g. "tick", "regen").
16
+ * Defaults to "resets" — override for rolling windows where the timestamp is
17
+ * an incremental regeneration step rather than a full window reset.
18
+ */
19
+ resetLabel?: string;
14
20
  }
15
21
  /** Quantitative usage data. */
16
22
  export interface UsageAmount {
@@ -157,6 +163,7 @@ export declare const usageWindowSchema: import("arktype/internal/variants/object
157
163
  label: string;
158
164
  durationMs?: number | undefined;
159
165
  resetsAt?: number | undefined;
166
+ resetLabel?: string | undefined;
160
167
  }, {}>;
161
168
  export declare const usageAmountSchema: import("arktype/internal/variants/object.ts").ObjectType<{
162
169
  used?: number | undefined;
@@ -194,6 +201,7 @@ export declare const usageLimitSchema: import("arktype/internal/variants/object.
194
201
  label: string;
195
202
  durationMs?: number | undefined;
196
203
  resetsAt?: number | undefined;
204
+ resetLabel?: string | undefined;
197
205
  } | undefined;
198
206
  amount: {
199
207
  used?: number | undefined;
@@ -240,6 +248,7 @@ export declare const usageReportSchema: import("arktype/internal/variants/object
240
248
  label: string;
241
249
  durationMs?: number | undefined;
242
250
  resetsAt?: number | undefined;
251
+ resetLabel?: string | undefined;
243
252
  } | undefined;
244
253
  amount: {
245
254
  used?: number | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.0.8",
4
+ "version": "17.0.9",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.8",
42
- "@oh-my-pi/pi-utils": "17.0.8",
43
- "@oh-my-pi/pi-wire": "17.0.8",
41
+ "@oh-my-pi/pi-catalog": "17.0.9",
42
+ "@oh-my-pi/pi-utils": "17.0.9",
43
+ "@oh-my-pi/pi-wire": "17.0.9",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -11,7 +11,7 @@ import { Database, type Statement } from "bun:sqlite";
11
11
  import { createHash } from "node:crypto";
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
- import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
14
+ import { $env, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
15
15
  import type { ApiKeyResolver } from "./auth-retry";
16
16
  import * as AIError from "./error";
17
17
  import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -57,6 +57,8 @@ import {
57
57
  listCodexResetCredits,
58
58
  } from "./usage/openai-codex-reset";
59
59
  import { opencodeGoUsageProvider } from "./usage/opencode-go";
60
+ import { syntheticUsageProvider } from "./usage/synthetic";
61
+ import { xaiOauthUsageProvider } from "./usage/xai-oauth";
60
62
  import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
61
63
 
62
64
  const USAGE_RANKING_METRIC_EPSILON = 1e-9;
@@ -596,6 +598,8 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
596
598
  opencodeGoUsageProvider,
597
599
  githubCopilotUsageProvider,
598
600
  cursorUsageProvider,
601
+ syntheticUsageProvider,
602
+ xaiOauthUsageProvider,
599
603
  ];
600
604
 
601
605
  const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
@@ -3227,6 +3231,29 @@ export class AuthStorage {
3227
3231
  entries = dedupedEntries;
3228
3232
  }
3229
3233
 
3234
+ // SuperGrok billing only accepts OAuth bearers. Catalog envVars for
3235
+ // xai-oauth are [XAI_OAUTH_TOKEN, XAI_API_KEY], so the generic path
3236
+ // would (a) build api_key usage requests from stored keys / the paid
3237
+ // API env var and (b) never fall through to XAI_OAUTH_TOKEN when a
3238
+ // non-OAuth row is the only stored credential. Skip api_key material
3239
+ // and only env-fallback to the dedicated OAuth bearer.
3240
+ if (providerId === "xai-oauth") {
3241
+ let hasUsableStoredOAuthCredential = false;
3242
+ for (const entry of entries) {
3243
+ if (entry.credential.type !== "oauth") continue;
3244
+ const request = this.#buildUsageRequestForOauth(provider, entry.credential, baseUrl);
3245
+ if (providerImpl.supports && !providerImpl.supports(request)) continue;
3246
+ requests.push(request);
3247
+ hasUsableStoredOAuthCredential = true;
3248
+ }
3249
+ const oauthToken = $env.XAI_OAUTH_TOKEN?.trim();
3250
+ if (!hasUsableStoredOAuthCredential && oauthToken) {
3251
+ const request = this.#buildUsageRequest(provider, { type: "oauth", accessToken: oauthToken }, baseUrl);
3252
+ if (!providerImpl.supports || providerImpl.supports(request)) requests.push(request);
3253
+ }
3254
+ continue;
3255
+ }
3256
+
3230
3257
  if (entries.length === 0) {
3231
3258
  const runtimeKey = this.#runtimeOverrides.get(providerId);
3232
3259
  const envKey = getEnvApiKey(providerId);
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ export * from "./usage/ollama";
39
39
  export * from "./usage/openai-codex";
40
40
  export * from "./usage/openai-codex-reset";
41
41
  export * from "./usage/opencode-go";
42
+ export * from "./usage/synthetic";
43
+ export * from "./usage/xai-oauth";
42
44
  export * from "./usage/zai";
43
45
  export * from "./utils/anthropic-auth";
44
46
  export * from "./utils/event-stream";
@@ -2501,6 +2501,7 @@ export async function processResponsesStream<TApi extends Api>(
2501
2501
  const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
2502
2502
  if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
2503
2503
  finalizeCustomToolCallInputDone(entry.block, event.input);
2504
+ entry.block[kStreamingArgumentsDone] = true;
2504
2505
  }
2505
2506
  } else if (event.type === "response.output_item.done") {
2506
2507
  const item = structuredCloneJSON(event.item);
@@ -2620,6 +2621,10 @@ export async function processResponsesStream<TApi extends Api>(
2620
2621
  }
2621
2622
  } else if (terminalEvent) {
2622
2623
  const response = terminalEvent.response;
2624
+ const shouldPromoteIncompleteToolUse =
2625
+ response?.status === "incomplete" &&
2626
+ response.incomplete_details?.reason === "max_output_tokens" &&
2627
+ hasExecutableIncompleteResponsesToolCalls(output);
2623
2628
  finalizePendingResponsesToolCalls(output);
2624
2629
  if (response?.id) {
2625
2630
  output.responseId = response.id;
@@ -2656,7 +2661,11 @@ export async function processResponsesStream<TApi extends Api>(
2656
2661
  kind: "content-blocked",
2657
2662
  });
2658
2663
  }
2659
- promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
2664
+ promoteResponsesToolUseStopReason(
2665
+ output,
2666
+ (response as { end_turn?: boolean } | undefined)?.end_turn,
2667
+ shouldPromoteIncompleteToolUse,
2668
+ );
2660
2669
  options?.onCompleted?.();
2661
2670
  // `response.completed`/`response.incomplete`/`response.done` is the last event of a
2662
2671
  // Responses stream. Stop pulling instead of waiting for the server to
@@ -2712,6 +2721,28 @@ export function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined)
2712
2721
  }
2713
2722
  }
2714
2723
 
2724
+ function hasExecutableIncompleteResponsesToolCalls(output: AssistantMessage): boolean {
2725
+ let hasToolCall = false;
2726
+ for (const block of output.content) {
2727
+ if (block.type !== "toolCall") continue;
2728
+ hasToolCall = true;
2729
+ const pending = block as ToolCall & {
2730
+ [kStreamingPartialJson]?: string;
2731
+ [kStreamingArgumentsDone]?: boolean;
2732
+ };
2733
+ const rawArguments = pending[kStreamingPartialJson];
2734
+ // `output_item.done` is not positive completion proof: our Responses
2735
+ // compatibility encoder force-closes still-open calls before forwarding an
2736
+ // upstream `length` stop. Only an explicit arguments/input-done event sets
2737
+ // this marker; an open ordinary call can instead prove completion with its
2738
+ // retained strict-complete JSON.
2739
+ if (pending[kStreamingArgumentsDone]) continue;
2740
+ if (pending.customWireName !== undefined || rawArguments === undefined) return false;
2741
+ if (classifyJsonPrefix(rawArguments) !== "complete") return false;
2742
+ }
2743
+ return hasToolCall;
2744
+ }
2745
+
2715
2746
  /**
2716
2747
  * Finalize any streamed toolCall block whose `output_item.done` never arrived
2717
2748
  * (lossy proxy, or a terminal event that raced the per-item done): parse the
@@ -2745,8 +2776,15 @@ export function finalizePendingResponsesToolCalls(output: AssistantMessage): voi
2745
2776
  * re-samples instead of ending. Callers set `output.stopReason` from the wire
2746
2777
  * status first via {@link mapOpenAIResponsesStopReason}.
2747
2778
  */
2748
- export function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined): void {
2749
- if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
2779
+ export function promoteResponsesToolUseStopReason(
2780
+ output: AssistantMessage,
2781
+ endTurn: boolean | undefined,
2782
+ promoteIncompleteToolUse = false,
2783
+ ): void {
2784
+ if (
2785
+ output.content.some(block => block.type === "toolCall") &&
2786
+ (output.stopReason === "stop" || (promoteIncompleteToolUse && output.stopReason === "length"))
2787
+ ) {
2750
2788
  output.stopReason = "toolUse";
2751
2789
  }
2752
2790
  if (endTurn === false && output.stopReason === "stop") {
@@ -1,19 +1,35 @@
1
1
  import { afterEach, describe, expect, it, vi } from "bun:test";
2
- import { isXAIAccessTokenExpiring, loginXAIOAuth, refreshXAIOAuthToken, validateXAIEndpoint } from "../xai-oauth";
2
+ import {
3
+ buildXAICliBillingUrl,
4
+ extractXAIAccessTokenSubject,
5
+ fetchXAIOAuthIdentity,
6
+ getXAICliBillingHeaders,
7
+ isXAIAccessTokenExpiring,
8
+ loginXAIOAuth,
9
+ parseXAIAccessTokenPayload,
10
+ refreshXAIOAuthToken,
11
+ validateXAIBillingEndpoint,
12
+ validateXAIEndpoint,
13
+ } from "../xai-oauth";
3
14
 
4
15
  afterEach(() => {
5
16
  vi.restoreAllMocks();
6
17
  });
7
18
 
8
19
  function jwtWithExp(exp: number): string {
20
+ return jwtWithPayload({ exp });
21
+ }
22
+
23
+ function jwtWithPayload(payload: Record<string, unknown>): string {
9
24
  const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
10
- const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
11
- return `${header}.${payload}.sig`;
25
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
26
+ return `${header}.${encodedPayload}.sig`;
12
27
  }
13
28
 
14
29
  const DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
15
30
  const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
16
31
  const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
32
+ const USERINFO_URL = "https://auth.x.ai/oauth2/userinfo";
17
33
  const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
18
34
  const SCOPE = "openid profile email offline_access grok-cli:access api:access";
19
35
 
@@ -43,7 +59,10 @@ function jsonResponse(body: unknown, status: number = 200): Response {
43
59
  });
44
60
  }
45
61
 
46
- function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
62
+ function createDeviceFlowFetch(
63
+ tokenResponses: readonly TokenResponse[],
64
+ userinfoResponse: TokenResponse = { body: {} },
65
+ ) {
47
66
  const requests: RecordedRequest[] = [];
48
67
  let tokenResponseIndex = 0;
49
68
  const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
@@ -64,6 +83,9 @@ function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
64
83
  }
65
84
  return jsonResponse(tokenResponse.body, tokenResponse.status);
66
85
  }
86
+ if (url === USERINFO_URL) {
87
+ return jsonResponse(userinfoResponse.body, userinfoResponse.status);
88
+ }
67
89
  throw new Error(`Unexpected xAI OAuth request: ${url}`);
68
90
  });
69
91
 
@@ -101,6 +123,109 @@ describe("isXAIAccessTokenExpiring", () => {
101
123
  });
102
124
  });
103
125
 
126
+ describe("xAI OAuth helpers", () => {
127
+ it("parses JWT payloads and extracts subjects", () => {
128
+ const token = jwtWithPayload({ sub: " subject-123 ", exp: 1_900_000_000 });
129
+
130
+ expect(parseXAIAccessTokenPayload(token)).toEqual({ sub: " subject-123 ", exp: 1_900_000_000 });
131
+ expect(extractXAIAccessTokenSubject(token)).toBe("subject-123");
132
+ expect(parseXAIAccessTokenPayload("not-a-jwt")).toBeNull();
133
+ expect(extractXAIAccessTokenSubject("not-a-jwt")).toBeUndefined();
134
+ });
135
+
136
+ it("builds the billing URL and CLI-aligned headers", () => {
137
+ expect(buildXAICliBillingUrl()).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits");
138
+ expect(buildXAICliBillingUrl("tokens")).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=tokens");
139
+ expect(getXAICliBillingHeaders({ accessToken: "access-token" })).toEqual({
140
+ Authorization: "Bearer access-token",
141
+ Accept: "application/json",
142
+ "X-XAI-Token-Auth": "xai-grok-cli",
143
+ });
144
+ });
145
+
146
+ it("pins SuperGrok billing URLs to https grok.com hosts", () => {
147
+ expect(validateXAIBillingEndpoint("https://cli-chat-proxy.grok.com/v1/billing")).toBe(
148
+ "https://cli-chat-proxy.grok.com/v1/billing",
149
+ );
150
+ expect(() => validateXAIBillingEndpoint("https://auth.x.ai/v1/billing")).toThrow(/Invalid xAI billing_url/);
151
+ expect(() => validateXAIBillingEndpoint("http://cli-chat-proxy.grok.com/v1/billing")).toThrow(
152
+ /Invalid xAI billing_url/,
153
+ );
154
+ expect(() => validateXAIBillingEndpoint("https://evil.com/v1/billing")).toThrow(/Invalid xAI billing_url/);
155
+ });
156
+
157
+ it("normalizes OIDC userinfo identity", async () => {
158
+ const requests: RecordedRequest[] = [];
159
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
160
+ requests.push({
161
+ url: typeof input === "string" ? input : input instanceof Request ? input.url : input.toString(),
162
+ init,
163
+ });
164
+ return jsonResponse({ sub: "profile-sub", email: "User@Example.com", name: "User" });
165
+ });
166
+
167
+ await expect(fetchXAIOAuthIdentity("access-token", fetchMock as unknown as typeof fetch)).resolves.toEqual({
168
+ accountId: "profile-sub",
169
+ email: "user@example.com",
170
+ name: "User",
171
+ });
172
+ expect(requests[0]?.url).toBe(USERINFO_URL);
173
+ expect(new Headers(requests[0]?.init?.headers)).toEqual(
174
+ new Headers({ Authorization: "Bearer access-token", Accept: "application/json" }),
175
+ );
176
+ expect(requests[0]?.init?.redirect).toBe("error");
177
+ });
178
+
179
+ it("combines caller cancellation with the 15-second userinfo timeout", async () => {
180
+ const timeoutControllers: AbortController[] = [];
181
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(timeoutMs => {
182
+ expect(timeoutMs).toBe(15_000);
183
+ const controller = new AbortController();
184
+ timeoutControllers.push(controller);
185
+ return controller.signal;
186
+ });
187
+ const requests: RecordedRequest[] = [];
188
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
189
+ requests.push({
190
+ url: typeof input === "string" ? input : input instanceof Request ? input.url : input.toString(),
191
+ init,
192
+ });
193
+ const { promise, reject } = Promise.withResolvers<Response>();
194
+ const requestSignal = init?.signal;
195
+ if (!requestSignal) {
196
+ reject(new Error("expected userinfo request signal"));
197
+ } else if (requestSignal.aborted) {
198
+ reject(requestSignal.reason);
199
+ } else {
200
+ requestSignal.addEventListener("abort", () => reject(requestSignal.reason), { once: true });
201
+ }
202
+ return promise;
203
+ });
204
+
205
+ const callerController = new AbortController();
206
+ const callerCancelled = fetchXAIOAuthIdentity(
207
+ "access-token",
208
+ fetchMock as unknown as typeof fetch,
209
+ callerController.signal,
210
+ );
211
+ callerController.abort();
212
+ await expect(callerCancelled).resolves.toBeNull();
213
+ expect(requests[0]?.init?.signal).not.toBe(callerController.signal);
214
+
215
+ expect(timeoutSpy).toHaveBeenCalledWith(15_000);
216
+ const timeoutCancelled = fetchXAIOAuthIdentity(
217
+ "access-token",
218
+ fetchMock as unknown as typeof fetch,
219
+ new AbortController().signal,
220
+ );
221
+ const timeoutController = timeoutControllers[1];
222
+ expect(timeoutController).toBeDefined();
223
+ timeoutController?.abort();
224
+ await expect(timeoutCancelled).resolves.toBeNull();
225
+ expect(requests[1]?.init?.signal).not.toBe(timeoutController?.signal);
226
+ });
227
+ });
228
+
104
229
  describe("validateXAIEndpoint", () => {
105
230
  it("rejects non-HTTPS URLs", () => {
106
231
  expect(() => validateXAIEndpoint("http://x.ai/token", "token_endpoint")).toThrow(/Invalid xAI token_endpoint/);
@@ -131,6 +256,35 @@ describe("refreshXAIOAuthToken", () => {
131
256
  );
132
257
  expect(fetchMock).not.toHaveBeenCalled();
133
258
  });
259
+
260
+ it("persists refreshed OAuth identity from OIDC userinfo", async () => {
261
+ const accessToken = jwtWithPayload({ sub: "jwt-sub" });
262
+ const requests: RecordedRequest[] = [];
263
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
264
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
265
+ requests.push({ url, init });
266
+ if (url === DISCOVERY_URL) return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
267
+ if (url === TOKEN_ENDPOINT) {
268
+ return jsonResponse({
269
+ access_token: accessToken,
270
+ expires_in: 3600,
271
+ });
272
+ }
273
+ if (url === USERINFO_URL) return jsonResponse({ sub: "profile-sub", email: "User@Example.com" });
274
+ throw new Error(`Unexpected xAI OAuth request: ${url}`);
275
+ });
276
+
277
+ await expect(
278
+ refreshXAIOAuthToken("old-refresh-token", fetchMock as unknown as typeof fetch),
279
+ ).resolves.toMatchObject({
280
+ access: accessToken,
281
+ refresh: "old-refresh-token",
282
+ accountId: "profile-sub",
283
+ email: "user@example.com",
284
+ });
285
+ expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, TOKEN_ENDPOINT, USERINFO_URL]);
286
+ expect(requests.find(request => request.url === TOKEN_ENDPOINT)?.init?.redirect).toBe("error");
287
+ });
134
288
  });
135
289
 
136
290
  describe("loginXAIOAuth", () => {
@@ -165,7 +319,12 @@ describe("loginXAIOAuth", () => {
165
319
  onManualCodeInput,
166
320
  });
167
321
 
168
- expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, DEVICE_CODE_URL, TOKEN_ENDPOINT]);
322
+ expect(requests.map(request => request.url)).toEqual([
323
+ DISCOVERY_URL,
324
+ DEVICE_CODE_URL,
325
+ TOKEN_ENDPOINT,
326
+ USERINFO_URL,
327
+ ]);
169
328
 
170
329
  const discoveryRequest = requests[0];
171
330
  expect(discoveryRequest?.init?.method).toBe("GET");
@@ -230,6 +389,7 @@ describe("loginXAIOAuth", () => {
230
389
 
231
390
  const tokenRequests = requests.filter(request => request.url === TOKEN_ENDPOINT);
232
391
  expect(tokenRequests).toHaveLength(3);
392
+ expect(tokenRequests.every(request => request.init?.redirect === "error")).toBe(true);
233
393
  expect(tokenRequests.map(request => Object.fromEntries(requestForm(request)))).toEqual([
234
394
  {
235
395
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
@@ -252,6 +412,54 @@ describe("loginXAIOAuth", () => {
252
412
  expect(credentials.refresh).toBe("eventual-refresh-token");
253
413
  });
254
414
 
415
+ it("retains the JWT subject and succeeds when OIDC userinfo fails", async () => {
416
+ const accessToken = jwtWithPayload({ sub: "jwt-sub" });
417
+ const controller = new AbortController();
418
+ const { fetchMock, requests } = createDeviceFlowFetch(
419
+ [
420
+ {
421
+ body: {
422
+ access_token: accessToken,
423
+ refresh_token: "refresh-token",
424
+ expires_in: 3600,
425
+ },
426
+ },
427
+ ],
428
+ { body: { error: "userinfo unavailable" }, status: 503 },
429
+ );
430
+
431
+ const credentials = await loginXAIOAuth({ fetch: fetchMock, signal: controller.signal });
432
+
433
+ expect(credentials).toMatchObject({
434
+ access: accessToken,
435
+ refresh: "refresh-token",
436
+ accountId: "jwt-sub",
437
+ });
438
+ expect(requests.at(-1)?.url).toBe(USERINFO_URL);
439
+ expect(requests.at(-1)?.init?.signal).not.toBe(controller.signal);
440
+ });
441
+
442
+ it("keeps the JWT subject when userinfo returns only an email", async () => {
443
+ const accessToken = jwtWithPayload({ sub: "jwt-sub" });
444
+ const { fetchMock } = createDeviceFlowFetch(
445
+ [
446
+ {
447
+ body: {
448
+ access_token: accessToken,
449
+ refresh_token: "refresh-token",
450
+ expires_in: 3600,
451
+ },
452
+ },
453
+ ],
454
+ { body: { email: "User@Example.com" } },
455
+ );
456
+
457
+ await expect(loginXAIOAuth({ fetch: fetchMock })).resolves.toMatchObject({
458
+ accountId: "jwt-sub",
459
+ email: "user@example.com",
460
+ });
461
+ });
462
+
255
463
  it("rejects a token response that omits access_token", async () => {
256
464
  const { fetchMock, requests } = createDeviceFlowFetch([
257
465
  {
@@ -15,8 +15,12 @@ import type { OAuthController, OAuthCredentials } from "./types";
15
15
  const XAI_OAUTH_ISSUER = "https://auth.x.ai";
16
16
  const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
17
17
  const XAI_OAUTH_DEVICE_CODE_URL = `${XAI_OAUTH_ISSUER}/oauth2/device/code`;
18
+ const XAI_OAUTH_USERINFO_URL = `${XAI_OAUTH_ISSUER}/oauth2/userinfo`;
18
19
  const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
19
20
  const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
21
+ const XAI_CLI_BILLING_BASE_URL = "https://cli-chat-proxy.grok.com";
22
+ const XAI_CLI_BILLING_PATH = "/v1/billing";
23
+ const XAI_CLI_BILLING_FORMAT = "credits";
20
24
 
21
25
  // Mirrors the 5-min skew used by anthropic.ts:160 — keeps every provider on the
22
26
  // same conservative client-side expiry window.
@@ -51,6 +55,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
51
55
  * @throws Error with message `Invalid xAI <field>: <url>` when the URL fails
52
56
  * either scheme or host validation.
53
57
  */
58
+ function isXaiAuthHostname(host: string): boolean {
59
+ return host === "x.ai" || host.endsWith(".x.ai");
60
+ }
61
+
62
+ /** SuperGrok CLI billing proxy host (`cli-chat-proxy.grok.com`), not the OIDC issuer. */
63
+ function isXaiBillingHostname(host: string): boolean {
64
+ return host === "grok.com" || host.endsWith(".grok.com");
65
+ }
66
+
54
67
  export function validateXAIEndpoint(url: string, field: string): string {
55
68
  let parsed: URL;
56
69
  try {
@@ -62,7 +75,28 @@ export function validateXAIEndpoint(url: string, field: string): string {
62
75
  throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
63
76
  }
64
77
  const host = parsed.hostname.toLowerCase();
65
- if (!host || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
78
+ if (!host || !isXaiAuthHostname(host)) {
79
+ throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
80
+ }
81
+ return url;
82
+ }
83
+
84
+ /**
85
+ * Pin SuperGrok billing URLs to HTTPS `grok.com` / `*.grok.com`.
86
+ * The CLI billing proxy is intentionally not on `*.x.ai`.
87
+ */
88
+ export function validateXAIBillingEndpoint(url: string, field: string = "billing_url"): string {
89
+ let parsed: URL;
90
+ try {
91
+ parsed = new URL(url);
92
+ } catch {
93
+ throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
94
+ }
95
+ if (parsed.protocol !== "https:") {
96
+ throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
97
+ }
98
+ const host = parsed.hostname.toLowerCase();
99
+ if (!host || !isXaiBillingHostname(host)) {
66
100
  throw new AIError.OAuthError(`Invalid xAI ${field}: ${url}`, { kind: "validation", provider: "xai" });
67
101
  }
68
102
  return url;
@@ -124,6 +158,22 @@ async function xaiOAuthDiscovery(
124
158
  return { token_endpoint: tokenEndpoint };
125
159
  }
126
160
 
161
+ /** Decode an xAI access-token JWT payload without verifying its signature. */
162
+ export function parseXAIAccessTokenPayload(jwt: string): Record<string, unknown> | null {
163
+ try {
164
+ if (typeof jwt !== "string" || !jwt.includes(".")) return null;
165
+ const parts = jwt.split(".");
166
+ if (parts.length < 2) return null;
167
+ const payloadPart = parts[1];
168
+ if (!payloadPart) return null;
169
+ const decoded = Buffer.from(payloadPart, "base64url").toString("utf8");
170
+ const payload = JSON.parse(decoded) as unknown;
171
+ return isRecord(payload) && !Array.isArray(payload) ? payload : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
127
177
  /**
128
178
  * Check whether a JWT access token is at or past its `exp` claim (with an
129
179
  * optional refresh-skew margin).
@@ -132,25 +182,100 @@ async function xaiOAuthDiscovery(
132
182
  * not token validation.
133
183
  */
134
184
  export function isXAIAccessTokenExpiring(jwt: string, skewSeconds: number = 0): boolean {
185
+ const payload = parseXAIAccessTokenPayload(jwt);
186
+ if (!payload) return false;
187
+ const exp = payload.exp;
188
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
189
+ const now = Math.floor(Date.now() / 1000);
190
+ const skew = Math.max(0, Math.floor(skewSeconds));
191
+ return exp <= now + skew;
192
+ }
193
+
194
+ /** Extract the stable xAI subject UUID from an access token. */
195
+ export function extractXAIAccessTokenSubject(jwt: string): string | undefined {
196
+ const sub = parseXAIAccessTokenPayload(jwt)?.sub;
197
+ return typeof sub === "string" && sub.trim() ? sub.trim() : undefined;
198
+ }
199
+
200
+ export interface XAIOAuthIdentity {
201
+ accountId?: string;
202
+ email?: string;
203
+ name?: string;
204
+ }
205
+
206
+ /** Fetch optional OIDC userinfo for a valid xAI access token. */
207
+ export async function fetchXAIOAuthIdentity(
208
+ accessToken: string,
209
+ fetchOverride?: FetchImpl,
210
+ signal?: AbortSignal,
211
+ ): Promise<XAIOAuthIdentity | null> {
212
+ const token = accessToken.trim();
213
+ if (!token) return null;
214
+ const fetchImpl = fetchOverride ?? fetch;
135
215
  try {
136
- if (typeof jwt !== "string" || !jwt.includes(".")) return false;
137
- const parts = jwt.split(".");
138
- if (parts.length < 2) return false;
139
- const payloadPart = parts[1];
140
- if (!payloadPart) return false;
141
- const decoded = Buffer.from(payloadPart, "base64url").toString("utf8");
142
- const payload: unknown = JSON.parse(decoded);
143
- if (!isRecord(payload)) return false;
144
- const exp = payload.exp;
145
- if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
146
- const now = Math.floor(Date.now() / 1000);
147
- const skew = Math.max(0, Math.floor(skewSeconds));
148
- return exp <= now + skew;
216
+ const response = await fetchImpl(XAI_OAUTH_USERINFO_URL, {
217
+ method: "GET",
218
+ headers: {
219
+ Authorization: `Bearer ${token}`,
220
+ Accept: "application/json",
221
+ },
222
+ redirect: "error",
223
+ signal: signal
224
+ ? AbortSignal.any([signal, AbortSignal.timeout(DISCOVERY_TIMEOUT_MS)])
225
+ : AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
226
+ });
227
+ if (!response.ok) return null;
228
+ const payload = (await response.json()) as unknown;
229
+ if (!isRecord(payload) || Array.isArray(payload)) return null;
230
+ const sub = typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined;
231
+ const email = typeof payload.email === "string" && payload.email.trim() ? payload.email.trim() : undefined;
232
+ const name = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : undefined;
233
+ if (!sub && !email && !name) return null;
234
+ return {
235
+ ...(sub ? { accountId: sub } : {}),
236
+ ...(email ? { email: email.toLowerCase() } : {}),
237
+ ...(name ? { name } : {}),
238
+ };
149
239
  } catch {
150
- return false;
240
+ return null;
151
241
  }
152
242
  }
153
243
 
244
+ async function withXAIOAuthIdentity(
245
+ credentials: OAuthCredentials,
246
+ fetchOverride?: FetchImpl,
247
+ signal?: AbortSignal,
248
+ ): Promise<OAuthCredentials> {
249
+ const identity = await fetchXAIOAuthIdentity(credentials.access, fetchOverride, signal);
250
+ const accountId = identity?.accountId ?? credentials.accountId ?? extractXAIAccessTokenSubject(credentials.access);
251
+ const email = identity?.email ?? credentials.email;
252
+ return {
253
+ ...credentials,
254
+ ...(accountId ? { accountId } : {}),
255
+ ...(email ? { email } : {}),
256
+ };
257
+ }
258
+
259
+ /** Build the SuperGrok CLI billing URL. */
260
+ export function buildXAICliBillingUrl(format: string = XAI_CLI_BILLING_FORMAT): string {
261
+ const url = new URL(XAI_CLI_BILLING_PATH, XAI_CLI_BILLING_BASE_URL);
262
+ url.searchParams.set("format", format);
263
+ return validateXAIBillingEndpoint(url.toString());
264
+ }
265
+
266
+ /**
267
+ * Headers for SuperGrok CLI billing (`cli-chat-proxy.grok.com`).
268
+ * Official Grok CLI also sends `X-XAI-Token-Auth: xai-grok-cli` on this host;
269
+ * include it so billing stays on the same product gate as chat inference.
270
+ */
271
+ export function getXAICliBillingHeaders(options: { accessToken: string }): Record<string, string> {
272
+ return {
273
+ Authorization: `Bearer ${options.accessToken}`,
274
+ Accept: "application/json",
275
+ "X-XAI-Token-Auth": "xai-grok-cli",
276
+ };
277
+ }
278
+
154
279
  function parseXAIDeviceAuthorization(payload: unknown): XAIDeviceAuthorization {
155
280
  if (!isRecord(payload)) {
156
281
  throw new AIError.OAuthError("xAI device-code response was not a JSON object.", {
@@ -304,6 +429,7 @@ async function pollXAIDeviceToken(
304
429
  client_id: XAI_OAUTH_CLIENT_ID,
305
430
  device_code: deviceCode,
306
431
  }),
432
+ redirect: "error",
307
433
  signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
308
434
  });
309
435
  } catch (error) {
@@ -364,12 +490,13 @@ export async function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredent
364
490
  });
365
491
  ctrl.onProgress?.("Waiting for xAI device authorization...");
366
492
 
367
- return pollOAuthDeviceCodeFlow({
493
+ const credentials = await pollOAuthDeviceCodeFlow({
368
494
  poll: () => pollXAIDeviceToken(discovery.token_endpoint, device.deviceCode, fetchImpl, ctrl.signal),
369
495
  intervalSeconds: device.intervalSeconds,
370
496
  expiresInSeconds: device.expiresInSeconds,
371
497
  signal: ctrl.signal,
372
498
  });
499
+ return withXAIOAuthIdentity(credentials, fetchImpl, ctrl.signal);
373
500
  }
374
501
 
375
502
  /**
@@ -400,6 +527,7 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?:
400
527
  Accept: "application/json",
401
528
  },
402
529
  body,
530
+ redirect: "error",
403
531
  signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
404
532
  });
405
533
 
@@ -426,5 +554,6 @@ export async function refreshXAIOAuthToken(refreshToken: string, fetchOverride?:
426
554
  { kind: "validation", provider: "xai", cause: error },
427
555
  );
428
556
  }
429
- return parseXAITokenResponse(payload, "xAI token refresh response", refreshToken);
557
+ const credentials = parseXAITokenResponse(payload, "xAI token refresh response", refreshToken);
558
+ return withXAIOAuthIdentity(credentials, fetchImpl);
430
559
  }
@@ -0,0 +1,180 @@
1
+ import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
2
+ import type {
3
+ UsageAmount,
4
+ UsageFetchContext,
5
+ UsageFetchParams,
6
+ UsageLimit,
7
+ UsageProvider,
8
+ UsageReport,
9
+ UsageStatus,
10
+ UsageWindow,
11
+ } from "../usage";
12
+
13
+ const QUOTAS_URL = "https://api.synthetic.new/v2/quotas";
14
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
15
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
16
+
17
+ function parseDollarAmount(value: unknown): number | undefined {
18
+ if (typeof value !== "string") return undefined;
19
+ const trimmed = value.replace(/^\$/, "").trim();
20
+ const parsed = Number(trimmed);
21
+ return Number.isFinite(parsed) ? parsed : undefined;
22
+ }
23
+
24
+ function parseIsoMs(value: unknown): number | undefined {
25
+ if (typeof value !== "string" || !value) return undefined;
26
+ const ms = Date.parse(value);
27
+ return Number.isFinite(ms) ? ms : undefined;
28
+ }
29
+
30
+ function buildUsageAmount(args: {
31
+ used: number | undefined;
32
+ limit: number | undefined;
33
+ remaining: number | undefined;
34
+ usedFraction: number | undefined;
35
+ unit: UsageAmount["unit"];
36
+ }): UsageAmount {
37
+ let usedFraction = args.usedFraction;
38
+ if (usedFraction === undefined && args.used !== undefined && args.limit !== undefined && args.limit > 0) {
39
+ usedFraction = Math.min(args.used / args.limit, 1);
40
+ }
41
+ const remainingFraction = usedFraction !== undefined ? Math.max(1 - usedFraction, 0) : undefined;
42
+ return {
43
+ ...(args.used !== undefined ? { used: args.used } : {}),
44
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
45
+ ...(args.remaining !== undefined ? { remaining: args.remaining } : {}),
46
+ ...(usedFraction !== undefined ? { usedFraction } : {}),
47
+ ...(remainingFraction !== undefined ? { remainingFraction } : {}),
48
+ unit: args.unit,
49
+ };
50
+ }
51
+
52
+ function getUsageStatus(usedFraction: number | undefined): UsageStatus | undefined {
53
+ if (usedFraction === undefined) return undefined;
54
+ if (usedFraction >= 1) return "exhausted";
55
+ if (usedFraction >= 0.9) return "warning";
56
+ return "ok";
57
+ }
58
+
59
+ function parseRollingFiveHourLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null {
60
+ if (!isRecord(raw)) return null;
61
+ const remaining = typeof raw.remaining === "number" ? raw.remaining : undefined;
62
+ const max = typeof raw.max === "number" ? raw.max : undefined;
63
+ const limited = raw.limited === true;
64
+ const nextTickAt = parseIsoMs(raw.nextTickAt);
65
+ const tickPercent = typeof raw.tickPercent === "number" ? raw.tickPercent : undefined;
66
+
67
+ if (remaining === undefined && max === undefined) return null;
68
+
69
+ const used = max !== undefined && remaining !== undefined ? max - remaining : undefined;
70
+ const regenPercent = tickPercent !== undefined ? Number((tickPercent * 100).toFixed(2)) : undefined;
71
+ const window: UsageWindow = {
72
+ id: "5h",
73
+ label: regenPercent !== undefined ? `5h · regen ${regenPercent}%/tick` : "5h",
74
+ durationMs: FIVE_HOUR_MS,
75
+ ...(nextTickAt !== undefined ? { resetsAt: nextTickAt, resetLabel: "tick" } : {}),
76
+ };
77
+ const amount = buildUsageAmount({
78
+ used,
79
+ limit: max,
80
+ remaining,
81
+ usedFraction: undefined,
82
+ unit: "requests",
83
+ });
84
+ const status: UsageStatus = limited ? "exhausted" : (getUsageStatus(amount.usedFraction) ?? "ok");
85
+ return {
86
+ id: "synthetic:requests:5h",
87
+ label: "Synthetic Requests",
88
+ scope: { provider, windowId: "5h", shared: true },
89
+ window,
90
+ amount,
91
+ status,
92
+ };
93
+ }
94
+
95
+ function parseWeeklyTokenLimit(raw: unknown, provider: UsageFetchParams["provider"]): UsageLimit | null {
96
+ if (!isRecord(raw)) return null;
97
+ const remainingCredits = parseDollarAmount(raw.remainingCredits);
98
+ const maxCredits = parseDollarAmount(raw.maxCredits);
99
+ const percentRemaining = typeof raw.percentRemaining === "number" ? raw.percentRemaining : undefined;
100
+ const nextRegenAt = parseIsoMs(raw.nextRegenAt);
101
+
102
+ if (remainingCredits === undefined && maxCredits === undefined) return null;
103
+
104
+ const usedFraction =
105
+ percentRemaining !== undefined ? Math.min(Math.max(1 - percentRemaining / 100, 0), 1) : undefined;
106
+ const used = usedFraction !== undefined && maxCredits !== undefined ? usedFraction * maxCredits : undefined;
107
+ const nextRegenCredits = parseDollarAmount(raw.nextRegenCredits);
108
+ const window: UsageWindow = {
109
+ id: "7d",
110
+ label: nextRegenCredits !== undefined ? `7d · regen $${nextRegenCredits.toFixed(2)}/tick` : "7d",
111
+ durationMs: WEEK_MS,
112
+ ...(nextRegenAt !== undefined ? { resetsAt: nextRegenAt, resetLabel: "regen" } : {}),
113
+ };
114
+ const amount = buildUsageAmount({
115
+ used,
116
+ limit: maxCredits,
117
+ remaining: remainingCredits,
118
+ usedFraction,
119
+ unit: "usd",
120
+ });
121
+ return {
122
+ id: "synthetic:usd:7d",
123
+ label: "Synthetic Credits",
124
+ scope: { provider, windowId: "7d", shared: true },
125
+ window,
126
+ amount,
127
+ status: getUsageStatus(amount.usedFraction),
128
+ };
129
+ }
130
+
131
+ async function fetchSyntheticUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
132
+ if (params.provider !== "synthetic") return null;
133
+ const credential = params.credential;
134
+ if (credential.type !== "api_key" || !credential.apiKey) return null;
135
+
136
+ let payload: unknown = null;
137
+ try {
138
+ const response = await ctx.fetch(QUOTAS_URL, {
139
+ headers: {
140
+ Authorization: `Bearer ${credential.apiKey}`,
141
+ "Content-Type": "application/json",
142
+ },
143
+ signal: params.signal,
144
+ });
145
+ if (!response.ok) {
146
+ ctx.logger?.warn("Synthetic usage fetch failed", { status: response.status, statusText: response.statusText });
147
+ return null;
148
+ }
149
+ payload = await response.json();
150
+ } catch (error) {
151
+ ctx.logger?.warn("Synthetic usage fetch error", { error: String(error) });
152
+ return null;
153
+ }
154
+
155
+ if (!isRecord(payload)) return null;
156
+
157
+ const limits: UsageLimit[] = [];
158
+
159
+ const fiveHour = parseRollingFiveHourLimit(payload.rollingFiveHourLimit, params.provider);
160
+ if (fiveHour) limits.push(fiveHour);
161
+
162
+ const weekly = parseWeeklyTokenLimit(payload.weeklyTokenLimit, params.provider);
163
+ if (weekly) limits.push(weekly);
164
+
165
+ if (limits.length === 0) return null;
166
+
167
+ return {
168
+ provider: params.provider,
169
+ fetchedAt: Date.now(),
170
+ limits,
171
+ metadata: { endpoint: QUOTAS_URL },
172
+ raw: payload,
173
+ };
174
+ }
175
+
176
+ export const syntheticUsageProvider: UsageProvider = {
177
+ id: "synthetic",
178
+ fetchUsage: fetchSyntheticUsage,
179
+ supports: params => params.provider === "synthetic" && params.credential.type === "api_key",
180
+ };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * SuperGrok (`xai-oauth`) subscription usage provider.
3
+ *
4
+ * Reads weekly credit and product utilization from the Grok CLI billing
5
+ * endpoint. Only OAuth access credentials are accepted; paid API keys are a
6
+ * separate product and must never be sent here.
7
+ */
8
+
9
+ import {
10
+ buildXAICliBillingUrl,
11
+ extractXAIAccessTokenSubject,
12
+ fetchXAIOAuthIdentity,
13
+ getXAICliBillingHeaders,
14
+ } from "../registry/oauth/xai-oauth";
15
+ import type {
16
+ UsageAmount,
17
+ UsageFetchContext,
18
+ UsageFetchParams,
19
+ UsageLimit,
20
+ UsageProvider,
21
+ UsageReport,
22
+ UsageStatus,
23
+ UsageWindow,
24
+ } from "../usage";
25
+ import { isRecord } from "../utils";
26
+ import { toNumber } from "./shared";
27
+
28
+ const PROVIDER_ID = "xai-oauth";
29
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
30
+
31
+ interface XaiBillingPeriod {
32
+ start: string;
33
+ end: string;
34
+ type: string;
35
+ }
36
+
37
+ interface XaiProductUsage {
38
+ product: string;
39
+ usagePercent: number;
40
+ }
41
+
42
+ interface XaiBillingConfig {
43
+ currentPeriod: XaiBillingPeriod;
44
+ creditUsagePercent: number;
45
+ productUsage: XaiProductUsage[];
46
+ onDemandCap?: number;
47
+ onDemandUsed?: number;
48
+ }
49
+
50
+ function parseIsoMs(value: string): number | undefined {
51
+ const parsed = Date.parse(value);
52
+ return Number.isFinite(parsed) ? parsed : undefined;
53
+ }
54
+
55
+ function parsePercent(value: unknown): number | undefined {
56
+ const percent = toNumber(value);
57
+ return percent !== undefined && percent >= 0 && percent <= 100 ? percent : undefined;
58
+ }
59
+
60
+ function parseOnDemandAmount(value: unknown): number | undefined {
61
+ if (!isRecord(value)) return undefined;
62
+ const amount = toNumber(value.val);
63
+ return amount !== undefined && amount >= 0 ? amount : undefined;
64
+ }
65
+
66
+ function buildPercentAmount(usagePercent: number): UsageAmount {
67
+ const usedFraction = usagePercent / 100;
68
+ return {
69
+ used: usagePercent,
70
+ limit: 100,
71
+ remaining: 100 - usagePercent,
72
+ usedFraction,
73
+ remainingFraction: 1 - usedFraction,
74
+ unit: "percent",
75
+ };
76
+ }
77
+
78
+ function buildUsageStatus(usedFraction: number): UsageStatus {
79
+ if (usedFraction >= 1) return "exhausted";
80
+ if (usedFraction >= 0.9) return "warning";
81
+ return "ok";
82
+ }
83
+
84
+ function slugifyProduct(product: string): string {
85
+ return product
86
+ .trim()
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, "-")
89
+ .replace(/^-+|-+$/g, "");
90
+ }
91
+
92
+ function buildPeriodWindow(period: XaiBillingPeriod): UsageWindow {
93
+ return {
94
+ id: "1w",
95
+ label: "Weekly",
96
+ durationMs: WEEK_MS,
97
+ resetsAt: parseIsoMs(period.end),
98
+ };
99
+ }
100
+
101
+ function parseBillingConfig(payload: unknown): XaiBillingConfig | null {
102
+ if (!isRecord(payload) || !isRecord(payload.config)) return null;
103
+ const raw = payload.config;
104
+ if (!isRecord(raw.currentPeriod)) return null;
105
+
106
+ const start = typeof raw.currentPeriod.start === "string" ? parseIsoMs(raw.currentPeriod.start) : undefined;
107
+ const end = typeof raw.currentPeriod.end === "string" ? parseIsoMs(raw.currentPeriod.end) : undefined;
108
+ const type = typeof raw.currentPeriod.type === "string" ? raw.currentPeriod.type : "";
109
+ // Keep recently-ended weekly windows so /usage still renders across period
110
+ // rollover while the billing API is mid-refresh. Reject only inverted ranges
111
+ // and non-weekly period types.
112
+ if (start === undefined || end === undefined || end <= start || !type.toUpperCase().includes("WEEK")) {
113
+ return null;
114
+ }
115
+
116
+ const creditUsagePercent = parsePercent(raw.creditUsagePercent);
117
+ if (creditUsagePercent === undefined) return null;
118
+
119
+ const productUsage: XaiProductUsage[] = [];
120
+ if (raw.productUsage !== undefined) {
121
+ if (!Array.isArray(raw.productUsage)) return null;
122
+ for (const item of raw.productUsage) {
123
+ if (!isRecord(item)) continue;
124
+ const product = typeof item.product === "string" ? item.product.trim() : "";
125
+ const usagePercent = parsePercent(item.usagePercent);
126
+ if (!product || usagePercent === undefined) continue;
127
+ productUsage.push({ product, usagePercent });
128
+ }
129
+ }
130
+
131
+ return {
132
+ currentPeriod: {
133
+ start: raw.currentPeriod.start as string,
134
+ end: raw.currentPeriod.end as string,
135
+ type,
136
+ },
137
+ creditUsagePercent,
138
+ productUsage,
139
+ onDemandCap: parseOnDemandAmount(raw.onDemandCap),
140
+ onDemandUsed: parseOnDemandAmount(raw.onDemandUsed),
141
+ };
142
+ }
143
+
144
+ function buildLimits(config: XaiBillingConfig, accountId: string | undefined): UsageLimit[] {
145
+ const window = buildPeriodWindow(config.currentPeriod);
146
+ const scope = {
147
+ provider: PROVIDER_ID,
148
+ ...(accountId ? { accountId } : {}),
149
+ windowId: window.id,
150
+ shared: true as const,
151
+ };
152
+ const overall = buildPercentAmount(config.creditUsagePercent);
153
+ const limits: UsageLimit[] = [
154
+ {
155
+ id: `${PROVIDER_ID}:credits:1w`,
156
+ label: "SuperGrok Weekly Credits",
157
+ scope,
158
+ window,
159
+ amount: overall,
160
+ status: buildUsageStatus(overall.usedFraction ?? 0),
161
+ },
162
+ ];
163
+
164
+ for (const item of config.productUsage) {
165
+ const amount = buildPercentAmount(item.usagePercent);
166
+ const slug = slugifyProduct(item.product);
167
+ if (!slug) continue;
168
+ limits.push({
169
+ id: `${PROVIDER_ID}:product:${slug}:1w`,
170
+ label: `${item.product === "GrokBuild" ? "Grok Build" : item.product === "Api" ? "API" : item.product} (Weekly)`,
171
+ scope,
172
+ window,
173
+ amount,
174
+ status: buildUsageStatus(amount.usedFraction ?? 0),
175
+ });
176
+ }
177
+ if (config.onDemandCap !== undefined && config.onDemandCap > 0 && config.onDemandUsed !== undefined) {
178
+ const usedFraction = Math.min(config.onDemandUsed / config.onDemandCap, 1);
179
+ limits.push({
180
+ id: `${PROVIDER_ID}:on-demand`,
181
+ label: "On-demand",
182
+ scope: {
183
+ provider: PROVIDER_ID,
184
+ ...(accountId ? { accountId } : {}),
185
+ shared: true,
186
+ },
187
+ amount: {
188
+ used: config.onDemandUsed,
189
+ limit: config.onDemandCap,
190
+ remaining: Math.max(0, config.onDemandCap - config.onDemandUsed),
191
+ usedFraction,
192
+ remainingFraction: 1 - usedFraction,
193
+ unit: "unknown",
194
+ },
195
+ status: buildUsageStatus(usedFraction),
196
+ });
197
+ }
198
+
199
+ return limits;
200
+ }
201
+
202
+ export const xaiOauthUsageProvider: UsageProvider = {
203
+ id: PROVIDER_ID,
204
+
205
+ supports(params: UsageFetchParams): boolean {
206
+ return params.provider === PROVIDER_ID && params.credential.type === "oauth" && !!params.credential.accessToken;
207
+ },
208
+
209
+ async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
210
+ if (params.provider !== PROVIDER_ID || params.credential.type !== "oauth") return null;
211
+ const accessToken = params.credential.accessToken?.trim();
212
+ if (!accessToken) return null;
213
+ if (params.credential.expiresAt !== undefined && params.credential.expiresAt <= Date.now()) return null;
214
+
215
+ let accountId = params.credential.accountId?.trim() || extractXAIAccessTokenSubject(accessToken);
216
+ let email = params.credential.email?.trim().toLowerCase();
217
+ if (!email) {
218
+ try {
219
+ const identity = await fetchXAIOAuthIdentity(accessToken, ctx.fetch, params.signal);
220
+ email = identity?.email?.trim().toLowerCase() || undefined;
221
+ accountId ??= identity?.accountId?.trim() || undefined;
222
+ } catch {
223
+ // Identity enrichment is best effort; billing remains authoritative.
224
+ }
225
+ }
226
+
227
+ const url = buildXAICliBillingUrl();
228
+ let payload: unknown;
229
+ try {
230
+ const response = await ctx.fetch(url, {
231
+ headers: getXAICliBillingHeaders({ accessToken }),
232
+ redirect: "error",
233
+ signal: params.signal,
234
+ });
235
+ if (!response.ok) return null;
236
+ payload = await response.json();
237
+ } catch {
238
+ return null;
239
+ }
240
+
241
+ const config = parseBillingConfig(payload);
242
+ if (!config) return null;
243
+ return {
244
+ provider: PROVIDER_ID,
245
+ fetchedAt: Date.now(),
246
+ limits: buildLimits(config, accountId),
247
+ metadata: {
248
+ endpoint: url,
249
+ source: "cli-chat-proxy.grok.com/v1/billing",
250
+ ...(accountId ? { accountId } : {}),
251
+ ...(email ? { email } : {}),
252
+ },
253
+ raw: payload,
254
+ };
255
+ },
256
+ };
package/src/usage.ts CHANGED
@@ -20,6 +20,12 @@ export interface UsageWindow {
20
20
  durationMs?: number;
21
21
  /** Absolute reset timestamp in milliseconds since epoch. */
22
22
  resetsAt?: number;
23
+ /**
24
+ * Verb rendered before the {@link resetsAt} countdown (e.g. "tick", "regen").
25
+ * Defaults to "resets" — override for rolling windows where the timestamp is
26
+ * an incremental regeneration step rather than a full window reset.
27
+ */
28
+ resetLabel?: string;
23
29
  }
24
30
 
25
31
  /** Quantitative usage data. */
@@ -189,6 +195,7 @@ export const usageWindowSchema = type({
189
195
  label: "string",
190
196
  "durationMs?": "number",
191
197
  "resetsAt?": "number",
198
+ "resetLabel?": "string",
192
199
  });
193
200
 
194
201
  export const usageAmountSchema = type({