@bitkyc08/opencodex 2.41.0 → 2.42.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 (40) hide show
  1. package/gui/dist/assets/index-BU1tE0sr.js +112 -0
  2. package/gui/dist/assets/index-DL9-iS6J.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/cursor/protobuf-request.ts +41 -21
  6. package/src/adapters/identity.ts +8 -2
  7. package/src/adapters/openai-responses.ts +43 -3
  8. package/src/bridge.ts +25 -3
  9. package/src/cli/account-auth.ts +28 -3
  10. package/src/cli/account-extended.ts +7 -1
  11. package/src/cli/capabilities.ts +2 -2
  12. package/src/cli/observe.ts +3 -1
  13. package/src/codex/auth-api.ts +102 -9
  14. package/src/codex/catalog/effort.ts +15 -2
  15. package/src/codex/catalog/metadata.ts +114 -9
  16. package/src/codex/catalog/native-models.ts +71 -0
  17. package/src/codex/catalog/parsing.ts +3 -3
  18. package/src/codex/catalog/provider-fetch.ts +4 -3
  19. package/src/codex/catalog.ts +1 -1
  20. package/src/codex/data/upstream-models.json +169 -0
  21. package/src/codex/inject.ts +96 -6
  22. package/src/codex/injected-marker.ts +30 -4
  23. package/src/codex/journal.ts +14 -0
  24. package/src/generated/compatibility-version.json +40 -32
  25. package/src/oauth/account-quota-rank.ts +40 -1
  26. package/src/oauth/chatgpt-device.ts +187 -0
  27. package/src/oauth/chatgpt.ts +31 -4
  28. package/src/oauth/index.ts +13 -3
  29. package/src/oauth/log.ts +3 -0
  30. package/src/providers/muse-subscription-usage.ts +95 -0
  31. package/src/providers/quota.ts +96 -0
  32. package/src/providers/registry.ts +1 -1
  33. package/src/server/index.ts +15 -7
  34. package/src/server/live.ts +18 -4
  35. package/src/server/management/oauth-account-routes.ts +10 -3
  36. package/src/server/responses/core.ts +34 -0
  37. package/src/server/responses/empty-completion-guard.ts +4 -0
  38. package/src/types/request.ts +8 -0
  39. package/gui/dist/assets/index-B2YjLA-i.css +0 -1
  40. package/gui/dist/assets/index-aPup8CKb.js +0 -112
@@ -22,11 +22,17 @@ export const CODEX_GPT5_IDENTITY_LINE = "You are Codex, a coding agent based on
22
22
  export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on GPT-5.";
23
23
 
24
24
  /**
25
- * Known Codex GPT-5 identity sentences. Narrow: only "coding agent" / "an agent" + GPT-5(.x)?
25
+ * Known Codex identity sentences. Narrow: only "coding agent" / "an agent" + GPT-<major>(.minor)*.
26
26
  * Avoid a broad `You are Codex.*` rewrite that could touch unrelated content.
27
+ *
28
+ * The major version is a wildcard because Codex writes the CURRENT generation into this line and
29
+ * bumps it: `gpt-6-astra` (upstream #42607) ships "You are Codex, an agent based on GPT-6.".
30
+ * Pinning `GPT-5` meant a GPT-6-era prompt routed to a third-party provider kept telling that
31
+ * model it was Codex-on-GPT-6 — the exact misattribution this chokepoint exists to remove, silently
32
+ * reintroduced by a version bump.
27
33
  */
28
34
  const CODEX_GPT5_IDENTITY_RE =
29
- /You are Codex, (?:a coding agent|an agent) based on GPT-5(?:\.[0-9]+)*\./g;
35
+ /You are Codex, (?:a coding agent|an agent) based on GPT-[0-9]+(?:\.[0-9]+)*\./g;
30
36
 
31
37
  /** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */
32
38
  export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI.";
@@ -2075,11 +2075,26 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
2075
2075
  const usage = payload.usage;
2076
2076
  const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
2077
2077
  const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
2078
- if (inputTokens === 0 && outputTokens === 0) return undefined;
2078
+ // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep —
2079
+ // unknown keys (subscription metadata, future counters) ride along even when the token counts
2080
+ // themselves are zero or absent (metadata-only usage).
2081
+ const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
2082
+ const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
2083
+ || (isPlainObject(usage.input_tokens_details)
2084
+ && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"))
2085
+ || (isPlainObject(usage.output_tokens_details)
2086
+ && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
2087
+ if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
2088
+ const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
2089
+ const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
2079
2090
  return {
2080
2091
  inputTokens,
2081
2092
  outputTokens,
2082
2093
  ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
2094
+ ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
2095
+ ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
2096
+ ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
2097
+ ...(hasExtras ? { rawUsage: { ...usage } } : {}),
2083
2098
  };
2084
2099
  }
2085
2100
 
@@ -2391,7 +2406,26 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2391
2406
  reservation.commitRetained();
2392
2407
  budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
2393
2408
  }
2394
- usage = usageFromResponsesPayload(payload.response);
2409
+ {
2410
+ const nextUsage = usageFromResponsesPayload(payload.response);
2411
+ // The attached raw usage object can be event-sized (unknown keys carry arbitrary
2412
+ // values); it stays reachable until the terminal yields, so charge it like the
2413
+ // adjacent retained collectors or it would defeat the per-request memory cap.
2414
+ const previousRawBytes = usage?.rawUsage === undefined ? 0
2415
+ : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength;
2416
+ const nextRawBytes = nextUsage?.rawUsage === undefined ? 0
2417
+ : budgetEncoder.encode(JSON.stringify(nextUsage.rawUsage)).byteLength;
2418
+ if (nextRawBytes > 0) {
2419
+ const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" });
2420
+ usage = nextUsage;
2421
+ reservation.commitRetained();
2422
+ } else {
2423
+ usage = nextUsage;
2424
+ }
2425
+ if (previousRawBytes > 0) {
2426
+ budget.releaseRetained(previousRawBytes, { kind: "retained_collectors" });
2427
+ }
2428
+ }
2395
2429
  break;
2396
2430
  }
2397
2431
  }
@@ -2399,7 +2433,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2399
2433
  // completed snapshot so text is never double-counted.
2400
2434
  const text = snapshot || doneText || deltas;
2401
2435
  if (text) yield { type: "text_delta", text };
2402
- budget.releaseRetained(budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength, { kind: "retained_collectors" });
2436
+ budget.releaseRetained(
2437
+ budgetEncoder.encode(deltas).byteLength
2438
+ + budgetEncoder.encode(doneText).byteLength
2439
+ + budgetEncoder.encode(snapshot).byteLength
2440
+ + (usage?.rawUsage === undefined ? 0 : budgetEncoder.encode(JSON.stringify(usage.rawUsage)).byteLength),
2441
+ { kind: "retained_collectors" },
2442
+ );
2403
2443
  yield {
2404
2444
  type: "done",
2405
2445
  ...(usage ? { usage } : {}),
package/src/bridge.ts CHANGED
@@ -59,6 +59,10 @@ function sseEvent(name: string, data: Record<string, unknown>): string {
59
59
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
60
60
  }
61
61
 
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return value !== null && typeof value === "object" && !Array.isArray(value);
64
+ }
65
+
62
66
  function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
63
67
  // input_tokens_details / output_tokens_details are ALWAYS emitted (zero defaults):
64
68
  // strict Responses clients deserialize them as required fields — grok-build's pinned
@@ -80,7 +84,24 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
80
84
  const inputTokens = usage.contextTotalTokens !== undefined
81
85
  ? Math.max(0, usage.contextTotalTokens - usage.outputTokens)
82
86
  : usage.inputTokens;
87
+ // openai/codex#41980 parity: unknown upstream usage fields (subscription metadata, future
88
+ // counters) pass through the rebuild. Normalized values stay authoritative for the known
89
+ // keys (they are derived from the same raw values, so this never disagrees with upstream).
90
+ const raw: Record<string, unknown> = usage.rawUsage ?? {};
91
+ // cache_write_tokens is a KNOWN key: it is emitted only from the validated normalized
92
+ // value below, never copied through raw (an unknown-shaped value must not leak into the
93
+ // normalized contract).
94
+ const rawInputDetails = isRecord(raw.input_tokens_details)
95
+ ? Object.fromEntries(Object.entries(raw.input_tokens_details as Record<string, unknown>)
96
+ .filter(([key]) => key !== "cache_write_tokens"))
97
+ : {} as Record<string, unknown>;
98
+ const rawOutputDetails = isRecord(raw.output_tokens_details)
99
+ ? raw.output_tokens_details as Record<string, unknown>
100
+ : {} as Record<string, unknown>;
83
101
  const out: Record<string, unknown> = {
102
+ ...Object.fromEntries(Object.entries(raw).filter(([key]) =>
103
+ key !== "input_tokens" && key !== "output_tokens" && key !== "total_tokens"
104
+ && key !== "input_tokens_details" && key !== "output_tokens_details")),
84
105
  input_tokens: inputTokens,
85
106
  output_tokens: usage.outputTokens,
86
107
  total_tokens: usage.contextTotalTokens !== undefined
@@ -90,18 +111,19 @@ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
90
111
  // cached_tokens carries cache READS only, matching OpenAI semantics, and is always present
91
112
  // (zero default) for strict clients. Clamp to inputTokens so a provider's absolute
92
113
  // checkpoint can never report more cache reads than input.
93
- const inputDetails: Record<string, number> = {
114
+ const inputDetails: Record<string, unknown> = {
115
+ ...rawInputDetails,
94
116
  cached_tokens: Math.min(usage.cachedInputTokens ?? 0, inputTokens),
95
117
  };
96
118
  if (usage.cacheCreationInputTokens !== undefined) {
97
- const cacheRead = inputDetails.cached_tokens ?? 0;
119
+ const cacheRead = typeof inputDetails.cached_tokens === "number" ? inputDetails.cached_tokens : 0;
98
120
  inputDetails.cache_write_tokens = Math.min(
99
121
  usage.cacheCreationInputTokens,
100
122
  Math.max(0, inputTokens - cacheRead),
101
123
  );
102
124
  }
103
125
  out.input_tokens_details = inputDetails;
104
- out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
126
+ out.output_tokens_details = { ...rawOutputDetails, reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
105
127
  return out;
106
128
  }
107
129
 
@@ -31,11 +31,16 @@ function writeStdoutFully(text: string): void {
31
31
  }
32
32
 
33
33
  const USAGE = `Usage:
34
- ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
34
+ ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--code -] [--no-wait] [--json]
35
35
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
36
36
  ocx account cancel <provider> [--flow <flow-id>] [--json]
37
37
  ocx account reset-credits <account-id|main> [--consume --yes] [--json]
38
38
 
39
+ --device runs the OpenAI device-code login instead of the browser callback: use
40
+ it when the proxy has no browser or nothing can reach localhost:1455, such as a
41
+ headless or remote hub. Enter the printed code at the printed URL from any other
42
+ machine.
43
+
39
44
  The redirect URL or authorization code is a short-lived credential. Pipe it in
40
45
  rather than passing it as an argument, where it lands in shell history and is
41
46
  visible to anyone who can run ps:
@@ -54,6 +59,9 @@ interface LoginStart {
54
59
  /** `-` means "read it from stdin", the documented way to pass a code silently. */
55
60
  const STDIN_SENTINEL = "-";
56
61
 
62
+ /** Providers whose ONLY login is already a device flow; --device is redundant, not wrong. */
63
+ const DEVICE_NATIVE_PROVIDERS = new Set(["kimi", "nous", "github-copilot"]);
64
+
57
65
  const ARGV_WARNING =
58
66
  "warning: the authorization code was passed as a command-line argument, so it is now in your shell history and was visible in the process list while this ran. Pipe it on stdin instead, or pass `-` to read from stdin.";
59
67
 
@@ -86,10 +94,17 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
86
94
  const wantsJson = takeFlag(args, "--json");
87
95
  const noWait = takeFlag(args, "--no-wait");
88
96
  const reauth = takeFlag(args, "--reauth");
97
+ const device = takeFlag(args, "--device");
89
98
  const id = takeOption(args, "--id");
90
99
  const suppliedCode = takeOptionWithSyntax(args, "--code");
91
100
  if (!provider) throw new CliUsageError("provider is required", USAGE);
92
101
  rejectArgs(args, USAGE);
102
+ // kimi, nous, and github-copilot are already device flows, so --device is a
103
+ // true statement about them and is accepted as a no-op rather than an error.
104
+ // Anything else has no device grant at all and must fail loudly.
105
+ if (device && !CODEX_NAMES.has(provider) && !DEVICE_NATIVE_PROVIDERS.has(provider)) {
106
+ throw new CliUsageError(`--device is not supported for provider '${provider}'`, USAGE);
107
+ }
93
108
  // Only resolve when --code was actually given: a plain `ocx account login`
94
109
  // opens the browser flow and polls, and must not block on stdin.
95
110
  const code = await resolveCode(suppliedCode, deps, false);
@@ -97,13 +112,18 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
97
112
  if (CODEX_NAMES.has(provider)) {
98
113
  const start = await runtimeRequest<LoginStart>("/api/codex-auth/login", {
99
114
  method: "POST",
100
- body: JSON.stringify({ ...(id ? { id } : {}), ...(reauth ? { reauth: true } : {}) }),
115
+ body: JSON.stringify({
116
+ ...(id ? { id } : {}),
117
+ ...(reauth ? { reauth: true } : {}),
118
+ ...(device ? { device: true } : {}),
119
+ }),
101
120
  }, deps);
102
121
  if (!wantsJson) {
103
122
  // One atomic pre-poll block, flushed synchronously so a piped parent
104
123
  // reads the URL before the polling window starts (#1007).
105
124
  const block = [
106
125
  start.url ? `Open this URL to sign in:\n${start.url}` : "",
126
+ start.deviceCode ? `Device code: ${start.deviceCode}` : "",
107
127
  start.instructions ?? "",
108
128
  start.flowId ? `Flow: ${start.flowId}` : "",
109
129
  ].filter(line => line !== "").join("\n");
@@ -120,7 +140,12 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
120
140
  return;
121
141
  }
122
142
  if (!start.flowId) throw new CliUsageError("login did not return a flow id");
123
- for (let attempt = 0; attempt < 150; attempt++) {
143
+ // A device login is deliberately slow: the user leaves this machine to
144
+ // enter the code elsewhere. Match the 15-minute grant instead of giving up
145
+ // at minute five while it is still valid, plus settlement margin for the
146
+ // token exchange and credential write after the final poll.
147
+ const maxAttempts = device ? 480 : 150;
148
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
124
149
  await Bun.sleep(2_000);
125
150
  const state = await runtimeRequest<Record<string, unknown>>(
126
151
  `/api/codex-auth/login-status?flowId=${encodeURIComponent(start.flowId)}${id ? `&accountId=${encodeURIComponent(id)}` : ""}${reauth ? "&reauth=1" : ""}`,
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config";
2
+ import { hasPassiveAccountQuota } from "../providers/quota";
2
3
  import { closeSync, openSync, readSync } from "node:fs";
3
4
  import {
4
5
  MAX_ACCOUNT_PRIORITY,
@@ -330,7 +331,12 @@ export async function cmdRefresh(args: string[], deps: AccountDeps): Promise<num
330
331
  if (result.status === 0) return proxyUnreachable(result.transportError);
331
332
  if (result.status !== 200) return apiError(result.errorJson ?? {}, `failed to refresh ${name}`, result.status);
332
333
  if (wantsJson) console.log(JSON.stringify({ provider: name, report: result.report }, null, 2));
333
- else console.log(result.report ? providerQuotaLine(name, result.report) : `no quota report available for ${name}`);
334
+ else if (result.report) console.log(providerQuotaLine(name, result.report));
335
+ // A passive provider has no probe to run, so "no report available" reads as a
336
+ // failure of something that was never attempted. Say what is actually true.
337
+ else if (hasPassiveAccountQuota(name)) {
338
+ console.log(`${name} reports usage only during a streaming response; there is nothing to refresh. Run a request through this provider to update it, then see \`ocx account list ${name}\`.`);
339
+ } else console.log(`no quota report available for ${name}`);
334
340
  return 0;
335
341
  }
336
342
  const result = await fetchCodexRows(deps, baseUrl, true);
@@ -247,7 +247,7 @@ export const CAPABILITIES: readonly Capability[] = [
247
247
  "A bare invocation reads and never writes.",
248
248
  "The APPLIED value is echoed, not the requested one, so a server-side normalization stays visible.",
249
249
  "Values are not re-validated in the CLI: the server owns the strategy names and the 1-100 sticky bound.",
250
- "`anthropic` is the only OAuth pool with this setting; other OAuth providers are refused without a round-trip.",
250
+ "`anthropic` owns the full pool contract. Other OAuth providers reach the same endpoint with a generic subset (enabled/strategy/autoSwitchThreshold) whose settings persist but do not yet steer selection; `sticky` and `quotaWindow` are refused for them.",
251
251
  ],
252
252
  },
253
253
  {
@@ -274,7 +274,7 @@ export const CAPABILITIES: readonly Capability[] = [
274
274
  { name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
275
275
  { name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
276
276
  { name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
277
- { name: "--follow", value: "boolean", summary: "Stream new rows as JSONL; implies --jsonl." },
277
+ { name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
278
278
  { name: "--json", value: "boolean", summary: "Emit the server payload as JSON." },
279
279
  { name: "--jsonl", value: "boolean", summary: "Emit one row per line." },
280
280
  ],
@@ -72,7 +72,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
72
72
  const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
73
73
  rejectArgs(args, USAGE);
74
74
  if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
75
- if (follow && wantsJson) throw new CliUsageError("--follow uses --jsonl, not --json", USAGE);
75
+ if (follow && wantsJson) {
76
+ throw new CliUsageError("--follow cannot be combined with --json; use --jsonl for streaming JSONL", USAGE);
77
+ }
76
78
  let seen = new Set<string>();
77
79
  do {
78
80
  const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
@@ -274,6 +274,69 @@ function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAc
274
274
  } as T;
275
275
  }
276
276
 
277
+ /**
278
+ * Last reset-credit count this process parsed for the main account, tagged with the
279
+ * physical ChatGPT account it was read from.
280
+ *
281
+ * It is deliberately memory-only. The quota store is keyed by the stable `__main__`
282
+ * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is
283
+ * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state
284
+ * when it observes the id CHANGE, and its first observation after a restart has nothing
285
+ * to compare against. A disk-hydrated `__main__` entry can therefore belong to the
286
+ * previous login, so filling the DTO from it would show one account's tickets on
287
+ * another's card. Pool accounts have no such hole because their store key IS the account
288
+ * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the
289
+ * badge simply waits for the first usage response that carries the summary.
290
+ */
291
+ let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
292
+
293
+ function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void {
294
+ if (accountId === null || credits === undefined) return;
295
+ mainResetCreditsProvenance = { accountId, credits };
296
+ }
297
+
298
+ /** Forget the remembered count when the physical main identity is no longer the same. */
299
+ function mainResetCreditsForCurrentIdentity(): number | undefined {
300
+ if (!mainResetCreditsProvenance) return undefined;
301
+ const currentAccountId = getMainChatgptAccountId();
302
+ if (currentAccountId === null) return undefined;
303
+ if (currentAccountId !== mainResetCreditsProvenance.accountId) {
304
+ mainResetCreditsProvenance = null;
305
+ return undefined;
306
+ }
307
+ return mainResetCreditsProvenance.credits;
308
+ }
309
+
310
+ /**
311
+ * The main account is the only account whose DTO quota comes from the raw WHAM parse
312
+ * result instead of the merged store: `poolAccountDto` serializes what
313
+ * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
314
+ * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
315
+ * only intermittently, and the store exists to bridge that gap
316
+ * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
317
+ * snapshot omits it), so the main card lost its ticket badge on every response that
318
+ * happened to omit the summary while pool cards kept theirs.
319
+ *
320
+ * Only `resetCredits` is carried, deliberately, and only from an identity-tagged
321
+ * in-process observation rather than the alias-keyed store. The window fields have
322
+ * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
323
+ * so reinstating the whole stored object would resurrect a window the parse meant to
324
+ * clear whenever the store write was refused by generation gating. A freshly parsed value
325
+ * always wins, including `0`: zero is defined, so it never takes the fill branch.
326
+ */
327
+ function mainQuotaWithCarriedResetCredits(
328
+ parsed: Omit<StoredAccountQuota, "updatedAt">,
329
+ ): StoredAccountQuota {
330
+ const carried = parsed.resetCredits === undefined
331
+ ? mainResetCreditsForCurrentIdentity()
332
+ : undefined;
333
+ return {
334
+ ...parsed,
335
+ ...(carried !== undefined ? { resetCredits: carried } : {}),
336
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
337
+ };
338
+ }
339
+
277
340
  function poolAccountDto(
278
341
  account: CodexAccount,
279
342
  quotaResult: PoolQuotaResult,
@@ -836,6 +899,9 @@ async function fetchMainAccountInfoWhileOwned(
836
899
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
837
900
  const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
838
901
  const freshResetCredits = quota?.resetCredits;
902
+ // Tag the count with the identity it was read from, so a later response that omits the
903
+ // summary can restore the badge without ever crossing an account boundary.
904
+ rememberMainResetCredits(requestAccountId, freshResetCredits);
839
905
  const result = {
840
906
  email: data.email ?? null,
841
907
  plan,
@@ -1640,10 +1706,7 @@ export async function listCodexAuthAccountsSnapshot(
1640
1706
  hasCredential: hasMainCredential,
1641
1707
  needsReauth: mainNeedsReauth,
1642
1708
  quota: mainInfo.quota ? {
1643
- ...quotaForPlan({
1644
- ...mainInfo.quota,
1645
- updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
1646
- }, mainInfo.plan),
1709
+ ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
1647
1710
  } : null,
1648
1711
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
1649
1712
  };
@@ -2167,7 +2230,15 @@ export async function handleCodexAuthAPI(
2167
2230
  }
2168
2231
 
2169
2232
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
2170
- const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean; openBrowser?: unknown };
2233
+ const body = (await req.json().catch(() => ({}))) as {
2234
+ id?: string;
2235
+ reauth?: boolean;
2236
+ openBrowser?: unknown;
2237
+ device?: unknown;
2238
+ };
2239
+ // Device mode: no local browser, no loopback listener. The only way to add
2240
+ // an account to a headless hub (#3366).
2241
+ const useDeviceFlow = body.device === true;
2171
2242
  const requestedAccountId = body.id?.trim();
2172
2243
  const reauth = body.reauth === true;
2173
2244
  if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
@@ -2197,13 +2268,20 @@ export async function handleCodexAuthAPI(
2197
2268
  codexAuthLoginState.set(flowId, loginOwner);
2198
2269
  try {
2199
2270
  const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
2200
- const result = await startLoginFlow("chatgpt", { forceLogin: true });
2271
+ const result = await startLoginFlow("chatgpt", {
2272
+ forceLogin: true,
2273
+ ...(useDeviceFlow ? { flow: "device" as const } : {}),
2274
+ });
2201
2275
 
2202
2276
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
2203
2277
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
2204
2278
  // Both login routes share one resolver so this surface cannot drift from the other.
2205
2279
  const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
2206
- if (result.url && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
2280
+ // A device flow's URL is a verification page the user opens on ANOTHER
2281
+ // machine. Opening it on the hub host is useless at best, and on a
2282
+ // headless host it fails. `deviceCode` is the same signal the generic
2283
+ // OAuth login route uses to make this decision.
2284
+ if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
2207
2285
  const { openUrl } = await import("../lib/open-url");
2208
2286
  openUrl(result.url);
2209
2287
  }
@@ -2211,7 +2289,14 @@ export async function handleCodexAuthAPI(
2211
2289
  (async () => {
2212
2290
  try {
2213
2291
  let completed = false;
2214
- for (let i = 0; i < 150; i++) {
2292
+ // The device grant lives 15 minutes and the whole point is that the
2293
+ // user walks to another device to enter the code. A 5-minute server
2294
+ // budget would kill the flow at minute five while the grant is still
2295
+ // valid. The extra 30 attempts past 450 are settlement margin: a user
2296
+ // who authorizes in the final seconds still needs the token exchange
2297
+ // and credential write to land before this loop gives up.
2298
+ const pollAttempts = useDeviceFlow ? 480 : 150;
2299
+ for (let i = 0; i < pollAttempts; i++) {
2215
2300
  await new Promise(r => setTimeout(r, 2000));
2216
2301
  const st = getLoginStatus("chatgpt");
2217
2302
  if (st.done && st.loggedIn) {
@@ -2437,7 +2522,15 @@ export async function handleCodexAuthAPI(
2437
2522
  })();
2438
2523
 
2439
2524
  setCodexLoginState(flowId, { status: "pending" });
2440
- return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
2525
+ return jsonResponse({
2526
+ ok: true,
2527
+ flowId,
2528
+ url: result.url,
2529
+ instructions: result.instructions,
2530
+ // Dropped before #3366: every device-code surface renders this field,
2531
+ // so withholding it left the GUI and CLI with no code to show.
2532
+ ...(result.deviceCode ? { deviceCode: result.deviceCode } : {}),
2533
+ });
2441
2534
  } catch (e) {
2442
2535
  if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
2443
2536
  const msg = e instanceof Error ? e.message : String(e);
@@ -35,7 +35,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
35
35
  import { generatedModelMetadata, readCatalog, readCodexCatalogPath } from "./parsing";
36
36
  import type { CatalogModel, RawEntry } from "./parsing";
37
37
  import { UPSTREAM_NATIVE_ENTRIES } from "./metadata";
38
- import { nativeOpenAiCapabilitySourceSlug } from "./native-models";
38
+ import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS } from "./native-models";
39
39
  import { loadBundledCodexCatalog } from "./bundled";
40
40
  import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled";
41
41
  import { deriveEntry } from "./sync";
@@ -258,8 +258,21 @@ export function applyReasoningLevels(
258
258
  : efforts.find(effort => effort !== "none" && effort !== "minimal") ?? efforts[0];
259
259
  }
260
260
 
261
+ /**
262
+ * Native slugs entitled to the full GPT-5.6-era ladder (low..ultra, with max restored).
263
+ *
264
+ * The name is historical: membership is about the LADDER, not the model generation. `gpt-6-astra`
265
+ * qualifies because upstream ships it with the same six rungs
266
+ * (`supported_reasoning_levels` low/medium/high/xhigh/max/ultra, #42607). It used to qualify only
267
+ * as a side effect of borrowing Sol's capability source; once it became self-described that
268
+ * accident disappeared, and the sync path's else-branch
269
+ * (`applyReasoningLevels(entry, ["low","medium","high","xhigh"])`) would have truncated the
270
+ * shipped ladder, silently dropping `max` and `ultra`.
271
+ */
261
272
  export function isGpt56NativeSlug(slug: string): boolean {
262
- return !slug.includes("/") && nativeOpenAiCapabilitySourceSlug(slug).startsWith("gpt-5.6-");
273
+ if (slug.includes("/")) return false;
274
+ if (SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) return true;
275
+ return nativeOpenAiCapabilitySourceSlug(slug).startsWith("gpt-5.6-");
263
276
  }
264
277
 
265
278
  export function ensureGpt56ReasoningLevels(entry: RawEntry): void {