@oh-my-pi/pi-ai 15.2.2 → 15.2.4

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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.2.4] - 2026-05-22
6
+
7
+ ### Fixed
8
+
9
+ - Fixed ChatGPT Plus/Pro (Codex) OAuth login returning `Token exchange failed: 403` on Windows. When port 1455 was in use, the callback server silently fell back to a random port; OpenAI's authorization endpoint accepts any localhost redirect URI (loose validation), so the browser callback succeeds and shows "Authentication Successful", but the token endpoint rejects the non-registered port with 403. The `OpenAICodexOAuthFlow` now enforces a fixed `redirectUri` option so a busy port immediately surfaces as "port unavailable" instead of producing a confusing 403 ([#1277](https://github.com/can1357/oh-my-pi/issues/1277)).
10
+ - Improved `exchangeCodeForToken` error diagnostics: the 403 response body (`error` / `error_description` fields) is now included in the thrown message, matching the existing `refreshOpenAICodexToken` behaviour.
11
+
12
+ ### Added
13
+
14
+ - Added `ChatGPT Plus/Pro (Codex, headless/device)` (`openai-codex-device`) as an alternative login method for the Codex provider. Uses OpenAI's device-code flow (`/api/accounts/deviceauth/usercode` → poll `/api/accounts/deviceauth/token`), which avoids a local callback server and port 1455 entirely. Credentials are stored under the existing `openai-codex` provider key so all models and tooling continue to work without reconfiguration ([#1277](https://github.com/can1357/oh-my-pi/issues/1277)).
15
+
5
16
  ## [15.2.2] - 2026-05-22
6
17
 
7
18
  ### Fixed
@@ -18,7 +18,7 @@ export declare function isOpenAICompletionsProgressChunk(chunk: unknown): boolea
18
18
  export interface OpenAICompletionsOptions extends StreamOptions {
19
19
  toolChoice?: ToolChoice;
20
20
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
21
- /** Force-disable reasoning for OpenRouter-format requests (sends `reasoning: { enabled: false }`). */
21
+ /** Force-disable reasoning where supported, or request the lowest effort on generic effort endpoints. */
22
22
  disableReasoning?: boolean;
23
23
  serviceTier?: ServiceTier;
24
24
  }
@@ -242,9 +242,9 @@ export interface SimpleStreamOptions extends StreamOptions {
242
242
  * Force-disable reasoning for the request even when the model supports it.
243
243
  * Takes precedence over `reasoning`. Useful for fast utility calls
244
244
  * (e.g. title generation) where the model would otherwise burn the entire
245
- * output budget on internal thinking. Currently honored by OpenRouter
246
- * (sends `reasoning: { enabled: false }`); other providers already behave
247
- * this way when `reasoning` is undefined.
245
+ * output budget on internal thinking. Provider support is format-specific:
246
+ * some transports can disable reasoning directly, while generic
247
+ * effort-based OpenAI-compatible endpoints use the lowest supported effort.
248
248
  */
249
249
  disableReasoning?: boolean;
250
250
  /**
@@ -8,6 +8,13 @@ export type OpenAICodexLoginOptions = OAuthController & {
8
8
  originator?: string;
9
9
  };
10
10
  export declare function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials>;
11
+ /**
12
+ * Login with OpenAI Codex using the device-code (headless) flow.
13
+ *
14
+ * Avoids a local callback server entirely — useful when port 1455 is unavailable
15
+ * or when the browser callback flow fails with 403 (e.g. network/proxy issues).
16
+ */
17
+ export declare function loginOpenAICodexDevice(ctrl: OAuthController): Promise<OAuthCredentials>;
11
18
  /**
12
19
  * Refresh OpenAI Codex OAuth token
13
20
  */
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xiaomi" | "zenmux" | "zai";
10
+ export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xiaomi" | "zenmux" | "zai";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.2.2",
4
+ "version": "15.2.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@oh-my-pi/pi-utils": "15.2.2",
46
+ "@oh-my-pi/pi-utils": "15.2.4",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -29,6 +29,7 @@ import { kimiUsageProvider } from "./usage/kimi";
29
29
  import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
30
30
  import { zaiUsageProvider } from "./usage/zai";
31
31
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth";
32
+ import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
32
33
  import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
33
34
 
34
35
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1298,6 +1299,14 @@ export class AuthStorage {
1298
1299
  });
1299
1300
  break;
1300
1301
  }
1302
+ case "openai-codex-device": {
1303
+ // Device/headless flow — stores credentials under "openai-codex" so the
1304
+ // provider can pick them up without a separate provider configuration.
1305
+ const deviceCredentials = await loginOpenAICodexDevice(ctrl);
1306
+ const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials };
1307
+ await this.#upsertOAuthCredential("openai-codex", newCredential);
1308
+ return;
1309
+ }
1301
1310
  case "gitlab-duo": {
1302
1311
  const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo");
1303
1312
  credentials = await loginGitLabDuo({
@@ -171,7 +171,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
171
171
  high: "high",
172
172
  xhigh: "max",
173
173
  } satisfies Partial<Record<OpenAIReasoningEffort, string>>)
174
- : {};
174
+ : isFireworks
175
+ ? ({
176
+ // Fireworks' OpenAI-compatible endpoint rejects OpenAI's
177
+ // `minimal` literal but accepts `none` for the lowest setting.
178
+ minimal: "none",
179
+ } satisfies Partial<Record<OpenAIReasoningEffort, string>>)
180
+ : {};
175
181
 
176
182
  return {
177
183
  supportsStore: !isNonStandard,
@@ -10,7 +10,7 @@ import type {
10
10
  ChatCompletionToolMessageParam,
11
11
  } from "openai/resources/chat/completions";
12
12
  import packageJson from "../../package.json" with { type: "json" };
13
- import type { Effort } from "../model-thinking";
13
+ import { type Effort, getSupportedEfforts } from "../model-thinking";
14
14
  import { calculateCost } from "../models";
15
15
  import { getEnvApiKey } from "../stream";
16
16
  import {
@@ -219,7 +219,7 @@ export function isOpenAICompletionsProgressChunk(chunk: unknown): boolean {
219
219
  export interface OpenAICompletionsOptions extends StreamOptions {
220
220
  toolChoice?: ToolChoice;
221
221
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
222
- /** Force-disable reasoning for OpenRouter-format requests (sends `reasoning: { enabled: false }`). */
222
+ /** Force-disable reasoning where supported, or request the lowest effort on generic effort endpoints. */
223
223
  disableReasoning?: boolean;
224
224
  serviceTier?: ServiceTier;
225
225
  }
@@ -1177,6 +1177,21 @@ function buildParams(
1177
1177
  ) {
1178
1178
  // OpenAI-style reasoning_effort
1179
1179
  params.reasoning_effort = mapReasoningEffort(options.reasoning, compat.reasoningEffortMap) as Effort;
1180
+ } else if (
1181
+ supportsReasoningParams &&
1182
+ options?.disableReasoning &&
1183
+ !options?.reasoning &&
1184
+ model.reasoning &&
1185
+ compat.supportsReasoningEffort
1186
+ ) {
1187
+ // Generic OpenAI-compatible effort endpoints do not expose a true off
1188
+ // switch. Use the model's lowest supported effort as the closest
1189
+ // transport-level approximation when callers request disabled reasoning.
1190
+ const minEffort = getSupportedEfforts(model)[0];
1191
+ if (minEffort === undefined) {
1192
+ throw new Error(`Model ${model.provider}/${model.id} has no supported reasoning efforts`);
1193
+ }
1194
+ params.reasoning_effort = mapReasoningEffort(minEffort, compat.reasoningEffortMap) as Effort;
1180
1195
  }
1181
1196
 
1182
1197
  if (compat.disableReasoningOnToolChoice && params.tool_choice !== undefined) {
package/src/types.ts CHANGED
@@ -375,9 +375,9 @@ export interface SimpleStreamOptions extends StreamOptions {
375
375
  * Force-disable reasoning for the request even when the model supports it.
376
376
  * Takes precedence over `reasoning`. Useful for fast utility calls
377
377
  * (e.g. title generation) where the model would otherwise burn the entire
378
- * output budget on internal thinking. Currently honored by OpenRouter
379
- * (sends `reasoning: { enabled: false }`); other providers already behave
380
- * this way when `reasoning` is undefined.
378
+ * output budget on internal thinking. Provider support is format-specific:
379
+ * some transports can disable reasoning directly, while generic
380
+ * effort-based OpenAI-compatible endpoints use the lowest supported effort.
381
381
  */
382
382
  disableReasoning?: boolean;
383
383
  /**
@@ -25,6 +25,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
25
25
  name: "ChatGPT Plus/Pro (Codex Subscription)",
26
26
  available: true,
27
27
  },
28
+ {
29
+ id: "openai-codex-device",
30
+ name: "ChatGPT Plus/Pro (Codex, headless/device)",
31
+ available: true,
32
+ },
28
33
  {
29
34
  id: "gitlab-duo",
30
35
  name: "GitLab Duo",
@@ -279,7 +284,8 @@ export async function refreshOAuthToken(
279
284
  newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
280
285
  break;
281
286
  }
282
- case "openai-codex": {
287
+ case "openai-codex":
288
+ case "openai-codex-device": {
283
289
  const { refreshOpenAICodexToken } = await import("./openai-codex");
284
290
  newCredentials = await refreshOpenAICodexToken(credentials.refresh);
285
291
  break;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * OpenAI Codex (ChatGPT OAuth) flow
2
+ * OpenAI Codex (ChatGPT OAuth) flow — browser and device-code flows.
3
3
  */
4
- import { OAuthCallbackFlow } from "./callback-server";
4
+ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
5
5
  import { generatePKCE } from "./pkce";
6
6
  import type { OAuthController, OAuthCredentials } from "./types";
7
7
 
@@ -14,6 +14,14 @@ const SCOPE = "openid profile email offline_access";
14
14
  const JWT_CLAIM_PATH = "https://api.openai.com/auth";
15
15
  const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
16
16
  const TOKEN_REQUEST_TIMEOUT_MS = 15_000;
17
+ const DEVICE_USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
18
+ const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
19
+ const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
20
+ const DEVICE_AUTH_URL = "https://auth.openai.com/codex/device";
21
+ const DEVICE_POLL_INTERVAL_MS = 5_000;
22
+ const DEVICE_POLL_SAFETY_MARGIN_MS = 3_000;
23
+ /** Upper bound on device-code polling to avoid infinite loops on server errors. */
24
+ const DEVICE_MAX_POLLS = 120;
17
25
 
18
26
  type JwtPayload = {
19
27
  [JWT_CLAIM_PATH]?: {
@@ -59,7 +67,15 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
59
67
  private readonly pkce: PKCE,
60
68
  private readonly originator: string,
61
69
  ) {
62
- super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
70
+ super(ctrl, {
71
+ preferredPort: CALLBACK_PORT,
72
+ callbackPath: CALLBACK_PATH,
73
+ // Enforce the fixed port: OpenAI only allows http://localhost:1455/auth/callback.
74
+ // Without this, a busy port 1455 falls back to a random port, and the token
75
+ // exchange would fail with 403 because the redirect_uri no longer matches the
76
+ // registered allowlist entry.
77
+ redirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`,
78
+ } satisfies OAuthCallbackFlowOptions);
63
79
  }
64
80
 
65
81
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
@@ -100,7 +116,13 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
100
116
  });
101
117
 
102
118
  if (!tokenResponse.ok) {
103
- throw new Error(`Token exchange failed: ${tokenResponse.status}`);
119
+ let detail = `${tokenResponse.status}`;
120
+ try {
121
+ const body = (await tokenResponse.json()) as { error?: string; error_description?: string };
122
+ if (body.error)
123
+ detail = `${tokenResponse.status} ${body.error}${body.error_description ? `: ${body.error_description}` : ""}`;
124
+ } catch {}
125
+ throw new Error(`Token exchange failed: ${detail}`);
104
126
  }
105
127
 
106
128
  const tokenData = (await tokenResponse.json()) as {
@@ -143,6 +165,93 @@ export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promis
143
165
  return flow.login();
144
166
  }
145
167
 
168
+ /**
169
+ * Login with OpenAI Codex using the device-code (headless) flow.
170
+ *
171
+ * Avoids a local callback server entirely — useful when port 1455 is unavailable
172
+ * or when the browser callback flow fails with 403 (e.g. network/proxy issues).
173
+ */
174
+ export async function loginOpenAICodexDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
175
+ ctrl.onProgress?.("Initiating device authorization…");
176
+
177
+ const initResponse = await fetch(DEVICE_USERCODE_URL, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify({ client_id: CLIENT_ID }),
181
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
182
+ });
183
+
184
+ if (!initResponse.ok) {
185
+ throw new Error(`Device authorization initiation failed: ${initResponse.status}`);
186
+ }
187
+
188
+ const initData = (await initResponse.json()) as {
189
+ device_auth_id?: string;
190
+ user_code?: string;
191
+ interval?: string | number;
192
+ };
193
+
194
+ if (!initData.device_auth_id || !initData.user_code) {
195
+ throw new Error("Device authorization response missing required fields");
196
+ }
197
+
198
+ const userCode = initData.user_code;
199
+ const pollIntervalMs =
200
+ (typeof initData.interval === "number"
201
+ ? initData.interval
202
+ : parseInt(String(initData.interval ?? "5"), 10) || 5) *
203
+ 1000 +
204
+ DEVICE_POLL_SAFETY_MARGIN_MS;
205
+
206
+ ctrl.onAuth?.({
207
+ url: DEVICE_AUTH_URL,
208
+ instructions: `Enter code: ${userCode}`,
209
+ });
210
+
211
+ ctrl.onProgress?.(`Waiting for browser authorization (code: ${userCode})…`);
212
+
213
+ for (let poll = 0; poll < DEVICE_MAX_POLLS; poll++) {
214
+ await Bun.sleep(poll === 0 ? Math.min(pollIntervalMs, DEVICE_POLL_INTERVAL_MS) : pollIntervalMs);
215
+
216
+ if (ctrl.signal?.aborted) {
217
+ throw new Error("Device authorization cancelled");
218
+ }
219
+
220
+ const pollResponse = await fetch(DEVICE_TOKEN_URL, {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify({
224
+ device_auth_id: initData.device_auth_id,
225
+ user_code: userCode,
226
+ }),
227
+ signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
228
+ });
229
+
230
+ // 403/404 = authorization pending, keep polling
231
+ if (pollResponse.status === 403 || pollResponse.status === 404) {
232
+ continue;
233
+ }
234
+
235
+ if (!pollResponse.ok) {
236
+ throw new Error(`Device token polling failed: ${pollResponse.status}`);
237
+ }
238
+
239
+ const pollData = (await pollResponse.json()) as {
240
+ authorization_code?: string;
241
+ code_verifier?: string;
242
+ };
243
+
244
+ if (!pollData.authorization_code || !pollData.code_verifier) {
245
+ throw new Error("Device token response missing authorization_code or code_verifier");
246
+ }
247
+
248
+ ctrl.onProgress?.("Exchanging authorization code for tokens…");
249
+ return exchangeCodeForToken(pollData.authorization_code, pollData.code_verifier, DEVICE_REDIRECT_URI);
250
+ }
251
+
252
+ throw new Error("Device authorization timed out — user did not complete login in time");
253
+ }
254
+
146
255
  /**
147
256
  * Refresh OpenAI Codex OAuth token
148
257
  */
@@ -34,6 +34,7 @@ export type OAuthProvider =
34
34
  | "ollama"
35
35
  | "ollama-cloud"
36
36
  | "openai-codex"
37
+ | "openai-codex-device"
37
38
  | "opencode-go"
38
39
  | "opencode-zen"
39
40
  | "parallel"