@bitkyc08/opencodex 2.22.0 → 2.23.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 (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +5 -1
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +86 -74
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
@@ -99,17 +99,8 @@ function pushSystemText(parts: string[], content: unknown): void {
99
99
  if (text) parts.push(text);
100
100
  }
101
101
 
102
- function toolCallsToItems(toolCalls: unknown, input: Rec[]): void {
102
+ function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: Map<string, string>): void {
103
103
  if (!Array.isArray(toolCalls)) return;
104
- // Recover names from earlier function_call items in the same transcript when a client
105
- // re-sends tool_calls with only id/arguments (replace-style merge lost function.name).
106
- const knownNameByCallId = new Map<string, string>();
107
- for (const item of input) {
108
- if (!isRec(item) || item.type !== "function_call") continue;
109
- if (typeof item.call_id === "string" && typeof item.name === "string" && item.name.length > 0) {
110
- knownNameByCallId.set(item.call_id, item.name);
111
- }
112
- }
113
104
  for (const raw of toolCalls) {
114
105
  if (!isRec(raw)) continue;
115
106
  const fn = isRec(raw.function) ? raw.function : null;
@@ -243,6 +234,9 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
243
234
 
244
235
  const systemParts: string[] = [];
245
236
  const input: Rec[] = [];
237
+ // Recover replace-style tool calls incrementally instead of rebuilding the
238
+ // call-id index from the entire translated transcript for every message.
239
+ const knownNameByCallId = new Map<string, string>();
246
240
 
247
241
  for (const msg of raw.messages) {
248
242
  if (!isRec(msg)) continue;
@@ -260,7 +254,7 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
260
254
  case "assistant": {
261
255
  const blocks = assistantContentToBlocks(msg.content);
262
256
  if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
263
- if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input);
257
+ if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId);
264
258
  break;
265
259
  }
266
260
  case "tool": {
@@ -89,6 +89,10 @@ export function shouldMarkOneMillion(window: number | undefined, auto: AutoConte
89
89
  export function buildClaudeContextWindows(
90
90
  nativeSlugs: readonly string[],
91
91
  routedModels: readonly CatalogModel[],
92
+ // A configured providerContextCaps.openai has to reach the native rows here too. Without
93
+ // it the Claude surface keeps advertising the uncapped authoritative window while the
94
+ // Codex catalog advertises the capped one, and the two disagree about the same model.
95
+ nativeContextCap?: number,
92
96
  ): Record<string, number> {
93
97
  const out: Record<string, number> = {};
94
98
  const put = (key: string | null, value: number) => {
@@ -96,7 +100,7 @@ export function buildClaudeContextWindows(
96
100
  if (out[key] === undefined) out[key] = value; // first-wins (registry policy)
97
101
  };
98
102
  for (const slug of nativeSlugs) {
99
- const window = nativeOpenAiContextWindow(slug);
103
+ const window = nativeOpenAiContextWindow(slug, nativeContextCap);
100
104
  if (typeof window !== "number" || window <= 0) continue;
101
105
  put(slug, window);
102
106
  put(desktop3pAlias("native", slug), window);
@@ -191,6 +191,7 @@ function collectDesktop3pModels(
191
191
  nativeSlugs: string[],
192
192
  routedModels: Array<Desktop3pRoutedModel>,
193
193
  profile?: OcxClaudeDesktopProfile,
194
+ nativeContextCap?: number,
194
195
  ): { models: Desktop3pModelEntry[]; registry: Map<string, string> } {
195
196
  const registry = new Map<string, string>();
196
197
  const models: Desktop3pModelEntry[] = [];
@@ -199,7 +200,7 @@ function collectDesktop3pModels(
199
200
  // Desktop DTO uses, so a native 1M/372k model resolves identically in the written
200
201
  // config and on the dashboard.
201
202
  ...nativeSlugs.map(id => {
202
- const contextWindow = nativeOpenAiContextWindow(id);
203
+ const contextWindow = nativeOpenAiContextWindow(id, nativeContextCap);
203
204
  return { provider: "native", id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
204
205
  }),
205
206
  ...routedModels,
@@ -292,8 +293,9 @@ export function buildDesktop3pRegistry(
292
293
  nativeSlugs: string[],
293
294
  routedModels: Array<Desktop3pRoutedModel>,
294
295
  profile?: OcxClaudeDesktopProfile,
296
+ nativeContextCap?: number,
295
297
  ): Map<string, string> {
296
- const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile);
298
+ const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
297
299
  desktop3pRegistry = registry;
298
300
  return registry;
299
301
  }
@@ -303,8 +305,9 @@ export function generateDesktop3pModels(
303
305
  nativeSlugs: string[],
304
306
  routedModels: Array<Desktop3pRoutedModel>,
305
307
  profile?: OcxClaudeDesktopProfile,
308
+ nativeContextCap?: number,
306
309
  ): Desktop3pModelEntry[] {
307
- const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile);
310
+ const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
308
311
  desktop3pRegistry = registry;
309
312
  return models;
310
313
  }
@@ -334,6 +337,7 @@ export function generateDesktop3pConfig(
334
337
  apiKey = "ocx",
335
338
  mode: Desktop3pConfigMode = "static",
336
339
  profile?: OcxClaudeDesktopProfile,
340
+ nativeContextCap?: number,
337
341
  ): object {
338
342
  const base = {
339
343
  inferenceProvider: "gateway",
@@ -343,14 +347,14 @@ export function generateDesktop3pConfig(
343
347
  };
344
348
  if (mode === "discovery") {
345
349
  // Build/refresh the decode registry even though no static list is emitted.
346
- buildDesktop3pRegistry(nativeSlugs, routedModels, profile);
350
+ buildDesktop3pRegistry(nativeSlugs, routedModels, profile, nativeContextCap);
347
351
  return { ...base, modelDiscoveryEnabled: true };
348
352
  }
349
353
  return {
350
354
  ...base,
351
355
  modelDiscoveryEnabled: mode === "hybrid",
352
356
  inferenceModels: (() => {
353
- const models = generateDesktop3pModels(nativeSlugs, routedModels, profile);
357
+ const models = generateDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
354
358
  // Fail loud at the write boundary rather than ship a config Desktop rejects:
355
359
  // the output counterpart of the request-path guards.
356
360
  assertDesktop3pModelsValid(models);
@@ -554,6 +558,7 @@ export function writeDesktop3pConfig(
554
558
  apiKey?: string,
555
559
  mode: Desktop3pConfigMode = "static",
556
560
  profile?: OcxClaudeDesktopProfile,
561
+ nativeContextCap?: number,
557
562
  ): { written: boolean; path: string; reason?: string; fingerprint?: string } {
558
563
  const libraryPath = resolveDesktop3pConfigLibraryPath();
559
564
  const metadataPath = join(libraryPath, "_meta.json");
@@ -571,7 +576,7 @@ export function writeDesktop3pConfig(
571
576
  ? metadata.entries.map(current => current === existing ? entry : current)
572
577
  : [...metadata.entries, entry];
573
578
 
574
- const configJson = JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile), null, 2) + "\n";
579
+ const configJson = JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap), null, 2) + "\n";
575
580
  const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
576
581
  const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson);
577
582
  try {
@@ -25,7 +25,37 @@ function isRec(v: unknown): v is Rec {
25
25
  return !!v && typeof v === "object" && !Array.isArray(v);
26
26
  }
27
27
 
28
- /** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), else passthrough. */
28
+ function isClaudeClassifierModel(model: string): boolean {
29
+ const stripped = model.replace(/-\d{8}$/, "");
30
+ return /^claude-opus-[45]/.test(stripped);
31
+ }
32
+
33
+ /**
34
+ * Explicitly configured classifier route for Claude Code Auto Mode safety checks (#1697).
35
+ *
36
+ * Only OPERATOR-DECLARED targets are used: `classifierModel`, then the ordered
37
+ * `classifierFallbacks`. Both are qualified `provider/model` strings the operator chose, so
38
+ * routing them crosses no boundary the operator did not ask for.
39
+ *
40
+ * Deliberately NOT here: inferring a provider from `claudeCode.model`. That value is the
41
+ * injected/default config slot, not the provider the live session actually selected, so it goes
42
+ * stale the moment the user changes the model picker -- and acting on it would silently move a
43
+ * classifier turn onto a provider with its own privacy and billing consequences. Live session
44
+ * affinity needs the request/session state this function does not have; it is tracked as
45
+ * follow-up work rather than approximated from static config.
46
+ */
47
+ function configuredClassifierRoute(cc?: OcxClaudeCodeConfig): string | undefined {
48
+ const explicit = typeof cc?.classifierModel === "string" ? cc.classifierModel.trim() : "";
49
+ if (explicit.length > 0) return explicit;
50
+ if (Array.isArray(cc?.classifierFallbacks)) {
51
+ for (const candidate of cc.classifierFallbacks) {
52
+ if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim();
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ /** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), then classifier affinity/config, else passthrough. */
29
59
  export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): string {
30
60
  // Defensive: Desktop/CLI strip the [1m] context-variant marker client-side, but a
31
61
  // leaking build must not break alias decode (devlog 138 — the 1M signal is the
@@ -47,6 +77,14 @@ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): st
47
77
  const stripped = model.replace(/-\d{8}$/, "");
48
78
  const dateless = map[stripped];
49
79
  if (typeof dateless === "string" && dateless.length > 0) return dateless;
80
+
81
+ // Claude Code Auto Mode classifier routing (#1697). Bare classifier checks such as
82
+ // `claude-opus-5` carry no provider, so without this they fall through to defaultProvider --
83
+ // which may not speak Anthropic at all. Only an operator-declared target is used.
84
+ if (isClaudeClassifierModel(model)) {
85
+ const configured = configuredClassifierRoute(cc);
86
+ if (configured) return configured;
87
+ }
50
88
  return model;
51
89
  }
52
90
 
@@ -15,7 +15,7 @@
15
15
  * - created_at is a fixed constant; max_input_tokens is authoritative-or-null;
16
16
  * max_tokens is always null (no authoritative output limit exists proxy-side).
17
17
  */
18
- import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, type CatalogModel } from "../codex/catalog";
18
+ import { catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel } from "../codex/catalog";
19
19
  import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
20
20
  import { desktop3pAlias } from "./desktop-3p";
21
21
  import { AUTO_CONTEXT_OFF, type AutoContextMode } from "./context-windows";
@@ -108,6 +108,7 @@ export function buildAnthropicModelInfos(
108
108
  auto: AutoContextMode = AUTO_CONTEXT_OFF,
109
109
  idStyle: AnthropicIdStyle = "desktop3p",
110
110
  aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias,
111
+ nativeContextCap?: number,
111
112
  ): AnthropicModelInfo[] {
112
113
  const out: AnthropicModelInfo[] = [];
113
114
  const seen = new Set<string>();
@@ -117,7 +118,7 @@ export function buildAnthropicModelInfos(
117
118
  // the auto-context widening that let a 372K route carry the marker (and be
118
119
  // over-filled) is the #854 defect and does not come back. Guards (audit R1#11):
119
120
  // same dedupe set, never double-suffix.
120
- const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined) => {
121
+ const push1mVariant = (base: AnthropicModelInfo, contextWindow: number | undefined, maxInputTokens?: number) => {
121
122
  // The [1m] marker makes Claude Code account 1e6 tokens for the row, so it
122
123
  // may only name models whose AUTHORITATIVE effective window is >= 1M —
123
124
  // never the auto-context widening, which would mark a 372K route and have
@@ -127,16 +128,27 @@ export function buildAnthropicModelInfos(
127
128
  const id = `${base.id}[1m]`;
128
129
  if (seen.has(id)) return;
129
130
  seen.add(id);
130
- const window = contextWindow as number;
131
- out.push({ ...base, id, display_name: `${base.display_name} · 1M`, max_input_tokens: ONE_MILLION });
131
+ // The marker fixes Claude Code's accounting at 1e6, but a model may accept less input
132
+ // than that: GPT-5.6 advertises a 1,050,000 window while refusing anything past 922,000
133
+ // (measured — see devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md).
134
+ // Advertising the flat 1e6 there would invite mid-session context_length_exceeded, so the
135
+ // variant reports whichever of the two is smaller.
136
+ const advertised = typeof maxInputTokens === "number" && maxInputTokens > 0
137
+ ? Math.min(ONE_MILLION, maxInputTokens)
138
+ : ONE_MILLION;
139
+ out.push({ ...base, id, display_name: `${base.display_name} · 1M`, max_input_tokens: advertised });
132
140
  };
133
141
  for (const slug of nativeSlugs) {
134
142
  const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug);
135
143
  if (seen.has(id)) continue;
136
144
  seen.add(id);
137
- const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeOpenAiContextWindow(slug));
145
+ const nativeWindow = nativeOpenAiContextWindow(slug, nativeContextCap);
146
+ const nativeMaxInput = nativeOpenAiMaxInputTokens(slug, nativeContextCap);
147
+ // max_input_tokens is an INPUT limit, so it follows the measured input ceiling rather
148
+ // than the total window whenever the model publishes one.
149
+ const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeMaxInput ?? nativeWindow);
138
150
  out.push(info);
139
- push1mVariant(info, nativeOpenAiContextWindow(slug));
151
+ push1mVariant(info, nativeWindow, nativeMaxInput);
140
152
  }
141
153
  for (const m of routedModels) {
142
154
  const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : aliasForRoute(m.provider, m.id);
@@ -144,11 +156,19 @@ export function buildAnthropicModelInfos(
144
156
  seen.add(id);
145
157
  const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
146
158
  const imageInput = Array.isArray(m.inputModalities) ? m.inputModalities.includes("image") : false;
147
- const info = modelInfo(id, `${m.id} (${m.provider})`, ladder, imageInput, m.contextWindow);
159
+ // max_input_tokens is an input limit, so a row that publishes a lower input ceiling than
160
+ // its window (native GPT-5.6 forwarded through a provider: 922k under 1.05M) reports the
161
+ // ceiling. Rows without one keep reporting the window, as before.
162
+ const routedMaxInput = typeof m.maxInputTokens === "number" && m.maxInputTokens > 0
163
+ ? (typeof m.contextWindow === "number" && m.contextWindow > 0
164
+ ? Math.min(m.maxInputTokens, m.contextWindow)
165
+ : m.maxInputTokens)
166
+ : undefined;
167
+ const info = modelInfo(id, `${m.id} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow);
148
168
  out.push(info);
149
169
  // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude
150
170
  // routes — only a genuine >=1M window earns the variant row there.
151
- push1mVariant(info, m.contextWindow);
171
+ push1mVariant(info, m.contextWindow, routedMaxInput);
152
172
  }
153
173
  return out;
154
174
  }
@@ -145,6 +145,10 @@ export interface CodexQuotaDto {
145
145
  monthlyPercent?: number;
146
146
  weeklyResetAt?: number;
147
147
  monthlyResetAt?: number;
148
+ /** Sub-day burst window, when upstream declares one (#1791). */
149
+ shortPercent?: number;
150
+ shortResetAt?: number;
151
+ shortWindowSeconds?: number;
148
152
  }
149
153
 
150
154
  export interface ProviderQuotaWindowDto {
@@ -183,7 +187,7 @@ interface CodexAccountDto {
183
187
  function projectQuota(quota: CodexQuotaDto | null | undefined): CodexQuotaDto | null {
184
188
  if (!quota) return null;
185
189
  const projected: CodexQuotaDto = {};
186
- for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt"] as const) {
190
+ for (const key of ["weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"] as const) {
187
191
  if (typeof quota[key] === "number" && Number.isFinite(quota[key])) projected[key] = quota[key];
188
192
  }
189
193
  return projected;
@@ -15,6 +15,8 @@ import { filterCatalogVisibleModels, desktopVisibleNativeSlugs } from "../codex/
15
15
  import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api";
16
16
  import { findLiveProxy } from "../server/proxy-liveness";
17
17
  import { runtimeRequest } from "./runtime-api";
18
+ import { providerContextCap } from "../providers/context-cap";
19
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
18
20
 
19
21
  function isFamily(value: string | undefined): value is DesktopFamily {
20
22
  return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value);
@@ -98,6 +100,7 @@ export async function applyProfile(
98
100
  config.apiKeys?.[0]?.key,
99
101
  mode,
100
102
  state.profile,
103
+ providerContextCap(config, OPENAI_CODEX_PROVIDER_ID),
101
104
  );
102
105
  return { ok: result.written, path: result.path, reason: result.reason };
103
106
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { clearCodexAccountPin } from "../codex/account-priority";
3
- import { getConfigPath, readConfigDiagnostics, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate } from "../config";
3
+ import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate } from "../config";
4
4
  import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { normalizeVisionReasoningForModel } from "../vision/reasoning";
@@ -130,19 +130,42 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
130
130
  const raw = action === "set" ? args.shift() : undefined;
131
131
  if (!path || (action === "set" && raw === undefined)) throw new CliUsageError("config path and value are required", USAGE);
132
132
  rejectArgs(args, USAGE);
133
- const candidate = structuredClone(readConfigDiagnostics().config) as unknown as Record<string, unknown>;
134
- setPath(candidate, path, raw === undefined ? undefined : parseValue(raw), action === "unset");
135
- const config = validate(candidate);
136
- const savedValue = action === "unset" ? null : getPath(config, path);
137
- // Setting the order here is the operator restating it, exactly as through
138
- // `ocx account priority` or the management route, so it releases the manual pin
139
- // for the same reason those do: a pin made before any order existed would
140
- // otherwise outrank every order set afterwards, capping the pool at the pinned
141
- // account's tier with nothing on any surface explaining why. `import` is
142
- // deliberately not covered that file supplies its own pin, so there is no
143
- // stale one to release.
144
- if (pathSegments(path)[0] === "codexAccountPriorities") clearCodexAccountPin(config);
145
- saveConfig(config);
133
+ // #1835/#1838: the read used to happen OUTSIDE the mutation lock, so a concurrent
134
+ // edit landing between it and the save was reverted by this whole-snapshot write.
135
+ // `mutatePersistedConfig` reruns this callback against the latest validated disk
136
+ // state, so the operation is applied to what is actually there at commit time.
137
+ let savedValue: unknown = null;
138
+ const outcome = mutatePersistedConfig(fresh => {
139
+ // Snapshot BEFORE mutating: comparing after the write compares a value with
140
+ // itself and would report every no-op as a change, bumping the generation.
141
+ const before = JSON.stringify(fresh);
142
+ const candidate = structuredClone(fresh) as unknown as Record<string, unknown>;
143
+ setPath(candidate, path, raw === undefined ? undefined : parseValue(raw), action === "unset");
144
+ const config = validate(candidate);
145
+ savedValue = action === "unset" ? null : getPath(config, path);
146
+ // Setting the order here is the operator restating it, exactly as through
147
+ // `ocx account priority` or the management route, so it releases the manual pin
148
+ // for the same reason those do: a pin made before any order existed would
149
+ // otherwise outrank every order set afterwards, capping the pool at the pinned
150
+ // account's tier with nothing on any surface explaining why. `import` is
151
+ // deliberately not covered — that file supplies its own pin, so there is no
152
+ // stale one to release.
153
+ if (pathSegments(path)[0] === "codexAccountPriorities") clearCodexAccountPin(config);
154
+ // REPLACE rather than merge: `Object.assign` alone cannot remove a key that
155
+ // `unset` deleted, which would make unset silently succeed while changing nothing.
156
+ for (const key of Object.keys(fresh)) {
157
+ if (!(key in (config as unknown as Record<string, unknown>))) {
158
+ delete (fresh as unknown as Record<string, unknown>)[key];
159
+ }
160
+ }
161
+ Object.assign(fresh, config);
162
+ return { changed: JSON.stringify(fresh) !== before, value: undefined };
163
+ });
164
+ if (outcome.status === "unavailable") {
165
+ throw new Error(outcome.reason === "conflict"
166
+ ? "config changed while applying this update; retry"
167
+ : `config is ${outcome.reason}`);
168
+ }
146
169
  printData({ ok: true, path, value: redact(savedValue, path.split(".").at(-1)) }, wantsJson,
147
170
  [`${action === "unset" ? "Unset" : "Set"} ${path}.`]);
148
171
  return;
@@ -16,7 +16,7 @@
16
16
  * mocking modules — a route test that could not stub this would really terminate
17
17
  * the developer's own Codex.
18
18
  *
19
- * Plan and audit history: `devlog/_plan/260815_gui_codex_restart/010_phase1_backend_endpoint.md`.
19
+ * Plan and audit history: `devlog/_fin/260815_gui_codex_restart/010_phase1_backend_endpoint.md`.
20
20
  */
21
21
  import {
22
22
  collectCodexAppServerCatalogState,
@@ -217,6 +217,11 @@ function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAc
217
217
  return {
218
218
  ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
219
219
  ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
220
+ // A 30-day plan can still carry a burst window, and it blocks the account on its own.
221
+ // Dropping it here would show a healthy card for an account upstream is refusing (#1791).
222
+ ...(quota.shortPercent !== undefined ? { shortPercent: quota.shortPercent } : {}),
223
+ ...(quota.shortResetAt !== undefined ? { shortResetAt: quota.shortResetAt } : {}),
224
+ ...(quota.shortWindowSeconds !== undefined ? { shortWindowSeconds: quota.shortWindowSeconds } : {}),
220
225
  ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
221
226
  ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
222
227
  } as T;
@@ -11,7 +11,7 @@ import { isCodexAccountPaused } from "./account-pause";
11
11
  import { ConfigMutationLockError } from "../config";
12
12
  import { isCodexAccountUsable } from "./account-usability";
13
13
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
14
- import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
14
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, isMainAccountTokenLive } from "./main-account";
15
15
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
16
16
  import {
17
17
  codexQuotaScopeForModel,
@@ -451,7 +451,32 @@ export function applyCodexAuthContextToProvider(
451
451
  };
452
452
  }
453
453
 
454
- export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
454
+ export class CodexMainSubstitutionUnavailableError extends Error {
455
+ constructor() {
456
+ super("No usable Codex main credential to substitute for an admission bearer");
457
+ this.name = "CodexMainSubstitutionUnavailableError";
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Build the upstream auth headers for one Codex turn.
463
+ *
464
+ * The two credential domains meet here, and only here:
465
+ *
466
+ * - `pool` / `main-pool` always OVERWRITE with the stored account credential. Whatever the
467
+ * caller sent is irrelevant to what we send upstream.
468
+ * - `main` with an admission-bearer caller (#1686) must substitute the stored main credential.
469
+ * The caller proved admission with one of OUR secrets, which must never leave the process, so
470
+ * the only two acceptable outcomes are replaced-with-stored-main or fail-before-any-IO.
471
+ * Silently forwarding would be the leak validateForwardAdmissionCredential exists to prevent.
472
+ * - `main` with a dedicated-header caller keeps the existing intentional passthrough: the bearer
473
+ * there is the user's own ChatGPT credential, not ours.
474
+ */
475
+ export function materializeCodexUpstreamAuth(
476
+ headers: Headers,
477
+ ctx: CodexAuthContext,
478
+ options: { substituteMainCredential?: boolean } = {},
479
+ ): Headers {
455
480
  const selected = new Headers();
456
481
  for (const name of FORWARD_HEADERS) {
457
482
  const value = headers.get(name);
@@ -460,10 +485,26 @@ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthConte
460
485
  if (ctx.kind === "pool" || ctx.kind === "main-pool") {
461
486
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
462
487
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
488
+ return selected;
489
+ }
490
+ if (ctx.kind === "main" && options.substituteMainCredential === true) {
491
+ const stored = getMainAccountToken();
492
+ // Fail BEFORE any upstream I/O. Falling through here would send the admission secret.
493
+ if (!stored?.accessToken || !isMainAccountTokenLive()) {
494
+ throw new CodexMainSubstitutionUnavailableError();
495
+ }
496
+ selected.set("authorization", `Bearer ${stored.accessToken}`);
497
+ if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
498
+ return selected;
463
499
  }
464
500
  return selected;
465
501
  }
466
502
 
503
+ /** @deprecated Prefer materializeCodexUpstreamAuth; kept for call sites without admission context. */
504
+ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
505
+ return materializeCodexUpstreamAuth(headers, ctx);
506
+ }
507
+
467
508
  export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean {
468
509
  if (ctx.kind === "main") return true;
469
510
  if (ctx.kind === "main-pool") return isCodexAccountUsable(config, ctx.accountId);
@@ -99,16 +99,42 @@ export function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
99
99
  return /^(?:gpt|codex)-/.test(slug);
100
100
  }
101
101
 
102
- export const NATIVE_GPT56_CONTEXT_WINDOW = 372_000;
102
+ /**
103
+ * Advertised total context for the Codex-login native GPT-5.6 family.
104
+ *
105
+ * Measured on 2026-08-17 against a real Codex-login account, not taken from the live
106
+ * catalog: `GET /backend-api/codex/models` reports `context_window: 272000` /
107
+ * `max_context_window: 872000` for these slugs, yet a `POST /backend-api/codex/responses`
108
+ * probe admitted 921,508 input tokens and refused 922,013 with
109
+ * `error.code: context_length_exceeded` on sol, terra and luna alike. That boundary is
110
+ * exactly the 922,000 max-input the API-key side of this repo already declares
111
+ * (`src/providers/registry.ts`), and 1,050,000 = 922,000 input + 128,000 output.
112
+ * Evidence: devlog/_plan/260817_native_gpt56_1m_context/001_measurement_evidence.md.
113
+ */
114
+ export const NATIVE_GPT56_CONTEXT_WINDOW = 1_050_000;
115
+
116
+ /**
117
+ * Largest input the native GPT-5.6 family actually accepts (measured; see above).
118
+ *
119
+ * This is deliberately NOT `contextWindow * 0.9`: that would be 945,000, which the
120
+ * upstream refuses. Every derived limit — auto-compaction, admission ceilings, the
121
+ * Anthropic `max_input_tokens` surface — has to clamp to this instead.
122
+ */
123
+ export const NATIVE_GPT56_MAX_INPUT_TOKENS = 922_000;
103
124
 
104
- export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
125
+ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number; maxInputTokens?: number }> = {
105
126
  "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
106
127
  "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
107
128
  "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 },
108
- "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
109
- "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
110
- "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
111
- [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
129
+ "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
130
+ "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
131
+ "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
132
+ // Daybreak Blue borrows Sol's capability metadata and rides the same family contract.
133
+ // Unlike sol/terra/luna its window was NOT measured here: this account cannot reach it
134
+ // (`400 "The 'gpt-daybreak-blue-latest' model is not supported when using Codex with a
135
+ // ChatGPT account."`), so the promotion rests on a report from an account that has
136
+ // access rather than on a probe. Treat it as the weaker evidence of the four.
137
+ [NATIVE_DAYBREAK_BLUE_MODEL]: { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
112
138
  };
113
139
 
114
140
  const PINNED_UPSTREAM_MODELS: Map<string, RawEntry> = new Map(
@@ -140,6 +166,21 @@ export function nativeOpenAiContextWindow(slug: string, contextCap?: number): nu
140
166
  return applyProviderContextCap(raw, contextCap) ?? raw;
141
167
  }
142
168
 
169
+ /**
170
+ * Largest input a native slug accepts, or undefined when no separate limit is known
171
+ * (the caller then falls back to the context window).
172
+ *
173
+ * A provider context cap lowers this too: a capped 272k window must not keep advertising a
174
+ * 922k input ceiling, or the cap would be cosmetic on every input-side surface.
175
+ */
176
+ export function nativeOpenAiMaxInputTokens(slug: string, contextCap?: number): number | undefined {
177
+ const raw = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.maxInputTokens;
178
+ if (raw === undefined) return undefined;
179
+ const window = nativeOpenAiContextWindow(slug, contextCap);
180
+ const capped = applyProviderContextCap(raw, contextCap) ?? raw;
181
+ return window === undefined ? capped : Math.min(capped, window);
182
+ }
183
+
143
184
  export function nativeInputModalities(slug: string): string[] {
144
185
  const upstream = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug);
145
186
  if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) {
@@ -250,13 +291,19 @@ export function desktopVisibleNativeSlugs(
250
291
  ]);
251
292
  }
252
293
 
253
- export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
294
+ export function nativeModelRows(config: Pick<OcxConfig, "disabledModels" | "combos" | "providerContextCaps">): Array<{ slug: string; disabled: boolean; contextWindow?: number; maxInputTokens?: number }> {
254
295
  const disabled = disabledNativeSlugs(config);
255
296
  const shadowed = configuredNativeAliasSlugs(config);
256
297
  const openaiContextCap = providerContextCap(config, OPENAI_CODEX_PROVIDER_ID);
257
298
  return NATIVE_OPENAI_MODELS.filter(slug => !shadowed.has(slug)).map(slug => {
258
299
  const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap);
259
- return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) };
300
+ const maxInputTokens = nativeOpenAiMaxInputTokens(slug, openaiContextCap);
301
+ return {
302
+ slug,
303
+ disabled: disabled.has(slug),
304
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
305
+ ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
306
+ };
260
307
  });
261
308
  }
262
309
 
@@ -12,7 +12,19 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
12
12
  [NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol",
13
13
  });
14
14
 
15
- /** Account-scoped native ids that may inherit metadata but never enter the bare allowlist. */
15
+ /**
16
+ * Native ids whose capability metadata is inherited from another pinned native row.
17
+ *
18
+ * Membership here is about METADATA INHERITANCE only, and is independent of whether the
19
+ * slug is also globally allowlisted in `NATIVE_OPENAI_MODELS`. `gpt-daybreak-blue-latest`
20
+ * is now in BOTH: it inherits Sol's capability shape AND ships as a globally supported
21
+ * native row (owner decision, devlog 260816_codexrs_multiagent_v2_and_history_perf/011).
22
+ *
23
+ * The maps that consume the union of these two lists (`PINNED_NATIVE_CAPABILITY_ENTRIES`,
24
+ * `UPSTREAM_NATIVE_ENTRIES`) are keyed by slug, so an overlapping id collapses to one
25
+ * entry. Catalog row generation iterates `NATIVE_OPENAI_MODELS` alone, so it still emits
26
+ * exactly one bare row and one row per account selector.
27
+ */
16
28
  export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze(
17
29
  Object.keys(NATIVE_OPENAI_CAPABILITY_SOURCES),
18
30
  );
@@ -25,10 +37,28 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string {
25
37
  return NATIVE_OPENAI_CAPABILITY_SOURCES[slug] ?? slug;
26
38
  }
27
39
 
28
- /** Native OpenAI model ids that this release can route and restore with authoritative metadata. */
40
+ /**
41
+ * Native OpenAI model ids that this release can route and restore with authoritative metadata.
42
+ *
43
+ * `gpt-daybreak-blue-latest` is entitlement-gated upstream: it is absent from codex-rs's
44
+ * bundled catalog and reaches a client only through an authenticated `/models` response.
45
+ * It is listed here by explicit owner decision so the row exists without waiting for an
46
+ * observation, because opencodex injects `model_catalog_json` and codex-rs therefore builds
47
+ * a `StaticModelsManager` whose refresh is a no-op — an entitled account had no way to
48
+ * discover it on a clean install.
49
+ *
50
+ * Accepted tradeoff: an UNENTITLED account also sees the row. Catalog sync still succeeds;
51
+ * selecting the model reaches the canonical OpenAI provider and the backend answers 400
52
+ * "model not supported for this account", which is relayed (a bare pooled route may first
53
+ * retry one alternate account on that exact body; a selector-qualified route is fixed and
54
+ * relays immediately). `disabledModels` hides the row but is NOT a runtime routing denial.
55
+ *
56
+ * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
57
+ */
29
58
  export const NATIVE_OPENAI_MODELS = [
30
59
  "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
31
60
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
61
+ NATIVE_DAYBREAK_BLUE_MODEL,
32
62
  ];
33
63
 
34
64
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);