oc-codex-multi-auth 6.14.4 → 6.16.0

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.
Files changed (81) hide show
  1. package/README.md +49 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +159 -55
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/accounts/persistence.d.ts +53 -3
  6. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  7. package/dist/lib/accounts/persistence.js +110 -4
  8. package/dist/lib/accounts/persistence.js.map +1 -1
  9. package/dist/lib/accounts/state.d.ts +1 -0
  10. package/dist/lib/accounts/state.d.ts.map +1 -1
  11. package/dist/lib/accounts/state.js +11 -0
  12. package/dist/lib/accounts/state.js.map +1 -1
  13. package/dist/lib/accounts.d.ts +1 -1
  14. package/dist/lib/accounts.d.ts.map +1 -1
  15. package/dist/lib/accounts.js +1 -1
  16. package/dist/lib/accounts.js.map +1 -1
  17. package/dist/lib/auth/login-runner.d.ts +2 -0
  18. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  19. package/dist/lib/auth/login-runner.js +44 -12
  20. package/dist/lib/auth/login-runner.js.map +1 -1
  21. package/dist/lib/auth/loopback-flow.d.ts +124 -0
  22. package/dist/lib/auth/loopback-flow.d.ts.map +1 -0
  23. package/dist/lib/auth/loopback-flow.js +112 -0
  24. package/dist/lib/auth/loopback-flow.js.map +1 -0
  25. package/dist/lib/auth/plan-tier.d.ts +21 -0
  26. package/dist/lib/auth/plan-tier.d.ts.map +1 -0
  27. package/dist/lib/auth/plan-tier.js +92 -0
  28. package/dist/lib/auth/plan-tier.js.map +1 -0
  29. package/dist/lib/auth/token-utils.d.ts +8 -10
  30. package/dist/lib/auth/token-utils.d.ts.map +1 -1
  31. package/dist/lib/auth/token-utils.js +27 -18
  32. package/dist/lib/auth/token-utils.js.map +1 -1
  33. package/dist/lib/codex-usage.d.ts +13 -1
  34. package/dist/lib/codex-usage.d.ts.map +1 -1
  35. package/dist/lib/codex-usage.js +27 -8
  36. package/dist/lib/codex-usage.js.map +1 -1
  37. package/dist/lib/config.d.ts +8 -0
  38. package/dist/lib/config.d.ts.map +1 -1
  39. package/dist/lib/config.js +28 -0
  40. package/dist/lib/config.js.map +1 -1
  41. package/dist/lib/constants.d.ts +5 -2
  42. package/dist/lib/constants.d.ts.map +1 -1
  43. package/dist/lib/constants.js +5 -2
  44. package/dist/lib/constants.js.map +1 -1
  45. package/dist/lib/desktop-notifications.d.ts +14 -0
  46. package/dist/lib/desktop-notifications.d.ts.map +1 -0
  47. package/dist/lib/desktop-notifications.js +59 -0
  48. package/dist/lib/desktop-notifications.js.map +1 -0
  49. package/dist/lib/quota-notification-state.d.ts +25 -0
  50. package/dist/lib/quota-notification-state.d.ts.map +1 -0
  51. package/dist/lib/quota-notification-state.js +94 -0
  52. package/dist/lib/quota-notification-state.js.map +1 -0
  53. package/dist/lib/quota-notifications.d.ts +79 -0
  54. package/dist/lib/quota-notifications.d.ts.map +1 -0
  55. package/dist/lib/quota-notifications.js +401 -0
  56. package/dist/lib/quota-notifications.js.map +1 -0
  57. package/dist/lib/schemas.d.ts +9 -0
  58. package/dist/lib/schemas.d.ts.map +1 -1
  59. package/dist/lib/schemas.js +7 -0
  60. package/dist/lib/schemas.js.map +1 -1
  61. package/dist/lib/storage/flagged.d.ts.map +1 -1
  62. package/dist/lib/storage/flagged.js +5 -0
  63. package/dist/lib/storage/flagged.js.map +1 -1
  64. package/dist/lib/storage/migrations.d.ts +2 -0
  65. package/dist/lib/storage/migrations.d.ts.map +1 -1
  66. package/dist/lib/storage/migrations.js.map +1 -1
  67. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  68. package/dist/lib/tools/codex-limits.js +7 -4
  69. package/dist/lib/tools/codex-limits.js.map +1 -1
  70. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  71. package/dist/lib/tools/codex-list.js +13 -1
  72. package/dist/lib/tools/codex-list.js.map +1 -1
  73. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  74. package/dist/lib/tools/codex-status.js +8 -0
  75. package/dist/lib/tools/codex-status.js.map +1 -1
  76. package/dist/lib/tui-status.d.ts.map +1 -1
  77. package/dist/lib/tui-status.js +6 -2
  78. package/dist/lib/tui-status.js.map +1 -1
  79. package/dist/lib/types.d.ts +1 -0
  80. package/dist/lib/types.d.ts.map +1 -1
  81. package/package.json +1 -1
package/README.md CHANGED
@@ -263,6 +263,8 @@ Most of these also run as a **direct CLI** with no agent/model involvement (no t
263
263
  - unsupported-model handling is strict by default, with opt-in fallback controls
264
264
  - TUI quota status follows the account/workspace used by the latest request
265
265
  - Business workspace memberships and Personal accounts keep separate usage and quota windows. Business members sharing one workspace are distinguished by their member/seat identity, so their usage is not collapsed into one row.
266
+ - An account identifies itself by its own ChatGPT email and the last 6 characters of its account id, with the email masked when `maskEmail` is on. The OAuth id_token also lists the API-platform organizations the login belongs to; those are not ChatGPT workspaces and are never used to name an account, so logging in clears a label left behind by one. A label you set with `codex-label` is always kept.
267
+ - The ChatGPT plan (`Free`, `Plus`, `Pro`, `Business`, `Business Premium`) is read from the access token, refreshed on every token refresh, and shown by `codex-list` and `codex-status`. `codex-limits` and the TUI read the plan live from the usage endpoint and name it the same way. An unrecognized plan is reported verbatim rather than renamed.
266
268
 
267
269
  ---
268
270
 
@@ -293,6 +295,53 @@ Primary config files:
293
295
  - `~/.config/opencode/tui.json`
294
296
  - `~/.opencode/openai-codex-auth-config.json`
295
297
 
298
+ ### Desktop quota notifications
299
+
300
+ Quota notifications are an optional macOS-only feature. While the plugin is
301
+ running, it checks all enabled accounts and alerts through Notification Center
302
+ when the best remaining 5-hour or weekly pool quota crosses 25%, 10%, or 0%.
303
+ The feature is disabled by default.
304
+
305
+ Each line reports the enabled account with the most headroom in that window,
306
+ together with that same account's reset time, so the pair always describes a
307
+ quota that one account actually has. When a different account recovers sooner,
308
+ that reset is appended under its own label rather than folded into the first
309
+ one. Windows a plan has switched off are skipped rather than counted as full.
310
+ Account identities are omitted for readability and lock-screen privacy:
311
+
312
+ ```text
313
+ 5h: 10% | resets 02:00 | another account resets 22:30
314
+ Weekly: 72% | resets 22:30 on Aug 30
315
+ ```
316
+
317
+ ```json
318
+ {
319
+ "quotaNotifications": {
320
+ "enabled": true,
321
+ "intervalMs": 1800000,
322
+ "notifyEveryCheck": false,
323
+ "thresholds": [25, 10, 0]
324
+ }
325
+ }
326
+ ```
327
+
328
+ Add the object above to `~/.opencode/openai-codex-auth-config.json`, or set
329
+ `CODEX_AUTH_QUOTA_NOTIFICATIONS=1`, then quit and restart OpenCode. The minimum
330
+ interval is 30 seconds. If macOS blocks the alert, allow notifications for
331
+ the process shown in **System Settings > Notifications**. The setting is
332
+ ignored on Windows and Linux.
333
+
334
+ Set `"notifyEveryCheck": true` to show the aggregate quota notification after
335
+ every successful poll interval instead of only when a configured threshold is
336
+ crossed. Set `"thresholds": []` to turn threshold alerts off entirely; pair it
337
+ with `"notifyEveryCheck": true` or the monitor has nothing to deliver and stops
338
+ polling.
339
+
340
+ Delivery state lives beside the accounts file the alerts are computed from, so
341
+ OpenCode processes working in the same account scope show only one alert per
342
+ interval. With the default `perProjectAccounts`, that scope is one project:
343
+ two projects have separate account pools and therefore alert independently.
344
+
296
345
  ### Route models to preferred accounts
297
346
 
298
347
  Use `modelAccountPools` to assign one or more preferred ChatGPT accounts or Business seats to a
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AAsU/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAu8H/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AA2U/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAqjI/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -27,15 +27,16 @@ import { createHash } from "node:crypto";
27
27
  import { homedir } from "node:os";
28
28
  import { join } from "node:path";
29
29
  import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
30
+ import { formatPlanType } from "./lib/auth/plan-tier.js";
31
+ import { startLoopbackFlow, } from "./lib/auth/loopback-flow.js";
30
32
  import { buildDeviceCodeInstructions, completeDeviceCodeSession, createDeviceCodeSession, } from "./lib/auth/device-code.js";
31
33
  import { applyAccountSelectionFallbacks, persistResolvedAccountSelection, persistAccountPool, resolveAndPersistAccountSelection, resolveAccountSelection, } from "./lib/auth/login-runner.js";
32
34
  import { coordinateFlaggedPersistedRefresh, coordinatePersistedRefresh, } from "./lib/storage/coordinated-refresh.js";
33
- import { openBrowserUrl } from "./lib/auth/browser.js";
34
- import { startLocalOAuthServer } from "./lib/auth/server.js";
35
35
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
36
36
  import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getAutoUpdate, getToastDurationMs, getAccountToastsEnabled, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getRotationStrategy, getModelAccountPool, getModelAccountPoolMode, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, getCodexTuiMaskEmail, loadPluginConfig, } from "./lib/config.js";
37
37
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
38
38
  import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrelationId, clearCorrelationId, } from "./lib/logger.js";
39
+ import { createQuotaMonitor } from "./lib/quota-notifications.js";
39
40
  import { checkAndNotify } from "./lib/auto-update-checker.js";
40
41
  import { handleContextOverflow } from "./lib/context-overflow.js";
41
42
  import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLabel, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, resolveRequestAccountId, parseRateLimitReason, lookupCodexCliTokensByEmail, } from "./lib/accounts.js";
@@ -304,39 +305,76 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
304
305
  }
305
306
  }
306
307
  };
308
+ const listenerUnavailableMessage = () => `OAuth callback server failed to start on localhost loopback port 1455. ` +
309
+ `Retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`;
310
+ const callbackCancelledMessage = () => `OAuth callback timed out or was cancelled. ` +
311
+ `If you are on SSH, WSL, or a headless machine, retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`;
312
+ // The login is still live when this fires: the listener is bound and
313
+ // waiting, so opening the URL printed above finishes it. That is the
314
+ // whole recovery path on a host with no opener on PATH.
315
+ const browserOpenFailedMessage = () => `Could not launch your default browser. Open the OAuth URL above in any browser to finish signing in; ` +
316
+ `this login is still waiting on the localhost callback. ` +
317
+ `"${AUTH_LABELS.OAUTH_MANUAL_BROWSER}" does the same thing without trying to launch a browser, ` +
318
+ `and "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}" avoid the callback entirely.`;
319
+ const unavailableMessage = (lifecycle) => {
320
+ switch (lifecycle) {
321
+ case "listener_unavailable":
322
+ return listenerUnavailableMessage();
323
+ default: {
324
+ const unreachable = lifecycle;
325
+ return unreachable;
326
+ }
327
+ }
328
+ };
329
+ /**
330
+ * Every accepted paste must carry this attempt's `state`, a raw code
331
+ * included.
332
+ *
333
+ * That comparison is the manual flow's only in-plugin binding between
334
+ * the pasted value and this login. Accepting a bare code delegates the
335
+ * binding entirely to the authorization server's PKCE enforcement, and
336
+ * hands anything a user is talked into pasting to
337
+ * `exchangeAuthorizationCode` with this attempt's verifier — which is
338
+ * the shape of the attack the state check exists to stop. The raw
339
+ * branch of `parseAuthorizationInput` also returns the whole trimmed
340
+ * input as the code, so a noisy paste would be forwarded verbatim.
341
+ */
342
+ const manualInputRejection = (parsed, expectedState) => {
343
+ if (!parsed.code) {
344
+ return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...&state=...). If browser callback keeps failing, retry with Device Code.";
345
+ }
346
+ switch (parsed.source) {
347
+ case "raw":
348
+ return "That is a bare authorization code. This flow needs the full callback URL, including the state parameter (e.g., http://localhost:1455/auth/callback?code=...&state=...), which is what ties the code to this login attempt. If needed, retry with Device Code.";
349
+ case "url":
350
+ case "query":
351
+ case "fragment":
352
+ if (!parsed.state) {
353
+ return "That callback URL carries no OAuth state, so it cannot be matched to this login attempt. Paste the complete callback URL including its state parameter, or retry with Device Code.";
354
+ }
355
+ if (parsed.state !== expectedState) {
356
+ return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt, or retry with Device Code.";
357
+ }
358
+ return undefined;
359
+ default: {
360
+ const unreachable = parsed;
361
+ return unreachable;
362
+ }
363
+ }
364
+ };
307
365
  const buildManualOAuthFlow = (pkce, url, expectedState, replaceAll) => ({
308
366
  url,
309
367
  method: "code",
310
368
  instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
311
- validate: (input) => {
312
- const parsed = parseAuthorizationInput(input);
313
- if (!parsed.code) {
314
- return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...). If browser callback keeps failing, retry with Device Code.";
315
- }
316
- if (!parsed.state) {
317
- return parsed.source === "raw"
318
- ? "That is a bare authorization code. This flow needs the full callback URL, including the state parameter (e.g., http://localhost:1455/auth/callback?code=...&state=...). If needed, retry with Device Code."
319
- : "Missing OAuth state. Paste the full callback URL including both code and state parameters. If needed, retry with Device Code.";
320
- }
321
- if (parsed.state !== expectedState) {
322
- return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt, or retry with Device Code.";
323
- }
324
- return undefined;
325
- },
369
+ validate: (input) => manualInputRejection(parseAuthorizationInput(input), expectedState),
326
370
  callback: async (input) => {
327
371
  const parsed = parseAuthorizationInput(input);
328
- if (!parsed.code || !parsed.state) {
329
- return {
330
- type: "failed",
331
- reason: "invalid_response",
332
- message: "Missing authorization code or OAuth state",
333
- };
334
- }
335
- if (parsed.state !== expectedState) {
372
+ const rejection = manualInputRejection(parsed, expectedState);
373
+ if (rejection !== undefined || !parsed.code) {
336
374
  return {
337
375
  type: "failed",
338
376
  reason: "invalid_response",
339
- message: "OAuth state mismatch. Restart login and try again, or retry with Device Code.",
377
+ message: rejection ?? "Missing authorization code",
340
378
  };
341
379
  }
342
380
  const tokens = await exchangeAuthorizationCode(parsed.code, pkce.verifier, REDIRECT_URI);
@@ -353,21 +391,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
353
391
  },
354
392
  });
355
393
  const runOAuthFlow = async (forceNewLogin = false) => {
356
- const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin });
357
- logInfo(`OAuth URL: ${url}`);
358
- let serverInfo = null;
359
- try {
360
- serverInfo = await startLocalOAuthServer({ state });
361
- }
362
- catch (err) {
363
- logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${err?.message ?? String(err)}`);
364
- serverInfo = null;
365
- }
366
- openBrowserUrl(url);
367
- if (!serverInfo || !serverInfo.ready) {
368
- serverInfo?.close();
369
- const message = `OAuth callback server failed to start on localhost loopback port 1455. ` +
370
- `Retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`;
394
+ const session = await startLoopbackFlow({
395
+ openBrowser: true,
396
+ forceNewLogin,
397
+ });
398
+ if (session.type === "unavailable") {
399
+ const message = unavailableMessage(session.lifecycle);
371
400
  logWarn(`\n[${PLUGIN_NAME}] ${message}\n`);
372
401
  return {
373
402
  type: "failed",
@@ -375,17 +404,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
375
404
  message,
376
405
  };
377
406
  }
378
- const result = await serverInfo.waitForCode(state);
379
- serverInfo.close();
380
- if (!result) {
407
+ logInfo(`OAuth URL: ${session.url}`);
408
+ // Printed first, so the recovery instruction points at a URL the
409
+ // user can already see. The session stays open either way.
410
+ if (!session.browserOpened) {
411
+ logWarn(`\n[${PLUGIN_NAME}] ${browserOpenFailedMessage()}\n`);
412
+ }
413
+ const result = await session.waitAndExchange();
414
+ if (result.type === "cancelled") {
381
415
  return {
382
416
  type: "failed",
383
417
  reason: "unknown",
384
- message: `OAuth callback timed out or was cancelled. ` +
385
- `If you are on SSH, WSL, or a headless machine, retry with "${AUTH_LABELS.OAUTH_DEVICE_CODE}" or "${AUTH_LABELS.OAUTH_MANUAL}".`,
418
+ message: callbackCancelledMessage(),
386
419
  };
387
420
  }
388
- return await exchangeAuthorizationCode(result.code, pkce.verifier, REDIRECT_URI);
421
+ return result;
389
422
  };
390
423
  const showToast = async (message, variant = "success", options) => {
391
424
  try {
@@ -1128,10 +1161,22 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1128
1161
  });
1129
1162
  }
1130
1163
  };
1164
+ // Created here, after `invalidateAccountManagerCache`, because the
1165
+ // monitor refreshes access tokens unattended. A refresh rotates the
1166
+ // single-use refresh token durably on disk, so the cached
1167
+ // AccountManager holding the old one must be dropped or its debounced
1168
+ // save clobbers the rotation (see `reloadCachedAccountManager` above).
1169
+ const quotaMonitor = createQuotaMonitor({
1170
+ onCredentialsPersisted: invalidateAccountManagerCache,
1171
+ });
1131
1172
  // Event handler for session recovery and account selection
1132
1173
  const eventHandler = async (input) => {
1133
1174
  try {
1134
1175
  const { event } = input;
1176
+ if (event.type === "server.instance.disposed") {
1177
+ quotaMonitor.dispose();
1178
+ return;
1179
+ }
1135
1180
  // Handle TUI account selection events
1136
1181
  // Accepts generic selection events with an index property
1137
1182
  if (event.type === "account.select" ||
@@ -1238,6 +1283,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1238
1283
  const startupPerProjectAccounts = getPerProjectAccounts(startupPluginConfig);
1239
1284
  setStoragePath(startupPerProjectAccounts ? process.cwd() : null);
1240
1285
  await backfillHostOpenAIAuthFromPool();
1286
+ quotaMonitor.start();
1241
1287
  return {
1242
1288
  event: eventHandler,
1243
1289
  auth: {
@@ -2530,9 +2576,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2530
2576
  const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig);
2531
2577
  setStoragePath(authPerProjectAccounts ? process.cwd() : null);
2532
2578
  const accounts = [];
2579
+ // Programmatic input, not a menu choice: a headless caller or
2580
+ // script passes it to say "never launch a browser". Ignoring it
2581
+ // would silently do the opposite — enter the multi-account loop,
2582
+ // try to launch a browser, and bind port 1455 — and would also
2583
+ // drop the replaceAll semantics the manual path carries.
2533
2584
  const noBrowser = inputs?.noBrowser === "true" ||
2534
2585
  inputs?.["no-browser"] === "true";
2535
- const useManualMode = noBrowser;
2536
2586
  const explicitLoginMode = inputs?.loginMode === "fresh" || inputs?.loginMode === "add"
2537
2587
  ? inputs.loginMode
2538
2588
  : null;
@@ -2580,8 +2630,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2580
2630
  parts.push(`${limit.name} ${formatUsageLimitSummary(limit.window)}`);
2581
2631
  }
2582
2632
  }
2583
- if (usage.planType)
2584
- parts.push(`plan:${usage.planType}`);
2633
+ const planLabel = formatPlanType(usage.planType);
2634
+ if (planLabel)
2635
+ parts.push(`plan:${planLabel}`);
2585
2636
  if (usage.credits)
2586
2637
  parts.push(`credits:${usage.credits}`);
2587
2638
  return parts.length > 0 ? parts.join(", ") : "quota unavailable";
@@ -3166,10 +3217,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3166
3217
  if (refreshAccountIndex !== undefined) {
3167
3218
  targetCount = 1;
3168
3219
  }
3169
- if (useManualMode) {
3220
+ if (noBrowser) {
3170
3221
  targetCount = 1;
3171
- }
3172
- if (useManualMode) {
3222
+ // Paste-the-code, not the loopback listener: this is the one
3223
+ // path that needs neither a browser on this host nor a
3224
+ // reachable localhost:1455 from wherever the browser runs.
3173
3225
  const { pkce, state, url } = await createAuthorizationFlow();
3174
3226
  return buildManualOAuthFlow(pkce, url, state, startFresh);
3175
3227
  }
@@ -3260,6 +3312,57 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3260
3312
  };
3261
3313
  },
3262
3314
  },
3315
+ {
3316
+ label: AUTH_LABELS.OAUTH_MANUAL_BROWSER,
3317
+ type: "oauth",
3318
+ authorize: async () => {
3319
+ // Must happen BEFORE the callback persists, to ensure correct
3320
+ // storage location: OpenCode invokes callback() separately from
3321
+ // authorize(), so the path has to be pinned here.
3322
+ const manualBrowserPluginConfig = loadPluginConfig();
3323
+ applyUiRuntimeFromConfig(manualBrowserPluginConfig);
3324
+ const manualBrowserPerProjectAccounts = getPerProjectAccounts(manualBrowserPluginConfig);
3325
+ setStoragePath(manualBrowserPerProjectAccounts ? process.cwd() : null);
3326
+ const session = await startLoopbackFlow({ openBrowser: false });
3327
+ if (session.type === "unavailable") {
3328
+ const message = unavailableMessage(session.lifecycle);
3329
+ logWarn(`\n[${PLUGIN_NAME}] ${message}\n`);
3330
+ return {
3331
+ url: "",
3332
+ instructions: message,
3333
+ method: "auto",
3334
+ callback: () => Promise.resolve({
3335
+ type: "failed",
3336
+ reason: "invalid_response",
3337
+ message,
3338
+ }),
3339
+ };
3340
+ }
3341
+ return {
3342
+ url: session.url,
3343
+ instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL_BROWSER,
3344
+ method: "auto",
3345
+ callback: async () => {
3346
+ const result = await session.waitAndExchange();
3347
+ if (result.type === "cancelled") {
3348
+ return {
3349
+ type: "failed",
3350
+ reason: "unknown",
3351
+ message: callbackCancelledMessage(),
3352
+ };
3353
+ }
3354
+ if (result.type !== "success") {
3355
+ return result;
3356
+ }
3357
+ const resolved = await resolveAndPersistAccountSelection(result, {
3358
+ persistSelections: persistAuthenticatedSelections,
3359
+ replaceAll: false,
3360
+ });
3361
+ return resolved.primary;
3362
+ },
3363
+ };
3364
+ },
3365
+ },
3263
3366
  {
3264
3367
  label: AUTH_LABELS.OAUTH_DEVICE_CODE,
3265
3368
  type: "oauth",
@@ -3299,8 +3402,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3299
3402
  label: AUTH_LABELS.OAUTH_MANUAL,
3300
3403
  type: "oauth",
3301
3404
  authorize: async () => {
3302
- // Initialize storage path for manual OAuth flow
3303
- // Must happen BEFORE persistAccountPool to ensure correct storage location
3405
+ // Must happen BEFORE the callback persists, to ensure correct
3406
+ // storage location: OpenCode invokes callback() separately from
3407
+ // authorize(), so the path has to be pinned here.
3304
3408
  const manualPluginConfig = loadPluginConfig();
3305
3409
  applyUiRuntimeFromConfig(manualPluginConfig);
3306
3410
  const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig);