@bitkyc08/opencodex 2.40.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 (97) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-BU1tE0sr.js +112 -0
  3. package/gui/dist/assets/index-DL9-iS6J.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/meta.svg +1 -0
  6. package/package.json +4 -3
  7. package/src/adapters/cursor/catalog.ts +71 -29
  8. package/src/adapters/cursor/claude-id.ts +76 -0
  9. package/src/adapters/cursor/discovery.ts +16 -3
  10. package/src/adapters/cursor/effort-map.ts +27 -12
  11. package/src/adapters/cursor/protobuf-request.ts +41 -21
  12. package/src/adapters/google.ts +39 -2
  13. package/src/adapters/identity.ts +8 -2
  14. package/src/adapters/openai-responses.ts +57 -4
  15. package/src/bridge.ts +25 -3
  16. package/src/cli/account-auth.ts +28 -3
  17. package/src/cli/account-extended.ts +7 -1
  18. package/src/cli/capabilities.ts +2 -2
  19. package/src/cli/claude.ts +11 -2
  20. package/src/cli/connect.ts +7 -1
  21. package/src/cli/observe.ts +3 -1
  22. package/src/cli/registry.ts +1 -1
  23. package/src/cli/status.ts +19 -4
  24. package/src/client/connect.ts +5 -1
  25. package/src/client/hub-client.ts +29 -5
  26. package/src/clients/config-export.ts +12 -2
  27. package/src/codex/auth-api.ts +102 -9
  28. package/src/codex/catalog/aggregation.ts +8 -0
  29. package/src/codex/catalog/effort.ts +15 -2
  30. package/src/codex/catalog/metadata.ts +119 -9
  31. package/src/codex/catalog/native-models.ts +71 -0
  32. package/src/codex/catalog/parsing.ts +5 -3
  33. package/src/codex/catalog/provider-fetch.ts +166 -28
  34. package/src/codex/catalog.ts +1 -1
  35. package/src/codex/convergence-types.ts +1 -0
  36. package/src/codex/data/upstream-models.json +169 -0
  37. package/src/codex/desired-state.ts +18 -11
  38. package/src/codex/inject.ts +96 -6
  39. package/src/codex/injected-marker.ts +30 -4
  40. package/src/codex/journal.ts +14 -0
  41. package/src/combos/failover.ts +185 -6
  42. package/src/combos/index.ts +6 -0
  43. package/src/combos/resolve.ts +43 -6
  44. package/src/config.ts +5 -1
  45. package/src/generated/compatibility-version.json +115 -83
  46. package/src/generated/model-metadata.ts +1 -1
  47. package/src/grok/sync.ts +10 -2
  48. package/src/integrations/cursor-effort-table.ts +143 -0
  49. package/src/integrations/state.ts +1 -1
  50. package/src/integrations/writer.ts +2 -2
  51. package/src/lib/app-owned-memory-stores.ts +27 -8
  52. package/src/lib/bounded-body.ts +16 -1
  53. package/src/oauth/account-quota-rank.ts +40 -1
  54. package/src/oauth/chatgpt-device.ts +187 -0
  55. package/src/oauth/chatgpt.ts +31 -4
  56. package/src/oauth/generic-account-failover.ts +2 -2
  57. package/src/oauth/index.ts +24 -3
  58. package/src/oauth/log.ts +3 -0
  59. package/src/oauth/meta-muse.ts +235 -0
  60. package/src/providers/antigravity-models.ts +71 -13
  61. package/src/providers/command-code-efforts.ts +15 -0
  62. package/src/providers/free-directory.ts +4 -1
  63. package/src/providers/muse-subscription-usage.ts +95 -0
  64. package/src/providers/quota.ts +96 -0
  65. package/src/providers/registry.ts +116 -8
  66. package/src/responses/code-mode-helper-compat.ts +4 -1
  67. package/src/responses/state.ts +5 -4
  68. package/src/server/auth-cors.ts +241 -56
  69. package/src/server/chat-completions.ts +11 -2
  70. package/src/server/chat-native.ts +30 -4
  71. package/src/server/claude-messages.ts +17 -3
  72. package/src/server/effort-row.ts +131 -0
  73. package/src/server/index.ts +82 -45
  74. package/src/server/live.ts +18 -4
  75. package/src/server/management/api-key-rotation.ts +2 -1
  76. package/src/server/management/api-key-usage.ts +97 -43
  77. package/src/server/management/context.ts +3 -0
  78. package/src/server/management/cursor-integration-routes.ts +36 -7
  79. package/src/server/management/logs-usage-routes.ts +64 -87
  80. package/src/server/management/oauth-account-routes.ts +10 -3
  81. package/src/server/management/provider-routes.ts +218 -1
  82. package/src/server/management/route-registry.ts +1 -0
  83. package/src/server/management/usage-aggregate-cache.ts +464 -0
  84. package/src/server/management/usage-summary-cache.ts +4 -0
  85. package/src/server/models-capabilities.ts +60 -5
  86. package/src/server/responses/core.ts +95 -7
  87. package/src/server/responses/empty-completion-guard.ts +4 -0
  88. package/src/types/config.ts +10 -1
  89. package/src/types/request.ts +8 -0
  90. package/src/types/tools.ts +12 -9
  91. package/src/usage/expected-prices.ts +43 -7
  92. package/src/usage/ledger-scanner.ts +448 -0
  93. package/src/usage/log.ts +1 -1
  94. package/src/usage/summary.ts +915 -655
  95. package/src/web-search/index.ts +1 -1
  96. package/gui/dist/assets/index-BHe2rl_C.js +0 -112
  97. package/gui/dist/assets/index-CJSb3HPe.css +0 -1
@@ -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);
@@ -162,6 +162,12 @@ export function deriveComboCatalogModel(
162
162
  contextWindow,
163
163
  ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
164
164
  );
165
+ const knownMaxOutputTokens = members
166
+ .map(member => member.maxOutputTokens)
167
+ .filter((value): value is number => typeof value === "number" && value > 0);
168
+ const maxOutputTokens = knownMaxOutputTokens.length === members.length
169
+ ? Math.min(...knownMaxOutputTokens)
170
+ : undefined;
165
171
  const autoCompactTokenLimit = Math.min(
166
172
  ...members.map(member => clampAutoCompactTokenLimit(
167
173
  member.contextWindow!,
@@ -180,6 +186,7 @@ export function deriveComboCatalogModel(
180
186
  owned_by: COMBO_NAMESPACE,
181
187
  contextWindow,
182
188
  maxInputTokens,
189
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
183
190
  autoCompactTokenLimit,
184
191
  ...(hasLimitingContextCapMetadata ? { contextCapped } : {}),
185
192
  inputModalities,
@@ -320,6 +327,7 @@ export function normalizedOpenAiApiSignature(model: CatalogModel): string {
320
327
  id: model.id,
321
328
  contextWindow: model.contextWindow ?? null,
322
329
  maxInputTokens: model.maxInputTokens ?? null,
330
+ maxOutputTokens: model.maxOutputTokens ?? null,
323
331
  autoCompactTokenLimit: model.autoCompactTokenLimit ?? null,
324
332
  inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
325
333
  reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
@@ -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 {
@@ -41,10 +41,14 @@ import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
41
41
  import {
42
42
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
43
43
  NATIVE_DAYBREAK_BLUE_MODEL,
44
+ NATIVE_GPT6_ASTRA_MODEL,
44
45
  NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS,
45
46
  NATIVE_OPENAI_MODELS,
47
+ SELF_DESCRIBED_NATIVE_OPENAI_MODELS,
46
48
  SUPPORTED_NATIVE_OPENAI_SLUGS,
49
+ hasNativeOpenAiCapabilityMetadata,
47
50
  isNativeOpenAiCapabilityAliasModel,
51
+ nativeOpenAiAliasPresentation,
48
52
  nativeOpenAiCapabilitySourceSlug,
49
53
  } from "./native-models";
50
54
  import { cachedAvailableAccountGatedNativeModels } from "../model-entitlements";
@@ -52,16 +56,25 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
52
56
  export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
53
57
  export {
54
58
  NATIVE_DAYBREAK_BLUE_MODEL,
59
+ NATIVE_GPT6_ASTRA_MODEL,
55
60
  NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS,
56
61
  NATIVE_OPENAI_MODELS,
62
+ SELF_DESCRIBED_NATIVE_OPENAI_MODELS,
57
63
  SUPPORTED_NATIVE_OPENAI_SLUGS,
64
+ hasNativeOpenAiCapabilityMetadata,
58
65
  isNativeOpenAiCapabilityAliasModel,
66
+ nativeOpenAiAliasPresentation,
59
67
  nativeOpenAiCapabilitySourceSlug,
60
68
  } from "./native-models";
61
69
 
62
70
  export const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [
63
71
  "gpt-5.3-codex-spark",
64
72
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
73
+ // Preemptive leak-based registration: no shipped codex-rs catalog carries it, so without this
74
+ // entry an install WITH a live catalog would drop the row that native-models.ts deliberately
75
+ // ungated. Listing it here keeps the bare slug reachable so a request actually dispatches and
76
+ // reports the upstream status.
77
+ NATIVE_GPT6_ASTRA_MODEL,
65
78
  ];
66
79
 
67
80
  export function configuredNativeAliasSlugs(
@@ -161,6 +174,12 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: n
161
174
  // ChatGPT account."`), so the promotion rests on a report from an account that has
162
175
  // access rather than on a probe. Treat it as the weaker evidence of the four.
163
176
  [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
177
+ // gpt-6-astra ships its own numbers (upstream models.json, #42607): a 272,000 default window
178
+ // against an 872,000 ceiling. It is NOT in NATIVE_GPT56_FAMILY, so it must not inherit that
179
+ // family's measured 922,000 clamp — advertising 922,000 here over-stated the ceiling by 50k.
180
+ // maxInputTokens is clamped to the resolved window by nativeOpenAiMaxInputTokens, so this reads
181
+ // 272,000 by default and 872,000 only under the long-window opt-in.
182
+ [NATIVE_GPT6_ASTRA_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
164
183
  };
165
184
 
166
185
  const PINNED_UPSTREAM_MODELS: Map<string, RawEntry> = new Map(
@@ -246,13 +265,36 @@ export function nativeContextLimits(
246
265
  }
247
266
 
248
267
  /** Apply the user levers to an authoritative value. */
268
+ /**
269
+ * The ceiling a native slug may be RAISED to by a user lever, or undefined when it has no
270
+ * separate long window.
271
+ *
272
+ * This is what makes the dashboard's 1M opt-in work: without an opt-in ceiling a lever can only
273
+ * ever narrow the advertised window, so the toggle would appear to do nothing. The GPT-5.6 family
274
+ * shares one measured ceiling; a self-described native carries its own in
275
+ * `NATIVE_OPENAI_CONTEXT_OVERRIDES.maxContextWindow` (`gpt-6-astra` ships 872,000 against a
276
+ * 272,000 default), and reading it per-slug is what keeps the toggle honest for a model whose
277
+ * ceiling is not the family's.
278
+ */
279
+ function longWindowOptInCeiling(slug: string): number | undefined {
280
+ if (NATIVE_GPT56_FAMILY.has(slug)) return NATIVE_GPT56_MAX_INPUT_TOKENS;
281
+ const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug];
282
+ const defaultWindow = positiveInt(override?.contextWindow);
283
+ const longWindow = positiveInt(override?.maxContextWindow);
284
+ if (defaultWindow === undefined || longWindow === undefined || longWindow <= defaultWindow) {
285
+ return undefined;
286
+ }
287
+ return longWindow;
288
+ }
289
+
249
290
  function narrowToLimits(raw: number | undefined, slug: string, input: NativeContextLimitsInput): number | undefined {
250
291
  if (raw === undefined) return undefined;
251
292
  const limits = asLimits(input);
252
293
  const overlay = positiveInt(limits.modelWindows?.[slug]) ?? positiveInt(limits.providerWindow);
253
294
  const cap = positiveInt(limits.cap);
254
- if (NATIVE_GPT56_FAMILY.has(slug)) {
255
- const ceiling = NATIVE_GPT56_MAX_INPUT_TOKENS;
295
+ const optInCeiling = longWindowOptInCeiling(slug);
296
+ if (optInCeiling !== undefined) {
297
+ const ceiling = optInCeiling;
256
298
  const chosen = overlay ?? cap ?? raw;
257
299
  const window = Math.min(chosen, ceiling);
258
300
  return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window;
@@ -271,6 +313,11 @@ export function nativeOpenAiContextWindow(slug: string, limits?: NativeContextLi
271
313
  return narrowToLimits(raw, slug, limits);
272
314
  }
273
315
 
316
+ export function nativeOpenAiMaxOutputTokens(slug: string): number | undefined {
317
+ const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug);
318
+ return positiveInt(getModelMetadata("openai", sourceSlug)?.maxTokens);
319
+ }
320
+
274
321
  /**
275
322
  * Long-context tier for a native slug as a (default, long) pair, for clients that let the user
276
323
  * pick a window per request (Cursor's local-agent "Context" selector). The pair is the family's
@@ -491,15 +538,23 @@ export function applyNativeVisibility(
491
538
 
492
539
  function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined {
493
540
  const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug);
494
- if (!sourceSlug.startsWith("gpt-5.6-")) return undefined;
541
+ // A self-described native returns its OWN pinned row; the alias-cloning branch below stays
542
+ // reserved for slugs that genuinely borrow another model's identity. The allowlist is explicit
543
+ // rather than "has a pinned entry", which would also admit gpt-5.5/gpt-5.4/gpt-5.4-mini into
544
+ // the sync-replacement authority this map carries.
545
+ if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) {
546
+ return undefined;
547
+ }
495
548
  const source = PINNED_UPSTREAM_MODELS.get(sourceSlug);
496
549
  if (!source) return undefined;
497
- if (slug === sourceSlug) return source;
550
+ if (slug === sourceSlug) return withDerivedBaseInstructions(source);
498
551
 
499
552
  const alias = structuredClone(source) as RawEntry;
500
553
  alias.slug = slug;
501
- alias.display_name = "Daybreak Blue";
502
- alias.description = "Frontier general-purpose model with safeguards for defensive cybersecurity work.";
554
+ const presentation = nativeOpenAiAliasPresentation(slug);
555
+ if (!presentation) return undefined; // an alias with no product identity must not ship a wrong one
556
+ alias.display_name = presentation.displayName;
557
+ alias.description = presentation.description;
503
558
  if (typeof alias.base_instructions === "string") {
504
559
  alias.base_instructions = identifyRoutedModel(alias.base_instructions, slug);
505
560
  }
@@ -516,6 +571,27 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined {
516
571
  return alias;
517
572
  }
518
573
 
574
+ /**
575
+ * Backfill `base_instructions` from `model_messages.instructions_template` when upstream ships
576
+ * only the latter.
577
+ *
578
+ * `gpt-6-astra` is the first pinned row to arrive without a top-level `base_instructions`; every
579
+ * other native carries both. That field is not decorative here — `hasNativeCatalogRowShape`,
580
+ * `findNativeTemplate` and `findSupportedNativeTemplate` all test for it, so a row missing it is
581
+ * not recognized as a native catalog row at all. The two fields hold the same prompt upstream, so
582
+ * deriving one from the other preserves upstream's content while keeping this codebase's row
583
+ * shape intact. The pinned JSON is left byte-identical to upstream; only the projection fills in.
584
+ */
585
+ function withDerivedBaseInstructions(entry: RawEntry): RawEntry {
586
+ if (typeof entry.base_instructions === "string" && entry.base_instructions.length > 0) return entry;
587
+ const messages = entry.model_messages;
588
+ const template = messages && typeof messages === "object" && !Array.isArray(messages)
589
+ ? (messages as Record<string, unknown>).instructions_template
590
+ : undefined;
591
+ if (typeof template !== "string" || template.length === 0) return entry;
592
+ return { ...entry, base_instructions: template };
593
+ }
594
+
519
595
  export const UPSTREAM_NATIVE_ENTRIES: Map<string, RawEntry> = new Map(
520
596
  [...NATIVE_OPENAI_MODELS, ...NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS].flatMap(slug => {
521
597
  const entry = upstreamNativeEntryForSlug(slug);
@@ -531,10 +607,44 @@ export function upstreamNativeEntry(slug: string): RawEntry | null {
531
607
  return clone;
532
608
  }
533
609
 
610
+ /**
611
+ * Product label for a native slug whose custom row inherits native metadata.
612
+ *
613
+ * An alias carries a hand-written presentation because upstream never described it. A
614
+ * self-described native gets its label from its own pinned row instead, so the two kinds answer
615
+ * through one accessor and no caller has to know which it holds.
616
+ */
617
+ export function nativeOpenAiCapabilityDisplayName(slug: string): string | undefined {
618
+ const presentation = nativeOpenAiAliasPresentation(slug);
619
+ if (presentation) return presentation.displayName;
620
+ const pinned = UPSTREAM_NATIVE_ENTRIES.get(slug);
621
+ return typeof pinned?.display_name === "string" ? pinned.display_name : undefined;
622
+ }
623
+
624
+ /**
625
+ * Slugs whose persisted row may be replaced by the pinned snapshot even when it carries a real
626
+ * display name — because THIS codebase wrote that name from a guess.
627
+ *
628
+ * `shouldUpgradeToUpstreamEntry`'s normal rule ("upgrade only fallback-quality rows, where
629
+ * `display_name === slug`") assumes any row with a real label came from upstream and is therefore
630
+ * authoritative. That assumption broke for `gpt-6-astra`: opencodex shipped a speculative row with
631
+ * a hand-written "GPT-6 Astra" label and a provisional description while the slug was still a leak.
632
+ * Those rows are already on disk in every install that ran that release, and they look genuine, so
633
+ * without this list they would survive every future sync and permanently shadow the real shipped
634
+ * metadata — the wrong label, the wrong 922k ceiling, the wrong priority.
635
+ *
636
+ * Membership is a statement about opencodex's own history, not about upstream. Add a slug only
637
+ * when a released version of this project wrote a fabricated row for it.
638
+ */
639
+ const SELF_AUTHORED_NATIVE_ROWS: ReadonlySet<string> = new Set([NATIVE_GPT6_ASTRA_MODEL]);
640
+
534
641
  export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {
535
- return typeof entry.slug === "string"
536
- && UPSTREAM_NATIVE_ENTRIES.has(entry.slug)
537
- && entry.display_name === entry.slug;
642
+ if (typeof entry.slug !== "string" || !UPSTREAM_NATIVE_ENTRIES.has(entry.slug)) return false;
643
+ if (entry.display_name === entry.slug) return true;
644
+ // A row this project authored from a guess is not evidence of upstream truth, however genuine
645
+ // its display name looks. Replace it once, from the pin.
646
+ return SELF_AUTHORED_NATIVE_ROWS.has(entry.slug)
647
+ && entry.display_name !== UPSTREAM_NATIVE_ENTRIES.get(entry.slug)?.display_name;
538
648
  }
539
649
 
540
650
  export function nativeOpenAiSlugs(): string[] {
@@ -1,6 +1,32 @@
1
1
  /** ChatGPT/Codex wire id observed for the account-native Daybreak Blue surface. */
2
2
  export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest";
3
3
 
4
+ /**
5
+ * Leaked Responses API identifier for the announced next-generation OpenAI model
6
+ * (2026-09-03: OpenAI teased the launch on X; community probes report `gpt-6-astra` returning
7
+ * the same 404 as other internal staging slugs where an arbitrary slug returns 400).
8
+ * Registered preemptively so an entitled account can route it the moment it ships, before any
9
+ * codex-rs catalog carries it. Unlike Daybreak it is NOT wire-normalized to a serving id —
10
+ * the leaked slug IS the wire id.
11
+ */
12
+ /**
13
+ * SHIPPED as of 2026-09-03: openai/codex `ed391d4dd` (#42607, bundled model catalog) and
14
+ * `1f7b99922` (#42619, Amazon Bedrock catalogs). The registration is no longer speculative —
15
+ * `src/codex/data/upstream-models.json` now pins the real row, so this slug is SELF-DESCRIBED
16
+ * and must not borrow another model's capability metadata.
17
+ *
18
+ * Still NOT wire-normalized: unlike Daybreak the slug IS the wire id.
19
+ *
20
+ * Deliberately NOT account-gated (owner decision, 2026-09-04, reaffirmed during rollout).
21
+ * Upstream `available_in_plans` lists 23 plans including `free`, but the model is rolling out,
22
+ * so a given account's Codex surface may still answer
23
+ * `"The 'gpt-6-astra' model is not supported when using Codex with a ChatGPT account."`
24
+ * — the same refusal Daybreak returns. Gating on an entitlement roster would hide the row until
25
+ * that roster catches up; listing it means the request dispatches and the real upstream status
26
+ * is what the user sees. Evidence: devlog/_plan/260904_astra_release_alignment/021.
27
+ */
28
+ export const NATIVE_GPT6_ASTRA_MODEL = "gpt-6-astra";
29
+
4
30
  /** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */
5
31
  export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
6
32
  "gpt-5.6-sol",
@@ -25,6 +51,19 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
25
51
  [NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol",
26
52
  });
27
53
 
54
+ /**
55
+ * Native slugs that carry their OWN pinned upstream row rather than an alias's borrowed one.
56
+ *
57
+ * Membership authorizes `upstreamNativeEntryForSlug` to return the pinned entry directly. It is
58
+ * an explicit list, not a structural `PINNED_UPSTREAM_MODELS.has(slug)` predicate: the pin also
59
+ * holds `gpt-5.5`, `gpt-5.4` and `gpt-5.4-mini`, and admitting those into
60
+ * `UPSTREAM_NATIVE_ENTRIES` would newly authorize replacing their persisted catalog rows during
61
+ * sync — an invariant that map's own comment reserves for the GPT-5.6 family.
62
+ */
63
+ export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
64
+ NATIVE_GPT6_ASTRA_MODEL,
65
+ ]);
66
+
28
67
  /**
29
68
  * Native ids whose capability metadata is inherited from another pinned native row.
30
69
  *
@@ -46,10 +85,41 @@ export function isNativeOpenAiCapabilityAliasModel(slug: string): boolean {
46
85
  return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug);
47
86
  }
48
87
 
88
+ /**
89
+ * Native slugs whose Codex-forward CUSTOM row inherits authoritative native metadata.
90
+ *
91
+ * Two shapes qualify and the distinction matters only to `upstreamNativeEntryForSlug`:
92
+ * a capability ALIAS borrows another model's pinned row, while a SELF-DESCRIBED native has its
93
+ * own. Every consumer that asks "does this custom row get real native capabilities and a real
94
+ * product label" wants both, which is why they call this rather than the alias check —
95
+ * `gpt-6-astra` stopped being an alias when its own row was pinned, and gating on
96
+ * `isNativeOpenAiCapabilityAliasModel` alone would have silently demoted it to a bare-slug label
97
+ * with no inherited ladder.
98
+ */
99
+ export function hasNativeOpenAiCapabilityMetadata(slug: string): boolean {
100
+ return isNativeOpenAiCapabilityAliasModel(slug) || SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug);
101
+ }
102
+
49
103
  export function nativeOpenAiCapabilitySourceSlug(slug: string): string {
50
104
  return NATIVE_OPENAI_CAPABILITY_SOURCES[slug] ?? slug;
51
105
  }
52
106
 
107
+ /**
108
+ * Presentation identity per capability alias. Capability metadata (context, ladder, modalities)
109
+ * is inherited from the source model; the NAME and description are the alias's own product
110
+ * identity — hardcoding one alias's label would present every other alias as the wrong product.
111
+ */
112
+ export const NATIVE_OPENAI_ALIAS_PRESENTATION: Readonly<Record<string, { displayName: string; description: string }>> = Object.freeze({
113
+ [NATIVE_DAYBREAK_BLUE_MODEL]: {
114
+ displayName: "Daybreak Blue",
115
+ description: "Frontier general-purpose model with safeguards for defensive cybersecurity work.",
116
+ },
117
+ });
118
+
119
+ export function nativeOpenAiAliasPresentation(slug: string): { displayName: string; description: string } | undefined {
120
+ return NATIVE_OPENAI_ALIAS_PRESENTATION[slug];
121
+ }
122
+
53
123
  /**
54
124
  * Native OpenAI model ids that this release can route and restore with authoritative metadata.
55
125
  *
@@ -70,6 +140,7 @@ export const NATIVE_OPENAI_MODELS = [
70
140
  "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
71
141
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
72
142
  NATIVE_DAYBREAK_BLUE_MODEL,
143
+ NATIVE_GPT6_ASTRA_MODEL,
73
144
  ];
74
145
 
75
146
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
@@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact";
31
31
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
32
 
33
33
 
34
- import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, isNativeOpenAiCapabilityAliasModel, nativeMultiAgentVersion, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata";
34
+ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, hasNativeOpenAiCapabilityMetadata, nativeMultiAgentVersion, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "./metadata";
35
35
  import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
36
36
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
37
37
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
@@ -112,6 +112,8 @@ export interface CatalogModel {
112
112
  defaultReasoningEffort?: string;
113
113
  contextWindow?: number;
114
114
  maxInputTokens?: number;
115
+ /** Model-scoped output-token ceiling; omitted when no authoritative value is known. */
116
+ maxOutputTokens?: number;
115
117
  /** Soft client compaction threshold; hard context/input limits remain authoritative. */
116
118
  autoCompactTokenLimit?: number;
117
119
  contextCap?: number;
@@ -524,7 +526,7 @@ export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean {
524
526
  if (
525
527
  entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND
526
528
  && entry.use_responses_lite === true
527
- && isNativeOpenAiCapabilityAliasModel(routedNativeSlug)
529
+ && hasNativeOpenAiCapabilityMetadata(routedNativeSlug)
528
530
  ) return true;
529
531
  if (UPSTREAM_NATIVE_ENTRIES.has(slug) || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) return true;
530
532
  return false;
@@ -582,7 +584,7 @@ export function applyMultiAgentMode(
582
584
  : "";
583
585
  const codexForwardCapabilityAlias = entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND
584
586
  && entry.use_responses_lite === true
585
- && isNativeOpenAiCapabilityAliasModel(routedNativeSlug)
587
+ && hasNativeOpenAiCapabilityMetadata(routedNativeSlug)
586
588
  ? routedNativeSlug
587
589
  : undefined;
588
590
  const upstreamPin = nativeAlias