@oh-my-pi/pi-ai 17.0.7 → 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 +25 -0
- package/dist/types/error/flags.d.ts +7 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/providers/__tests__/keyless-auth-header.test.d.ts +1 -0
- package/dist/types/providers/openai-shared.d.ts +10 -1
- package/dist/types/registry/oauth/xai-oauth.d.ts +25 -9
- package/dist/types/types.d.ts +2 -1
- 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-gateway/server.ts +13 -7
- package/src/auth-storage.ts +28 -1
- package/src/error/flags.ts +11 -2
- package/src/index.ts +2 -0
- package/src/providers/__tests__/keyless-auth-header.test.ts +39 -0
- package/src/providers/__tests__/kimi-code-thinking.test.ts +1 -1
- package/src/providers/google-gemini-cli.ts +11 -12
- package/src/providers/openai-shared.ts +60 -4
- package/src/registry/alibaba-coding-plan.ts +16 -7
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +213 -5
- package/src/registry/oauth/xai-oauth.ts +146 -17
- package/src/types.ts +9 -4
- package/src/usage/synthetic.ts +180 -0
- package/src/usage/xai-oauth.ts +256 -0
- package/src/usage.ts +7 -0
- package/src/utils/leaked-thinking-stream.ts +58 -9
|
@@ -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
|
}
|
package/src/types.ts
CHANGED
|
@@ -141,13 +141,17 @@ function isOpenAIServiceTierApi(api: Api | undefined): boolean {
|
|
|
141
141
|
return api === "openai-completions" || api === "openai-responses" || api === "openai-codex-responses";
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
function
|
|
145
|
-
|
|
144
|
+
function excludesInferredOpenAIServiceTier(provider: Provider | undefined): boolean {
|
|
145
|
+
// Fireworks has its own priority-only control. GitHub Copilot proxies OpenAI
|
|
146
|
+
// models but rejects OpenAI's `service_tier` request field.
|
|
147
|
+
return provider === "fireworks" || provider === "github-copilot";
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
|
|
149
151
|
return (
|
|
150
|
-
!
|
|
152
|
+
!excludesInferredOpenAIServiceTier(model.provider) &&
|
|
153
|
+
isOpenAIServiceTierApi(model.api) &&
|
|
154
|
+
isOpenAIModelId(model.id)
|
|
151
155
|
);
|
|
152
156
|
}
|
|
153
157
|
|
|
@@ -159,7 +163,8 @@ function isOpenAIServiceTierModel(model: ServiceTierModel): boolean {
|
|
|
159
163
|
* `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
|
|
160
164
|
* anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
|
|
161
165
|
* Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
|
|
162
|
-
* too unless
|
|
166
|
+
* too unless the provider owns a separate tier control (Fireworks) or rejects
|
|
167
|
+
* OpenAI's service-tier field (GitHub Copilot).
|
|
163
168
|
*/
|
|
164
169
|
export function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined {
|
|
165
170
|
const provider = model.provider;
|