@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.
@@ -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
  }
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 hasDedicatedServiceTierControl(provider: Provider | undefined): boolean {
145
- return provider === "fireworks";
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
- !hasDedicatedServiceTierControl(model.provider) && isOpenAIServiceTierApi(model.api) && isOpenAIModelId(model.id)
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 that provider owns a separate tier control such as Fireworks.
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;