@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.
- package/CHANGELOG.md +33 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +10 -4
- package/dist/types/providers/openai-responses-wire.d.ts +7 -0
- package/dist/types/providers/openai-shared.d.ts +18 -5
- package/dist/types/registry/oauth/device-code.d.ts +25 -0
- package/dist/types/registry/oauth/index.d.ts +1 -25
- package/dist/types/registry/oauth/xai-oauth.d.ts +9 -25
- package/dist/types/registry/registry.d.ts +0 -1
- package/dist/types/registry/xai-oauth.d.ts +0 -1
- package/dist/types/types.d.ts +3 -3
- package/package.json +4 -4
- package/src/auth-gateway/server.ts +2 -2
- package/src/auth-storage.ts +104 -40
- package/src/providers/azure-openai-responses.ts +2 -2
- package/src/providers/openai-codex/request-transformer.ts +61 -9
- package/src/providers/openai-codex-responses.ts +7 -7
- package/src/providers/openai-completions.ts +9 -1
- package/src/providers/openai-responses-wire.ts +7 -0
- package/src/providers/openai-responses.ts +10 -3
- package/src/providers/openai-shared.ts +30 -13
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +191 -80
- package/src/registry/oauth/device-code.ts +92 -0
- package/src/registry/oauth/index.ts +1 -91
- package/src/registry/oauth/xai-oauth.ts +231 -191
- package/src/registry/xai-oauth.ts +0 -1
- package/src/types.ts +3 -3
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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 =
|
|
902
|
-
const transportSessionId =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
): {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
261
|
-
headers
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
392
|
+
return normalizeOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
|
382
393
|
}
|
|
383
394
|
|
|
384
395
|
export function getOpenAIResponsesRoutingSessionId(
|
|
385
|
-
options: Pick<
|
|
396
|
+
options: Pick<OpenAICacheOptions, "cacheRetention" | "sessionId"> | undefined,
|
|
386
397
|
): string | undefined {
|
|
387
398
|
if (resolveCacheRetention(options?.cacheRetention) === "none") return undefined;
|
|
388
|
-
return
|
|
399
|
+
return normalizeOpenAIPromptCacheKey(options?.sessionId);
|
|
389
400
|
}
|
|
390
401
|
|
|
391
402
|
export function getOpenRouterResponsesSessionId(
|
|
392
|
-
options: Pick<
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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("
|
|
67
|
-
it("
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
fetch: fetchMock
|
|
104
|
-
onAuth
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
expect(
|
|
122
|
-
expect(
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
153
|
-
|
|
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(
|
|
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
|
});
|