@oh-my-pi/pi-ai 16.3.13 → 16.3.15

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
- import type { Effort } from "@oh-my-pi/pi-catalog/effort";
1
+ import { Effort } from "@oh-my-pi/pi-catalog/effort";
2
2
  import { supportsAllTurnsReasoningContext, supportsCodexReasoningSummary } from "@oh-my-pi/pi-catalog/identity";
3
3
  import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
4
- import type { Api, Model } from "../../types";
4
+ import type { Model } from "../../types";
5
+ import { mapOpenAIReasoningEffort } from "../openai-shared";
5
6
 
6
7
  /** Reasoning replay scope for the Codex Responses API (`reasoning.context`). */
7
8
  export type CodexReasoningContext = "auto" | "current_turn" | "all_turns";
8
9
 
10
+ /** User-facing effort levels accepted by Codex request options. */
11
+ type CodexCallerEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
12
+
13
+ /** Caller literal → catalog `Effort` bridge (the enum is nominal). */
14
+ const EFFORT_BY_NAME: Record<CodexCallerEffort, Effort> = {
15
+ minimal: Effort.Minimal,
16
+ low: Effort.Low,
17
+ medium: Effort.Medium,
18
+ high: Effort.High,
19
+ xhigh: Effort.XHigh,
20
+ };
21
+
9
22
  export interface ReasoningConfig {
10
- effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
23
+ effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
11
24
  summary?: "auto" | "concise" | "detailed";
12
25
  context?: CodexReasoningContext;
26
+ /** Pro reasoning serving mode (gpt-5.6+ catalog pro aliases). */
27
+ mode?: "pro";
13
28
  }
14
29
 
15
30
  export interface CodexRequestOptions {
16
- reasoningEffort?: ReasoningConfig["effort"];
31
+ /** User-facing effort; the wire-only `max` tier is reached via the model's effort map. */
32
+ reasoningEffort?: CodexCallerEffort | "none";
17
33
  reasoningSummary?: ReasoningConfig["summary"] | null;
18
34
  /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */
19
35
  reasoningContext?: CodexReasoningContext;
@@ -80,10 +96,40 @@ export function shouldUseCodexResponsesLite(body: RequestBody, requested: boolea
80
96
  return requested === true && !containsInputImage(body.input);
81
97
  }
82
98
 
83
- function getReasoningConfig(model: Model<Api>, options: CodexRequestOptions): ReasoningConfig {
99
+ /**
100
+ * Clamp a user-facing effort to the model's ladder, then remap to the wire
101
+ * tier (e.g. GPT-5.6's shifted five-tier scale sends `max` for user `xhigh`).
102
+ * A mapped value outside the Codex wire vocabulary is a broken compat/model
103
+ * effort map — fail loudly rather than silently sending a different tier.
104
+ */
105
+ function mapCodexWireEffort(
106
+ model: Model<"openai-codex-responses">,
107
+ effort: CodexCallerEffort,
108
+ ): ReasoningConfig["effort"] {
109
+ const mapped = mapOpenAIReasoningEffort(model, model.compat, requireSupportedEffort(model, EFFORT_BY_NAME[effort]));
110
+ switch (mapped) {
111
+ case "none":
112
+ case "minimal":
113
+ case "low":
114
+ case "medium":
115
+ case "high":
116
+ case "xhigh":
117
+ case "max":
118
+ return mapped;
119
+ default:
120
+ throw new Error(
121
+ `Effort map for ${model.provider}/${model.id} produced invalid Codex reasoning effort "${mapped}"`,
122
+ );
123
+ }
124
+ }
125
+
126
+ function getReasoningConfig(
127
+ model: Model<"openai-codex-responses">,
128
+ effort: NonNullable<CodexRequestOptions["reasoningEffort"]>,
129
+ options: CodexRequestOptions,
130
+ ): ReasoningConfig {
84
131
  const config: ReasoningConfig = {
85
- effort:
86
- options.reasoningEffort === "none" ? "none" : requireSupportedEffort(model, options.reasoningEffort as Effort),
132
+ effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort),
87
133
  };
88
134
  // `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids
89
135
  // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
@@ -216,7 +262,7 @@ function stripImageDetails(input: InputItem[]): void {
216
262
 
217
263
  export async function transformRequestBody(
218
264
  body: RequestBody,
219
- model: Model<Api>,
265
+ model: Model<"openai-codex-responses">,
220
266
  options: CodexRequestOptions = {},
221
267
  prompt?: { developerMessages: string[] },
222
268
  ): Promise<RequestBody> {
@@ -300,7 +346,7 @@ export async function transformRequestBody(
300
346
  }
301
347
 
302
348
  if (options.reasoningEffort !== undefined) {
303
- const reasoningConfig = getReasoningConfig(model, options);
349
+ const reasoningConfig = getReasoningConfig(model, options.reasoningEffort, options);
304
350
  body.reasoning = {
305
351
  ...body.reasoning,
306
352
  ...reasoningConfig,
@@ -323,6 +369,12 @@ export async function transformRequestBody(
323
369
  } else {
324
370
  delete body.reasoning;
325
371
  }
372
+ // Catalog pro aliases (`gpt-5.6-*-pro`): applied after the effort branch so
373
+ // the mode is sent even when no effort is set (the branch above deletes
374
+ // `body.reasoning` in that case) — mode and effort are independent fields.
375
+ if (model.reasoningMode) {
376
+ body.reasoning = { ...body.reasoning, mode: model.reasoningMode };
377
+ }
326
378
 
327
379
  body.text = {
328
380
  ...body.text,
@@ -99,7 +99,7 @@ import {
99
99
  finalizeToolCallArgumentsDone,
100
100
  isOpenAIResponsesProgressEvent,
101
101
  mapOpenAIResponsesStopReason,
102
- normalizeOpenAIResponsesPromptCacheKey,
102
+ normalizeOpenAIPromptCacheKey,
103
103
  populateResponsesUsageFromResponse,
104
104
  promoteResponsesToolUseStopReason,
105
105
  } from "./openai-shared";
@@ -898,8 +898,8 @@ async function buildCodexRequestContext(
898
898
  const accountId = getCodexAccountId(apiKey);
899
899
  const baseUrl = model.baseUrl || CODEX_BASE_URL;
900
900
  const url = resolveCodexResponsesUrl(baseUrl);
901
- const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
902
- const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
901
+ const promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
902
+ const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
903
903
  const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
904
904
 
905
905
  const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
@@ -946,10 +946,10 @@ export async function buildTransformedCodexRequestBody(
946
946
  model: Model<"openai-codex-responses">,
947
947
  context: Context,
948
948
  options: OpenAICodexResponsesOptions | undefined,
949
- promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
949
+ promptCacheKey = normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
950
950
  ): Promise<RequestBody> {
951
951
  const params: RequestBody = {
952
- model: model.id,
952
+ model: model.requestModelId ?? model.id,
953
953
  input: convertMessages(model, context),
954
954
  stream: true,
955
955
  prompt_cache_key: promptCacheKey,
@@ -2139,7 +2139,7 @@ export async function prewarmOpenAICodexResponses(
2139
2139
  const accountId = getCodexAccountId(apiKey);
2140
2140
  const baseUrl = model.baseUrl || CODEX_BASE_URL;
2141
2141
  const url = resolveCodexResponsesUrl(baseUrl);
2142
- const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
2142
+ const transportSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
2143
2143
  const promptCacheKey = transportSessionId;
2144
2144
  const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
2145
2145
  const responsesLite = options?.responsesLite === true;
@@ -2283,7 +2283,7 @@ function getCodexWebSocketStateForPublicSession(
2283
2283
  ): CodexWebSocketSessionState | undefined {
2284
2284
  const baseUrl = options?.baseUrl || model.baseUrl || CODEX_BASE_URL;
2285
2285
  const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
2286
- const normalizedSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
2286
+ const normalizedSessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
2287
2287
  const publicSessionKey = normalizedSessionId ? `${baseUrl}:${model.id}:${normalizedSessionId}` : undefined;
2288
2288
  const privateSessionKey = publicSessionKey
2289
2289
  ? providerSessionState?.webSocketPublicToPrivate.get(publicSessionKey)
@@ -82,6 +82,7 @@ import {
82
82
  createInitialResponsesAssistantMessage,
83
83
  createOpenAIStrictToolsState,
84
84
  disableStrictToolsForScope,
85
+ getOpenAIPromptCacheKey,
85
86
  getOpenAIStrictToolsScope,
86
87
  isCompiledGrammarTooLargeStrictError,
87
88
  isOpenRouterAnthropicModel,
@@ -616,6 +617,7 @@ const streamOpenAICompletionsOnce = (
616
617
  apiKey,
617
618
  options?.headers,
618
619
  options?.initiatorOverride,
620
+ getOpenAIPromptCacheKey(options),
619
621
  );
620
622
  const premiumRequestsTotal = copilotPremiumRequests;
621
623
  let appliedStrictTools = false;
@@ -1359,6 +1361,7 @@ function createRequestSetup(
1359
1361
  apiKey?: string,
1360
1362
  extraHeaders?: Record<string, string>,
1361
1363
  initiatorOverride?: MessageAttribution,
1364
+ promptCacheSessionId?: string,
1362
1365
  ): OpenAIRequestSetup & { baseUrl: string } {
1363
1366
  const apiVersion = $env.AZURE_OPENAI_API_VERSION || "2024-10-21";
1364
1367
  const deploymentName = parseAzureDeploymentNameMap($env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).get(model.id) ?? model.id;
@@ -1366,6 +1369,7 @@ function createRequestSetup(
1366
1369
  apiKey,
1367
1370
  extraHeaders,
1368
1371
  initiatorOverride,
1372
+ promptCacheSessionId,
1369
1373
  messages: context.messages,
1370
1374
  defaultBaseUrl: "https://api.openai.com/v1",
1371
1375
  // Provider auth/header overlay: Kimi-code hosts require shared client
@@ -1413,7 +1417,11 @@ function buildParams(
1413
1417
  context: Context,
1414
1418
  options: OpenAICompletionsOptions | undefined,
1415
1419
  toolStrictModeOverride?: ToolStrictModeOverride,
1416
- ): { params: OpenAICompletionsParams; toolStrictMode: AppliedToolStrictMode; strictToolsApplied: boolean } {
1420
+ ): {
1421
+ params: OpenAICompletionsParams;
1422
+ toolStrictMode: AppliedToolStrictMode;
1423
+ strictToolsApplied: boolean;
1424
+ } {
1417
1425
  const initialPolicy = resolveOpenAICompatForRequest(model, options);
1418
1426
  const initialCompat = initialPolicy.compat as ResolvedOpenAICompat;
1419
1427
 
@@ -6318,6 +6318,13 @@ export interface Reasoning {
6318
6318
  * - `xhigh` is supported for all models after `gpt-5.1-codex-max`.
6319
6319
  */
6320
6320
  effort?: ReasoningEffort | null;
6321
+ /**
6322
+ * **gpt-5.6 and later models only**
6323
+ *
6324
+ * Reasoning serving mode. `pro` routes the request to the pro reasoning
6325
+ * path (more compute per response); omit for the standard path.
6326
+ */
6327
+ mode?: "pro" | null;
6321
6328
  /**
6322
6329
  * @deprecated **Deprecated:** use `summary` instead.
6323
6330
  *
@@ -76,7 +76,7 @@ import {
76
76
  createInitialResponsesAssistantMessage,
77
77
  createOpenAIStrictToolsState,
78
78
  disableStrictToolsForScope,
79
- getOpenAIResponsesPromptCacheKey,
79
+ getOpenAIPromptCacheKey,
80
80
  getOpenAIResponsesRoutingSessionId,
81
81
  getOpenAIStrictToolsScope,
82
82
  getOpenRouterResponsesSessionId,
@@ -390,7 +390,7 @@ const streamOpenAIResponsesOnce = (
390
390
  // stable prompt-cache key independently. Side-channel calls use this to
391
391
  // avoid perturbing provider conversation state without cold-starting the cache.
392
392
  const routingSessionId = getOpenAIResponsesRoutingSessionId(options);
393
- const promptCacheSessionId = getOpenAIResponsesPromptCacheKey(options);
393
+ const promptCacheSessionId = getOpenAIPromptCacheKey(options);
394
394
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
395
395
  const { headers, copilotPremiumRequests, baseUrl } = resolveOpenAIRequestSetup(model, {
396
396
  apiKey,
@@ -818,7 +818,7 @@ export function buildParams(
818
818
  }
819
819
 
820
820
  const cacheRetention = resolveCacheRetention(options?.cacheRetention);
821
- const promptCacheKey = getOpenAIResponsesPromptCacheKey(options);
821
+ const promptCacheKey = getOpenAIPromptCacheKey(options);
822
822
  const modelId = applyWireModelIdTransform(
823
823
  model.requestModelId ?? model.id,
824
824
  model.compat.wireModelIdMode,
@@ -904,6 +904,13 @@ export function buildParams(
904
904
  model.thinking?.effortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
905
905
  effort,
906
906
  });
907
+ // Catalog pro aliases (`gpt-5.6-*-pro`): merge AFTER the compat policy so the
908
+ // mode survives every policy branch (disabled/omitted effort included) while
909
+ // keeping whatever effort/summary the policy produced — mode and effort are
910
+ // independent wire fields.
911
+ if (model.reasoningMode) {
912
+ params.reasoning = { ...params.reasoning, mode: model.reasoningMode };
913
+ }
907
914
 
908
915
  applyOpenAIGatewayRouting(params, model.compat);
909
916
 
@@ -123,7 +123,8 @@ export interface OpenAIRequestSetupModel extends OpenAIModelIdentity {
123
123
  compat?: Pick<ResolvedOpenAISharedCompat, "promptCacheSessionHeader">;
124
124
  }
125
125
 
126
- export interface OpenAIResponsesCacheOptions {
126
+ /** Cache identity controls shared by OpenAI-family transports. */
127
+ export interface OpenAICacheOptions {
127
128
  cacheRetention?: CacheRetention;
128
129
  sessionId?: string;
129
130
  promptCacheKey?: string;
@@ -175,6 +176,14 @@ function applyCoreWeaveProjectHeader(headers: Record<string, string>): void {
175
176
  }
176
177
  }
177
178
 
179
+ function setHeaderIfAbsent(headers: Record<string, string>, name: string, value: string): void {
180
+ const normalizedName = name.toLowerCase();
181
+ for (const existingName in headers) {
182
+ if (existingName.toLowerCase() === normalizedName) return;
183
+ }
184
+ headers[name] = value;
185
+ }
186
+
178
187
  export function resolveOpenAIRequestSetup(
179
188
  model: OpenAIRequestSetupModel,
180
189
  options: OpenAIRequestSetupOptions,
@@ -257,11 +266,11 @@ export function resolveOpenAIRequestSetup(
257
266
  }
258
267
 
259
268
  if (options.openAISessionId && model.provider === "openai") {
260
- headers.session_id ??= options.openAISessionId;
261
- headers["x-client-request-id"] ??= options.openAISessionId;
269
+ setHeaderIfAbsent(headers, "session_id", options.openAISessionId);
270
+ setHeaderIfAbsent(headers, "x-client-request-id", options.openAISessionId);
262
271
  }
263
272
  if (options.promptCacheSessionId && model.compat?.promptCacheSessionHeader) {
264
- headers[model.compat.promptCacheSessionHeader] ??= options.promptCacheSessionId;
273
+ setHeaderIfAbsent(headers, model.compat.promptCacheSessionHeader, options.promptCacheSessionId);
265
274
  }
266
275
 
267
276
  if (options.defaultBaseUrl !== undefined) {
@@ -368,7 +377,8 @@ export function calculateOpenAIUsageAccounting(accounting: OpenAIUsageAccounting
368
377
  };
369
378
  }
370
379
 
371
- export function normalizeOpenAIResponsesPromptCacheKey(sessionId: string | undefined): string | undefined {
380
+ /** Normalize a cache identity to the wire limit accepted by OpenAI-family providers. */
381
+ export function normalizeOpenAIPromptCacheKey(sessionId: string | undefined): string | undefined {
372
382
  return normalizeOpenAIStableId(sessionId, 64, "pc_");
373
383
  }
374
384
 
@@ -376,20 +386,21 @@ export function normalizeOpenRouterResponsesSessionId(sessionId: string | undefi
376
386
  return normalizeOpenAIStableId(sessionId, 256, "session_");
377
387
  }
378
388
 
379
- export function getOpenAIResponsesPromptCacheKey(options: OpenAIResponsesCacheOptions | undefined): string | undefined {
389
+ /** Resolve a prompt-cache identity, falling back to the provider session unless caching is disabled. */
390
+ export function getOpenAIPromptCacheKey(options: OpenAICacheOptions | undefined): string | undefined {
380
391
  if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
381
- return normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
392
+ return normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
382
393
  }
383
394
 
384
395
  export function getOpenAIResponsesRoutingSessionId(
385
- options: Pick<OpenAIResponsesCacheOptions, "cacheRetention" | "sessionId"> | undefined,
396
+ options: Pick<OpenAICacheOptions, "cacheRetention" | "sessionId"> | undefined,
386
397
  ): string | undefined {
387
398
  if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
388
- return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
399
+ return normalizeOpenAIPromptCacheKey(options?.sessionId);
389
400
  }
390
401
 
391
402
  export function getOpenRouterResponsesSessionId(
392
- options: Pick<OpenAIResponsesCacheOptions, "cacheRetention" | "sessionId"> | undefined,
403
+ options: Pick<OpenAICacheOptions, "cacheRetention" | "sessionId"> | undefined,
393
404
  ): string | undefined {
394
405
  if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
395
406
  return normalizeOpenRouterResponsesSessionId(options?.sessionId);
@@ -695,13 +706,19 @@ export interface OpenAICompatPolicy {
695
706
  };
696
707
  }
697
708
 
698
- function mapOpenAIReasoningEffort(
709
+ /**
710
+ * Map a user-facing effort to the provider wire value: explicit compat
711
+ * override first, then the model's baked `thinking.effortMap`, else identity.
712
+ * Shared by the chat-completions/Responses policy resolver and the Codex
713
+ * request transformer.
714
+ */
715
+ export function mapOpenAIReasoningEffort(
699
716
  model: Pick<Model, "thinking">,
700
- compat: OpenAICompatPolicyCompat,
717
+ compat: { reasoningEffortMap?: Partial<Record<Effort, string>> } | undefined,
701
718
  effort: string,
702
719
  ): string {
703
720
  const level = effort as Effort;
704
- return compat.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
721
+ return compat?.reasoningEffortMap?.[level] ?? model.thinking?.effortMap?.[level] ?? effort;
705
722
  }
706
723
 
707
724
  function isImplicitDisableWhenNotRequested(disableMode: OpenAIReasoningDisableMode): boolean {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "bun:test";
2
- import { isXAIAccessTokenExpiring, refreshXAIOAuthToken, validateXAIEndpoint, XAIOAuthFlow } from "../xai-oauth";
2
+ import { isXAIAccessTokenExpiring, loginXAIOAuth, refreshXAIOAuthToken, validateXAIEndpoint } from "../xai-oauth";
3
3
 
4
4
  afterEach(() => {
5
5
  vi.restoreAllMocks();
@@ -11,6 +11,76 @@ function jwtWithExp(exp: number): string {
11
11
  return `${header}.${payload}.sig`;
12
12
  }
13
13
 
14
+ const DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
15
+ const DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
16
+ const TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
17
+ const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
18
+ const SCOPE = "openid profile email offline_access grok-cli:access api:access";
19
+
20
+ const DEVICE_AUTHORIZATION = {
21
+ device_code: "device-code-123",
22
+ user_code: "ABCD-EFGH",
23
+ verification_uri: "https://auth.x.ai/activate",
24
+ verification_uri_complete: "https://auth.x.ai/activate?user_code=ABCD-EFGH",
25
+ expires_in: 600,
26
+ interval: 1,
27
+ };
28
+
29
+ type RecordedRequest = {
30
+ url: string;
31
+ init: RequestInit | undefined;
32
+ };
33
+
34
+ type TokenResponse = {
35
+ body: unknown;
36
+ status?: number;
37
+ };
38
+
39
+ function jsonResponse(body: unknown, status: number = 200): Response {
40
+ return new Response(JSON.stringify(body), {
41
+ status,
42
+ headers: { "Content-Type": "application/json" },
43
+ });
44
+ }
45
+
46
+ function createDeviceFlowFetch(tokenResponses: readonly TokenResponse[]) {
47
+ const requests: RecordedRequest[] = [];
48
+ let tokenResponseIndex = 0;
49
+ const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
50
+ const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
51
+ requests.push({ url, init });
52
+
53
+ if (url === DISCOVERY_URL) {
54
+ return jsonResponse({ token_endpoint: TOKEN_ENDPOINT });
55
+ }
56
+ if (url === DEVICE_CODE_URL) {
57
+ return jsonResponse(DEVICE_AUTHORIZATION);
58
+ }
59
+ if (url === TOKEN_ENDPOINT) {
60
+ const tokenResponse = tokenResponses[tokenResponseIndex];
61
+ tokenResponseIndex += 1;
62
+ if (!tokenResponse) {
63
+ throw new Error(`Unexpected xAI token poll ${tokenResponseIndex}`);
64
+ }
65
+ return jsonResponse(tokenResponse.body, tokenResponse.status);
66
+ }
67
+ throw new Error(`Unexpected xAI OAuth request: ${url}`);
68
+ });
69
+
70
+ return {
71
+ fetchMock: fetchMock as unknown as typeof fetch,
72
+ requests,
73
+ };
74
+ }
75
+
76
+ function requestForm(request: RecordedRequest | undefined): URLSearchParams {
77
+ const body = request?.init?.body;
78
+ if (!(body instanceof URLSearchParams)) {
79
+ throw new Error("Expected an application/x-www-form-urlencoded request body");
80
+ }
81
+ return body;
82
+ }
83
+
14
84
  describe("isXAIAccessTokenExpiring", () => {
15
85
  it("returns false for an empty string", () => {
16
86
  expect(isXAIAccessTokenExpiring("")).toBe(false);
@@ -63,97 +133,138 @@ describe("refreshXAIOAuthToken", () => {
63
133
  });
64
134
  });
65
135
 
66
- describe("XAIOAuthFlow", () => {
67
- it("pins the redirect URI to xAI's allowlisted loopback port", () => {
68
- const flow = new XAIOAuthFlow({});
69
-
70
- expect(flow.redirectUri).toBe("http://127.0.0.1:56121/callback");
71
- });
72
-
73
- it("uses pasted-code login without starting a callback server", async () => {
74
- const serveSpy = vi.spyOn(Bun, "serve").mockImplementation(() => {
75
- throw new Error("callback server should not start");
76
- });
77
- let authUrl = "";
78
- let tokenRequestBody = "";
79
- const progress: string[] = [];
80
- const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
81
- const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
82
- if (url.includes("/.well-known/openid-configuration")) {
83
- return new Response(
84
- JSON.stringify({
85
- authorization_endpoint: "https://auth.x.ai/oauth/authorize",
86
- token_endpoint: "https://auth.x.ai/oauth/token",
87
- }),
88
- { status: 200, headers: { "Content-Type": "application/json" } },
89
- );
90
- }
91
- tokenRequestBody = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? "");
92
- return new Response(
93
- JSON.stringify({
136
+ describe("loginXAIOAuth", () => {
137
+ it("performs the RFC 8628 device flow and returns the issued credentials", async () => {
138
+ const now = 1_800_000_000_000;
139
+ vi.spyOn(Date, "now").mockReturnValue(now);
140
+ const { fetchMock, requests } = createDeviceFlowFetch([
141
+ {
142
+ body: {
94
143
  access_token: "access-token",
95
144
  refresh_token: "refresh-token",
96
145
  expires_in: 3600,
97
- }),
98
- { status: 200, headers: { "Content-Type": "application/json" } },
99
- );
146
+ },
147
+ },
148
+ ]);
149
+ const authEvents: Array<{ url: string; instructions?: string }> = [];
150
+ const progress: string[] = [];
151
+ const onAuth = vi.fn((info: { url: string; instructions?: string }) => {
152
+ authEvents.push(info);
153
+ });
154
+ const onProgress = vi.fn((message: string) => {
155
+ progress.push(message);
156
+ });
157
+ const onManualCodeInput = vi.fn(async () => {
158
+ throw new Error("device authorization must not request a pasted code");
100
159
  });
101
160
 
102
- const flow = new XAIOAuthFlow({
103
- fetch: fetchMock as unknown as typeof fetch,
104
- onAuth: info => {
105
- authUrl = info.url;
106
- },
107
- onManualCodeInput: async () => {
108
- const parsed = new URL(authUrl);
109
- const redirectUri = parsed.searchParams.get("redirect_uri") ?? "";
110
- const state = parsed.searchParams.get("state") ?? "";
111
- return `${redirectUri}?code=code-xyz&state=${encodeURIComponent(state)}`;
112
- },
113
- onProgress: message => progress.push(message),
161
+ const credentials = await loginXAIOAuth({
162
+ fetch: fetchMock,
163
+ onAuth,
164
+ onProgress,
165
+ onManualCodeInput,
114
166
  });
115
167
 
116
- const credentials = await flow.login();
117
- const authorizeUrl = new URL(authUrl);
118
- const tokenParams = new URLSearchParams(tokenRequestBody);
168
+ expect(requests.map(request => request.url)).toEqual([DISCOVERY_URL, DEVICE_CODE_URL, TOKEN_ENDPOINT]);
119
169
 
120
- expect(serveSpy).not.toHaveBeenCalled();
121
- expect(authorizeUrl.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback");
122
- expect(progress).toContain("Waiting for pasted authorization code...");
123
- expect(tokenParams.get("code")).toBe("code-xyz");
124
- expect(credentials.access).toBe("access-token");
125
- expect(credentials.refresh).toBe("refresh-token");
126
- });
127
- });
170
+ const discoveryRequest = requests[0];
171
+ expect(discoveryRequest?.init?.method).toBe("GET");
172
+ expect(new Headers(discoveryRequest?.init?.headers).get("Accept")).toBe("application/json");
128
173
 
129
- describe("XAIOAuthFlow.exchangeToken", () => {
130
- it("rejects when the token-exchange response is missing access_token", async () => {
131
- const fetchMock = vi.fn(async (input: string | URL) => {
132
- const url = typeof input === "string" ? input : input.toString();
133
- if (url.includes("/.well-known/openid-configuration")) {
134
- return new Response(
135
- JSON.stringify({
136
- authorization_endpoint: "https://auth.x.ai/oauth/authorize",
137
- token_endpoint: "https://auth.x.ai/oauth/token",
138
- }),
139
- { status: 200, headers: { "Content-Type": "application/json" } },
140
- );
141
- }
142
- // Token-exchange response deliberately omits `access_token` to exercise
143
- // the missing-token rejection path. The value of `refresh_token` here is
144
- // a literal test marker, not a real secret — the test verifies
145
- // exchangeToken throws before any token would be persisted.
146
- return new Response(JSON.stringify({ refresh_token: "stub-refresh-token-for-test-only" }), {
147
- status: 200,
148
- headers: { "Content-Type": "application/json" },
149
- });
174
+ const deviceRequest = requests[1];
175
+ expect(deviceRequest?.init?.method).toBe("POST");
176
+ const deviceHeaders = new Headers(deviceRequest?.init?.headers);
177
+ expect(deviceHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
178
+ expect(deviceHeaders.get("Accept")).toBe("application/json");
179
+ const deviceForm = requestForm(deviceRequest);
180
+ expect([...deviceForm.keys()].sort()).toEqual(["client_id", "scope"]);
181
+ expect(Object.fromEntries(deviceForm)).toEqual({
182
+ client_id: CLIENT_ID,
183
+ scope: SCOPE,
150
184
  });
151
185
 
152
- const flow = new XAIOAuthFlow({ fetch: fetchMock as unknown as typeof fetch });
153
- await flow.generateAuthUrl("state-abc", "http://127.0.0.1:56121/callback");
186
+ const tokenRequest = requests[2];
187
+ expect(tokenRequest?.init?.method).toBe("POST");
188
+ const tokenHeaders = new Headers(tokenRequest?.init?.headers);
189
+ expect(tokenHeaders.get("Content-Type")).toBe("application/x-www-form-urlencoded");
190
+ expect(tokenHeaders.get("Accept")).toBe("application/json");
191
+ const tokenForm = requestForm(tokenRequest);
192
+ expect([...tokenForm.keys()].sort()).toEqual(["client_id", "device_code", "grant_type"]);
193
+ expect(Object.fromEntries(tokenForm)).toEqual({
194
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
195
+ client_id: CLIENT_ID,
196
+ device_code: DEVICE_AUTHORIZATION.device_code,
197
+ });
198
+
199
+ expect(authEvents).toEqual([
200
+ {
201
+ url: DEVICE_AUTHORIZATION.verification_uri_complete,
202
+ instructions: `Enter code: ${DEVICE_AUTHORIZATION.user_code}`,
203
+ },
204
+ ]);
205
+ expect(authEvents[0]?.instructions).not.toMatch(/hermes/i);
206
+ expect(onManualCodeInput).not.toHaveBeenCalled();
207
+ expect(progress).toEqual(["Waiting for xAI device authorization..."]);
208
+ expect(credentials).toEqual({
209
+ access: "access-token",
210
+ refresh: "refresh-token",
211
+ expires: now + 3_300_000,
212
+ });
213
+ });
214
+
215
+ it("continues through authorization_pending and slow_down responses", async () => {
216
+ const sleepSpy = vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
217
+ const { fetchMock, requests } = createDeviceFlowFetch([
218
+ { status: 400, body: { error: "authorization_pending" } },
219
+ { status: 400, body: { error: "slow_down" } },
220
+ {
221
+ body: {
222
+ access_token: "eventual-access-token",
223
+ refresh_token: "eventual-refresh-token",
224
+ expires_in: 3600,
225
+ },
226
+ },
227
+ ]);
228
+
229
+ const credentials = await loginXAIOAuth({ fetch: fetchMock });
230
+
231
+ const tokenRequests = requests.filter(request => request.url === TOKEN_ENDPOINT);
232
+ expect(tokenRequests).toHaveLength(3);
233
+ expect(tokenRequests.map(request => Object.fromEntries(requestForm(request)))).toEqual([
234
+ {
235
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
236
+ client_id: CLIENT_ID,
237
+ device_code: DEVICE_AUTHORIZATION.device_code,
238
+ },
239
+ {
240
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
241
+ client_id: CLIENT_ID,
242
+ device_code: DEVICE_AUTHORIZATION.device_code,
243
+ },
244
+ {
245
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
246
+ client_id: CLIENT_ID,
247
+ device_code: DEVICE_AUTHORIZATION.device_code,
248
+ },
249
+ ]);
250
+ expect(sleepSpy.mock.calls).toEqual([[1000], [6000]]);
251
+ expect(credentials.access).toBe("eventual-access-token");
252
+ expect(credentials.refresh).toBe("eventual-refresh-token");
253
+ });
254
+
255
+ it("rejects a token response that omits access_token", async () => {
256
+ const { fetchMock, requests } = createDeviceFlowFetch([
257
+ {
258
+ body: {
259
+ refresh_token: "refresh-token",
260
+ expires_in: 3600,
261
+ },
262
+ },
263
+ ]);
154
264
 
155
- await expect(flow.exchangeToken("code-xyz", "state-abc", "http://127.0.0.1:56121/callback")).rejects.toThrow(
156
- /access_token/,
265
+ await expect(loginXAIOAuth({ fetch: fetchMock })).rejects.toThrow(
266
+ /xAI device-code token response missing access_token/,
157
267
  );
268
+ expect(requests.filter(request => request.url === TOKEN_ENDPOINT)).toHaveLength(1);
158
269
  });
159
270
  });