@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 +9 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/providers/openai-shared.d.ts +1 -1
- package/dist/types/registry/oauth/xai-oauth.d.ts +25 -9
- package/dist/types/usage/synthetic.d.ts +2 -0
- package/dist/types/usage/xai-oauth.d.ts +9 -0
- package/dist/types/usage.d.ts +9 -0
- package/package.json +4 -4
- package/src/auth-storage.ts +28 -1
- package/src/index.ts +2 -0
- package/src/providers/openai-shared.ts +41 -3
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +213 -5
- package/src/registry/oauth/xai-oauth.ts +146 -17
- package/src/usage/synthetic.ts +180 -0
- package/src/usage/xai-oauth.ts +256 -0
- package/src/usage.ts +7 -0
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
|
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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,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;
|
package/dist/types/usage.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
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
|
},
|
package/src/auth-storage.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
2749
|
-
|
|
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 {
|
|
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
|
|
11
|
-
return `${header}.${
|
|
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(
|
|
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([
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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({
|