@bitkyc08/opencodex 2.61.0 → 2.63.0-preview.20260923

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 (80) hide show
  1. package/gui/dist/assets/App-EJxiUFMq.js +50 -0
  2. package/gui/dist/assets/{Tray-CLZh48fM.js → Tray-B03pW-Uf.js} +1 -1
  3. package/gui/dist/assets/index-BmJwNBHL.js +86 -0
  4. package/gui/dist/assets/index-DdDunwDb.css +1 -0
  5. package/gui/dist/assets/{usage-companion-chart-a0N58rRI.js → usage-companion-chart-IftE60UK.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/cursor/catalog.ts +15 -0
  9. package/src/adapters/cursor/effort-map.ts +7 -0
  10. package/src/adapters/cursor/envelope-echo.ts +51 -25
  11. package/src/adapters/cursor/protobuf-request.ts +39 -13
  12. package/src/adapters/cursor.ts +52 -4
  13. package/src/adapters/devin/cloud-direct/chat.ts +50 -16
  14. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +4 -1
  15. package/src/adapters/devin/live-models.ts +7 -0
  16. package/src/adapters/devin.ts +7 -5
  17. package/src/adapters/kiro/reasoning.ts +5 -0
  18. package/src/adapters/openai-responses/passthrough.ts +4 -4
  19. package/src/adapters/openai-responses/tool-output-recovery.ts +5 -3
  20. package/src/claude/desktop-gateway-state.ts +7 -2
  21. package/src/claude/desktop-policy.ts +105 -1
  22. package/src/cli/config-command.ts +22 -7
  23. package/src/cli/doctor.ts +14 -4
  24. package/src/codex/catalog/effort.ts +35 -4
  25. package/src/codex/catalog/metadata.ts +33 -5
  26. package/src/codex/catalog/native-models.ts +43 -2
  27. package/src/codex/catalog/pinned-models.ts +37 -0
  28. package/src/codex/catalog-auto-refresh.ts +6 -0
  29. package/src/codex/data/roster-pinned-models.json +359 -0
  30. package/src/codex/data/upstream-models.json +365 -271
  31. package/src/codex/inject/provider-table.ts +108 -0
  32. package/src/codex/inject/remove.ts +2 -114
  33. package/src/codex/model-entitlements.ts +14 -10
  34. package/src/codex/subagent-defaults.ts +2 -109
  35. package/src/codex/toml-source-lines.ts +112 -0
  36. package/src/config/live-reconcile.ts +145 -27
  37. package/src/config/load-degrade.ts +3 -4
  38. package/src/config.ts +2 -2
  39. package/src/generated/compatibility-version.json +83 -67
  40. package/src/generated/model-metadata.ts +5 -5
  41. package/src/lab/artifacts/sanitize.ts +60 -19
  42. package/src/lib/app-owned-memory-stores.ts +7 -0
  43. package/src/lib/app-owned-memory.ts +13 -2
  44. package/src/lib/errors.ts +6 -4
  45. package/src/lib/upstream-retry.ts +35 -5
  46. package/src/oauth/devin.ts +43 -37
  47. package/src/providers/codebuddy-models.ts +11 -0
  48. package/src/providers/kiro-models.ts +9 -0
  49. package/src/providers/quota/vendor-probes-oauth.ts +9 -2
  50. package/src/providers/registry/entries-core.ts +19 -8
  51. package/src/providers/registry/entries-extended.ts +4 -1
  52. package/src/providers/registry/model-seeds.ts +22 -2
  53. package/src/responses/bridge-search-replay-cache.ts +20 -10
  54. package/src/responses/plaintext-v2-agent-messages.ts +10 -1
  55. package/src/routing/identity-domains.ts +22 -15
  56. package/src/server/index/websocket-handler.ts +22 -2
  57. package/src/server/management/agent-settings-routes.ts +19 -10
  58. package/src/server/management/context.ts +4 -2
  59. package/src/server/responses/codex-ws-exchange.ts +24 -8
  60. package/src/server/responses/core-codex-account.ts +4 -0
  61. package/src/server/responses/core-combo-failure.ts +16 -9
  62. package/src/server/responses/core-combo.ts +7 -3
  63. package/src/server/responses/core-options.ts +6 -0
  64. package/src/server/responses/native-injection-replay.ts +13 -1
  65. package/src/server/responses/native-injection.ts +66 -7
  66. package/src/server/responses/native-response-control.ts +6 -2
  67. package/src/server/responses/native-steering-replay.ts +60 -0
  68. package/src/server/responses/native-steering.ts +9 -1
  69. package/src/server/responses/passthrough-delivery.ts +3 -4
  70. package/src/server/responses/passthrough-dispatch.ts +7 -0
  71. package/src/server/responses/request-prepare.ts +12 -0
  72. package/src/server/responses/request-transport.ts +5 -2
  73. package/src/server/responses/ws-upstream.ts +1 -1
  74. package/src/server/ws-bridge.ts +17 -1
  75. package/src/types/request.ts +5 -0
  76. package/src/usage/expected-prices.ts +38 -8
  77. package/src/web-search/executor.ts +38 -13
  78. package/gui/dist/assets/App-CH6C5H7x.js +0 -50
  79. package/gui/dist/assets/index-_bpvxJu0.css +0 -1
  80. package/gui/dist/assets/index-wpTOyepx.js +0 -86
@@ -21,6 +21,9 @@ export const DEVIN_STATIC_MODELS = [
21
21
  "gpt-5-6-sol",
22
22
  "gpt-5-6-luna",
23
23
  "gpt-5-6-terra",
24
+ // 260923 preemptive: GPT-6 Sol and Luna (OpenAI announced 2026-09-22) added ahead of this provider's own catalog; mirrors the GPT-5.6 Sol/Luna rows.
25
+ "gpt-6-sol",
26
+ "gpt-6-luna",
24
27
  "claude-opus-4-8",
25
28
  "claude-fable-5-1",
26
29
  "claude-sonnet-5",
@@ -55,7 +58,11 @@ export const DEVIN_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
55
58
  "gpt-5-6-luna": 1_000_000,
56
59
  "gpt-5-6-terra": 1_000_000,
57
60
  "gpt-6-astra": 1_000_000,
61
+ "gpt-6-sol": 1_000_000,
62
+ "gpt-6-luna": 1_000_000,
58
63
  "claude-opus-4-8": 1_000_000,
64
+ // 260923: read from the live catalog (devin/claude-opus-5-5 context_length 1_000_000).
65
+ "claude-opus-5-5": 1_000_000,
59
66
  "claude-opus-5": 1_000_000,
60
67
  "claude-fable-5-1": 1_000_000,
61
68
  "claude-sonnet-5": 1_000_000,
@@ -612,7 +612,7 @@ export function createDevinAdapter(
612
612
  // The signed-in account's tenant decides the host, not the static registry
613
613
  // entry: an EU or FedStart account that used provider.baseUrl would send
614
614
  // every RPC to the US server it is not provisioned on.
615
- const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId);
615
+ const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId, apiKey);
616
616
  // One catalog read per turn serves model-UID resolution, the input
617
617
  // ceiling, and the chat pre-flight inside streamChatEvents. Failures are
618
618
  // not cached, so a second read would only pay another fetch timeout on
@@ -642,10 +642,11 @@ export function createDevinAdapter(
642
642
  const maxOutputTokens = resolveDevinMaxOutputTokens(
643
643
  provider, modelUid, parsed.options.maxOutputTokens,
644
644
  );
645
- // The reset-retry wrapper waits out a 429 that states its own recovery
646
- // delay ("limit will reset in 35 seconds") and replays the identical
647
- // request — but only while zero events have been yielded, so a
648
- // post-output failure still takes the terminal path untouched.
645
+ // An admitted HTTP turn owns globally shared capacity until this call
646
+ // emits. Never retain that capacity while waiting out a provider 429:
647
+ // preserve the typed reset delay in generated diagnostic wording,
648
+ // never the raw trailer text that may reflect a credential. The
649
+ // refusal returns immediately so the caller can release its slot.
649
650
  for await (const event of streamChatEventsWithResetRetry({
650
651
  apiKey,
651
652
  apiServerUrl: host,
@@ -664,6 +665,7 @@ export function createDevinAdapter(
664
665
  },
665
666
  signal: incoming.abortSignal,
666
667
  }, {
668
+ maxWaitMs: 0,
667
669
  execution: {
668
670
  executor: incoming.providerFetch,
669
671
  sendBudget: incoming.sendBudget,
@@ -23,7 +23,12 @@ export const KIRO_NATIVE_EFFORT_FIELDS: Record<string, "reasoning" | "output_con
23
23
  "gpt-5.6-sol": "reasoning",
24
24
  "gpt-5.6-terra": "reasoning",
25
25
  "gpt-5.6-luna": "reasoning",
26
+ // 260923 preemptive (see kiro-models.ts): same native field as the GPT-5.6 family.
27
+ "gpt-6-sol": "reasoning",
28
+ "gpt-6-luna": "reasoning",
26
29
  "claude-opus-5": "output_config",
30
+ // 260923 preemptive (see kiro-models.ts): same Claude-specific field as Opus 5.
31
+ "claude-opus-5.5": "output_config",
27
32
  };
28
33
 
29
34
  export const KIRO_NATIVE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
@@ -329,11 +329,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
329
329
  outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
330
330
  // #4587: on a bridged provider, hand the destination back the search call and result the
331
331
  // proxy executed on its behalf, in place of the hosted cell the caller replays. Scoped to
332
- // this destination and recorded by the bridge itself, so a provider without the opt-in
333
- // computes no identity and keeps the body reference it already had. This runs before the
334
- // query backfill below because a restored cell is no longer a web_search_call to repair.
332
+ // its exact conversation and serving identity and recorded by the bridge itself, so a
333
+ // provider without the opt-in computes no identity and keeps the body reference it already
334
+ // had. This runs before query backfill because a restored cell is no longer one to repair.
335
335
  if (provider.webSearchBridge?.enabled === true) {
336
- outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(provider.baseUrl));
336
+ outBody = restoreBridgedWebSearchCalls(outBody, bridgeSearchReplayScope(parsed._reasoningReplayScope));
337
337
  }
338
338
  // Repair stored history from before the bridge emitted both keys, in either
339
339
  // direction: a conversation that already recorded a web_search_call replays it
@@ -289,9 +289,11 @@ export function backfillWebSearchQueries(body: unknown): unknown {
289
289
  * - It never restores a call id the body already carries. If the history somehow holds that
290
290
  * `function_call` too, emitting a second one would be a duplicate the upstream must reject.
291
291
  *
292
- * Entries are scoped to the upstream destination, so a history replayed against a different
293
- * provider cannot resurrect a call that provider never made. Callers pass `undefined` for any
294
- * provider without the bridge armed, and the common path then returns the original reference.
292
+ * Entries are scoped to the caller principal, conversation and exact serving identity, so a
293
+ * history replayed by another caller or against a different provider, model, destination or
294
+ * credential cannot resurrect a call that pairing never made. Callers pass `undefined` for any
295
+ * provider without the bridge armed and for a caller with no principal, and the common path then
296
+ * returns the original reference.
295
297
  */
296
298
  export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: string | undefined): unknown {
297
299
  if (destinationScope === undefined) return body;
@@ -1,4 +1,4 @@
1
- import { mutatePersistedConfig } from "../config";
1
+ import { adoptPersistedClaudeCode, mutatePersistedConfig } from "../config";
2
2
  import type { OcxConfig } from "../types";
3
3
  import { emptyDesktopProfile, type DesktopProfile } from "./desktop-profile";
4
4
 
@@ -30,9 +30,14 @@ export function persistCommittedDesktopGateway(
30
30
  try {
31
31
  const outcome = mutatePersistedConfig(current => {
32
32
  recordCommittedDesktopGateway(current, profile, fingerprint, appliedAt);
33
- return { changed: true, value: true };
33
+ return { changed: true, value: structuredClone(current.claudeCode) };
34
34
  });
35
35
  if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason };
36
+ adoptPersistedClaudeCode(snapshot, outcome.value);
37
+ // The mode/profile pair IS the committed transaction, not mergeable state.
38
+ // Without an armed baseline the three-way adopt cannot prove the live leaves
39
+ // unchanged and keeps a stale live desktopMode over the bytes just saved, so
40
+ // pin both leaves to the committed subtree after the disjoint-leaf merge.
36
41
  recordCommittedDesktopGateway(snapshot, profile, fingerprint, appliedAt);
37
42
  return { ok: true };
38
43
  } catch {
@@ -1,5 +1,5 @@
1
1
  /** Read-only, privacy-safe Windows policy diagnosis for Claude Desktop 3P. */
2
- import { spawnSync } from "node:child_process";
2
+ import { execFile, spawnSync } from "node:child_process";
3
3
  import { win32 } from "node:path";
4
4
  import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation";
5
5
  import { decodeWindowsTextBytes } from "../lib/windows-text";
@@ -23,6 +23,11 @@ export type ClaudeDesktopPolicyProbeRunner = (
23
23
  args: readonly string[],
24
24
  ) => ClaudeDesktopPolicyProbeResult;
25
25
 
26
+ export type ClaudeDesktopPolicyAsyncProbeRunner = (
27
+ file: string,
28
+ args: readonly string[],
29
+ ) => Promise<ClaudeDesktopPolicyProbeResult>;
30
+
26
31
  export interface ClaudeDesktopPolicyProbeOptions {
27
32
  readonly platform?: NodeJS.Platform;
28
33
  readonly run?: ClaudeDesktopPolicyProbeRunner;
@@ -54,6 +59,38 @@ const defaultPolicyProbeRunner: ClaudeDesktopPolicyProbeRunner = (file, args) =>
54
59
  };
55
60
  };
56
61
 
62
+ /**
63
+ * Translates an `execFile` callback into the probe contract. Exit codes arrive
64
+ * as numeric `error.code`; spawn failures carry a string errno; a timeout kill
65
+ * surfaces as `killed`/`SIGTERM` rather than `ETIMEDOUT`. A killed child is a
66
+ * TIMEOUT, never a spawn failure — the process started fine and ran out of time,
67
+ * so `spawnFailed` stays false or diagnostics conflate "did not start" with
68
+ * "ran too long".
69
+ */
70
+ export function classifyExecFileProbeResult(
71
+ error: (Error & { readonly code?: number | string; readonly killed?: boolean }) | null,
72
+ stdout: Uint8Array | undefined,
73
+ ): ClaudeDesktopPolicyProbeResult {
74
+ const errorCode = error?.code;
75
+ return {
76
+ status: error === null ? 0 : typeof errorCode === "number" ? errorCode : null,
77
+ stdout: stdout === undefined ? "" : decodeWindowsTextBytes(stdout),
78
+ timedOut: errorCode === "ETIMEDOUT" || error?.killed === true,
79
+ spawnFailed: error !== null && error.killed !== true && typeof errorCode !== "number" && errorCode !== "ETIMEDOUT",
80
+ };
81
+ }
82
+
83
+ const defaultAsyncPolicyProbeRunner: ClaudeDesktopPolicyAsyncProbeRunner = (file, args) => new Promise((resolve) => {
84
+ execFile(file, [...args], {
85
+ encoding: "buffer",
86
+ maxBuffer: 64 * 1024,
87
+ timeout: POLICY_PROBE_TIMEOUT_MS,
88
+ windowsHide: true,
89
+ }, (error, stdout) => {
90
+ resolve(classifyExecFileProbeResult(error, stdout));
91
+ });
92
+ });
93
+
57
94
  function usable(result: ClaudeDesktopPolicyProbeResult): boolean {
58
95
  return !result.timedOut && !result.spawnFailed && result.status !== null;
59
96
  }
@@ -108,6 +145,73 @@ export function probeClaudeDesktopPolicy(
108
145
  return parentListsPolicyKey(parent.stdout) ? "unknown" : "absent";
109
146
  }
110
147
 
148
+ /** Non-blocking variant for the long-lived server request path. */
149
+ export async function probeClaudeDesktopPolicyAsync(
150
+ options: Omit<ClaudeDesktopPolicyProbeOptions, "run"> & { readonly run?: ClaudeDesktopPolicyAsyncProbeRunner } = {},
151
+ ): Promise<ClaudeDesktopPolicyState> {
152
+ const platform = options.platform ?? process.platform;
153
+ if (platform !== "win32") return "not_applicable";
154
+
155
+ let regExe: string;
156
+ try {
157
+ const systemDirectory = (options.resolveSystemDirectory ?? resolveTrustedWindowsSystemDirectory)();
158
+ regExe = win32.join(systemDirectory, "reg.exe");
159
+ } catch {
160
+ return "unknown";
161
+ }
162
+
163
+ const run = options.run ?? defaultAsyncPolicyProbeRunner;
164
+ try {
165
+ const policy = await run(regExe, ["query", CLAUDE_POLICY_KEY, "/reg:64"]);
166
+ if (!usable(policy)) return "unknown";
167
+ if (policy.status === 0) return "present";
168
+ if (policy.status !== 1) return "unknown";
169
+
170
+ const parent = await run(regExe, ["query", CLAUDE_POLICY_PARENT_KEY, "/reg:64"]);
171
+ if (!usable(parent) || parent.status !== 0) return "unknown";
172
+ return parentListsPolicyKey(parent.stdout) ? "unknown" : "absent";
173
+ } catch {
174
+ return "unknown";
175
+ }
176
+ }
177
+
178
+ const POLICY_CACHE_TTL_MS = 30_000;
179
+
180
+ export function createCachedClaudeDesktopPolicyProbe(
181
+ probe: () => Promise<ClaudeDesktopPolicyState>,
182
+ ttlMs = POLICY_CACHE_TTL_MS,
183
+ now = performance.now,
184
+ ): () => Promise<ClaudeDesktopPolicyState> {
185
+ let cached: { state: ClaudeDesktopPolicyState; expiresAt: number } | undefined;
186
+ let refresh: Promise<ClaudeDesktopPolicyState> | undefined;
187
+ return () => {
188
+ const currentTime = now();
189
+ if (cached && cached.expiresAt > currentTime) return Promise.resolve(cached.state);
190
+ if (refresh) return refresh;
191
+ refresh = probe().then((state) => {
192
+ cached = { state, expiresAt: now() + ttlMs };
193
+ return state;
194
+ }).finally(() => {
195
+ refresh = undefined;
196
+ });
197
+ return refresh;
198
+ };
199
+ }
200
+
201
+ const cachedProductionProbe = createCachedClaudeDesktopPolicyProbe(
202
+ () => probeClaudeDesktopPolicyAsync(),
203
+ );
204
+
205
+ /** Coalesces status polling and bounds registry refreshes to one per cache interval. */
206
+ export function getCachedClaudeDesktopPolicy(
207
+ options: Omit<ClaudeDesktopPolicyProbeOptions, "run"> = {},
208
+ ): Promise<ClaudeDesktopPolicyState> {
209
+ const cacheable = options.resolveSystemDirectory === undefined
210
+ && (options.platform === undefined || options.platform === process.platform);
211
+ if (cacheable) return cachedProductionProbe();
212
+ return probeClaudeDesktopPolicyAsync(options);
213
+ }
214
+
111
215
  /** State-only health projection shared by CLI, apply, and management status. */
112
216
  export function claudeDesktopPolicyHealth(
113
217
  state: ClaudeDesktopPolicyState,
@@ -5,6 +5,7 @@ import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-
5
5
  import type { OcxConfig } from "../types";
6
6
  import { normalizeVisionReasoningForModel } from "../vision/reasoning";
7
7
  import type { ServiceApiTokenState } from "../lib/service-secrets";
8
+ import { redactUrlForLog } from "../lib/redact";
8
9
  import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
9
10
 
10
11
  const USAGE = `Usage:
@@ -17,12 +18,13 @@ const USAGE = `Usage:
17
18
  ocx config import <path|-> --yes [--json]`;
18
19
 
19
20
  /**
20
- * Keys whose VALUE is a credential and must never be printed or exported.
21
+ * Keys whose VALUE is a credential and must never be printed by display commands.
22
+ * `config export` writes the raw config so an export can restore credentials; it does
23
+ * not call `redact`.
21
24
  *
22
- * `webhookUrl` is here because for Slack and Discord the URL itself is the authorization:
23
- * anyone holding it can post to the channel. It looks like configuration rather than a secret,
24
- * which is exactly why it needs to be named explicitly — none of the other patterns match it,
25
- * so `ocx config show` printed it and `config export` wrote it to disk in the clear.
25
+ * URL-valued credentials must be named explicitly: `webhookUrl` matches none of the
26
+ * other patterns, and a proxy URL's userinfo is handled by the `proxy` branch in
27
+ * `redact` rather than by masking the whole value.
26
28
  */
27
29
  const SECRET_KEYS = /^(apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret|webhookUrl)$/i;
28
30
  const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
@@ -97,6 +99,19 @@ async function readRemoteHubConfigNote(config: OcxConfig): Promise<ReturnType<ty
97
99
  }
98
100
 
99
101
  function redact(value: unknown, key = ""): unknown {
102
+ if (key === "proxy" && typeof value === "string") {
103
+ // "direct" and credential-less proxy URLs carry no secret and stay readable; only a
104
+ // URL with userinfo is masked, and then only its credentials — host and port stay
105
+ // visible so the output still says WHERE traffic goes. A non-URL value that is not
106
+ // "direct" cannot be proven credential-free, so it is masked whole.
107
+ if (!value || value === "direct") return value;
108
+ try {
109
+ const parsed = new URL(value);
110
+ return parsed.username || parsed.password ? redactUrlForLog(value) : value;
111
+ } catch {
112
+ return "********";
113
+ }
114
+ }
100
115
  if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
101
116
  // `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
102
117
  // local one — up to 64 MB of it (src/config.ts). Printed in full it buried `runtimeRole` and
@@ -211,7 +226,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
211
226
  const path = args.shift();
212
227
  if (!path) throw new CliUsageError("config path is required", USAGE);
213
228
  rejectArgs(args, USAGE);
214
- const value = redact(getPath(readConfigDiagnostics().config, path), path.split(".").at(-1));
229
+ const value = redact(getPath(readConfigDiagnostics().config, path), pathSegments(path).at(-1));
215
230
  if (wantsJson || typeof value === "object") console.log(JSON.stringify(value, null, 2));
216
231
  else console.log(String(value));
217
232
  return;
@@ -257,7 +272,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
257
272
  ? "config changed while applying this update; retry"
258
273
  : `config is ${outcome.reason}`);
259
274
  }
260
- printData({ ok: true, path, value: redact(savedValue, path.split(".").at(-1)) }, wantsJson,
275
+ printData({ ok: true, path, value: redact(savedValue, pathSegments(path).at(-1)) }, wantsJson,
261
276
  [`${action === "unset" ? "Unset" : "Set"} ${path}.`]);
262
277
  return;
263
278
  }
package/src/cli/doctor.ts CHANGED
@@ -14,6 +14,7 @@ import { getConfigDir, getConfigPath, readConfigDiagnostics } from "../config";
14
14
  import { readPid } from "../config/process-state";
15
15
  import { probeUncleanExitState } from "./status";
16
16
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
17
+ import { directLocalHttpFetch } from "../server/direct-local-http";
17
18
  import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime";
18
19
  import type { BunRuntimeSource } from "../lib/bun-runtime";
19
20
  import { maskAccountId } from "../lib/privacy";
@@ -1073,12 +1074,15 @@ export interface DefaultModelExposure {
1073
1074
 
1074
1075
  /** Exactly the catalog's own `RawEntry` shape, so an on-disk row needs no conversion. */
1075
1076
  type CatalogVisibilityRow = Record<string, unknown>;
1077
+ type ExposedModelsFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
1078
+ const EXPOSED_MODELS_MAX_ROWS = 10_000;
1079
+ const EXPOSED_MODEL_ID_MAX_LENGTH = 1_024;
1076
1080
 
1077
1081
  export interface DefaultModelExposureDeps {
1078
1082
  readConfiguredModelFn?: () => string | null;
1079
1083
  /** The live proxy doctor already resolved, or null/absent when none is running. */
1080
1084
  live?: LiveProxy | null;
1081
- fetchFn?: typeof fetch;
1085
+ fetchFn?: ExposedModelsFetch;
1082
1086
  readCatalogModelsFn?: () => readonly CatalogVisibilityRow[] | null;
1083
1087
  }
1084
1088
 
@@ -1091,18 +1095,24 @@ export interface DefaultModelExposureDeps {
1091
1095
  * data-plane admission on a non-loopback bind (`isApiAuthRequired`), and doctor deliberately
1092
1096
  * holds no data-plane key, so a remote-bound proxy always falls through to the catalog.
1093
1097
  */
1094
- async function fetchExposedModelIds(live: LiveProxy, fetchFn: typeof fetch): Promise<Set<string> | null> {
1098
+ async function fetchExposedModelIds(live: LiveProxy, fetchFn: ExposedModelsFetch): Promise<Set<string> | null> {
1095
1099
  try {
1100
+ // directLocalHttpFetch never follows redirects and aborts past its byte cap, so the
1101
+ // unbounded-body and redirect cases are covered below the JSON parse, not by options here.
1096
1102
  const res = await fetchFn(`http://${probeHostname(live.hostname)}:${live.port}/v1/models`, {
1097
1103
  signal: AbortSignal.timeout(EXPOSED_MODELS_TIMEOUT_MS),
1098
1104
  });
1099
1105
  if (!res.ok) return null;
1100
1106
  const body = await res.json() as { data?: unknown };
1101
1107
  if (!Array.isArray(body?.data)) return null;
1108
+ if (body.data.length > EXPOSED_MODELS_MAX_ROWS) return null;
1102
1109
  const ids = new Set<string>();
1103
1110
  for (const row of body.data) {
1104
1111
  const id = (row as { id?: unknown } | null)?.id;
1105
- if (typeof id === "string" && id.length > 0) ids.add(id);
1112
+ if (typeof id !== "string") return null;
1113
+ if (id.length === 0) continue;
1114
+ if (id.length > EXPOSED_MODEL_ID_MAX_LENGTH) return null;
1115
+ ids.add(id);
1106
1116
  }
1107
1117
  return ids;
1108
1118
  } catch {
@@ -1158,7 +1168,7 @@ export async function collectDefaultModelExposure(
1158
1168
  }
1159
1169
 
1160
1170
  const live = deps.live ?? null;
1161
- const proxyIds = live ? await fetchExposedModelIds(live, deps.fetchFn ?? fetch) : null;
1171
+ const proxyIds = live ? await fetchExposedModelIds(live, deps.fetchFn ?? directLocalHttpFetch) : null;
1162
1172
  const catalogIds = catalogExposedModelIds((deps.readCatalogModelsFn ?? defaultCatalogModels)());
1163
1173
  if (proxyIds === null && catalogIds === null) {
1164
1174
  return {
@@ -277,11 +277,38 @@ export function applyReasoningLevels(
277
277
  * accident disappeared, and the sync path's else-branch
278
278
  * (`applyReasoningLevels(entry, ["low","medium","high","xhigh"])`) would have truncated the
279
279
  * shipped ladder, silently dropping `max` and `ultra`.
280
+ *
281
+ * Membership means "new-ladder native: keep the pinned ladder, never synthesize old-ladder top
282
+ * rungs" — NOT "advertise ultra". Returning false for a self-described row would be worse, not
283
+ * safer: every false branch (`finishUpstreamNativeEntry`, the persisted-row path in
284
+ * build-entries) calls `ensureUltraReasoningLevel`, which would hand `ultra` to `gpt-6-luna`.
285
+ * Whether `ultra` is added is decided separately by `nativeLadderIncludesUltra`.
286
+ *
287
+ * A capability alias of a self-described row qualifies through its source (`gpt-6-astra-minor`
288
+ * borrows `gpt-6-astra`), the same way Daybreak Blue qualifies through `gpt-5.6-sol`.
280
289
  */
281
290
  export function isGpt56NativeSlug(slug: string): boolean {
282
291
  if (slug.includes("/")) return false;
283
- if (SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) return true;
284
- return nativeOpenAiCapabilitySourceSlug(slug).startsWith("gpt-5.6-");
292
+ const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug);
293
+ if (SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(sourceSlug)) return true;
294
+ return sourceSlug.startsWith("gpt-5.6-");
295
+ }
296
+
297
+ /**
298
+ * Whether a new-ladder native may advertise `ultra`.
299
+ *
300
+ * GPT-5.6 keeps its historical behaviour (always advertised; the wire clamp maps it down). A
301
+ * self-described GPT-6 row answers from its OWN pinned ladder, or its source's for an alias:
302
+ * the 2026-09-23 roster probe (`/backend-api/codex/models?client_version=0.155.0`) ships
303
+ * `gpt-6-sol` with low..ultra but `gpt-6-luna` with low..max, and advertising a rung upstream
304
+ * never listed would let a subagent spawn request an effort the model does not have.
305
+ */
306
+ export function nativeLadderIncludesUltra(slug: string): boolean {
307
+ const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug);
308
+ if (!SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(sourceSlug)) return true;
309
+ const levels = UPSTREAM_NATIVE_ENTRIES.get(sourceSlug)?.supported_reasoning_levels;
310
+ return Array.isArray(levels)
311
+ && (levels as Array<{ effort?: string }>).some(level => level?.effort === "ultra");
285
312
  }
286
313
 
287
314
  export function ensureGpt56ReasoningLevels(entry: RawEntry): void {
@@ -289,8 +316,12 @@ export function ensureGpt56ReasoningLevels(entry: RawEntry): void {
289
316
  ? entry.supported_reasoning_levels as Array<Partial<CodexReasoningLevel>>
290
317
  : [];
291
318
  const out = [...levels];
292
- // max is a real native rung on the 5.6 family — always restored; ultra always advertised.
293
- for (const effort of ["max", "ultra"]) {
319
+ // max is a real native rung on the 5.6 family — always restored. ultra is advertised unless
320
+ // the slug's pinned ladder (or its source's) stops short of it, as gpt-6-luna's does.
321
+ const wanted = typeof entry.slug === "string" && !nativeLadderIncludesUltra(entry.slug)
322
+ ? ["max"]
323
+ : ["max", "ultra"];
324
+ for (const effort of wanted) {
294
325
  if (out.some(level => level.effort === effort)) continue;
295
326
  out.push(CODEX_REASONING_LEVELS.find(level => level.effort === effort)
296
327
  ?? { effort, description: `${effort} reasoning` });
@@ -31,7 +31,7 @@ import {
31
31
  import type { NormalizedComboConfig } from "../../combos/types";
32
32
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
33
33
  import { redactSecretString } from "../../lib/redact";
34
- import upstreamModelsSnapshot from "../data/upstream-models.json";
34
+ import { pinnedNativeModelRows } from "./pinned-models";
35
35
 
36
36
 
37
37
  import type { RawEntry } from "./parsing";
@@ -42,7 +42,10 @@ import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve";
42
42
  import {
43
43
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
44
44
  NATIVE_DAYBREAK_BLUE_MODEL,
45
+ NATIVE_GPT6_ASTRA_MINOR_MODEL,
45
46
  NATIVE_GPT6_ASTRA_MODEL,
47
+ NATIVE_GPT6_LUNA_MODEL,
48
+ NATIVE_GPT6_SOL_MODEL,
46
49
  NATIVE_RESERVE_MODEL,
47
50
  NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS,
48
51
  NATIVE_OPENAI_MODELS,
@@ -59,7 +62,10 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
59
62
  export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
60
63
  export {
61
64
  NATIVE_DAYBREAK_BLUE_MODEL,
65
+ NATIVE_GPT6_ASTRA_MINOR_MODEL,
62
66
  NATIVE_GPT6_ASTRA_MODEL,
67
+ NATIVE_GPT6_LUNA_MODEL,
68
+ NATIVE_GPT6_SOL_MODEL,
63
69
  NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS,
64
70
  NATIVE_OPENAI_MODELS,
65
71
  SELF_DESCRIBED_NATIVE_OPENAI_MODELS,
@@ -75,6 +81,10 @@ export const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [
75
81
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
76
82
  // The shipped pin also backfills older installed Codex catalogs that predate Astra.
77
83
  NATIVE_GPT6_ASTRA_MODEL,
84
+ // Same backfill for Sol and Luna from the roster pin: the live roster serves them only to
85
+ // client_version >= 0.155.0, so an installed catalog built by an older client lacks them.
86
+ // Astra Minor is deliberately absent: it is gated, and nativeOpenAiSlugs() would drop it anyway.
87
+ NATIVE_GPT6_SOL_MODEL, NATIVE_GPT6_LUNA_MODEL,
78
88
  ];
79
89
 
80
90
  export function configuredNativeAliasSlugs(
@@ -177,11 +187,24 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: n
177
187
  // maxInputTokens is clamped to the resolved window by nativeOpenAiMaxInputTokens, so this reads
178
188
  // 272,000 by default and 872,000 only under the long-window opt-in.
179
189
  [NATIVE_GPT6_ASTRA_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
190
+ // Sol and Luna ship the same 272,000 / 872,000 pair in their roster rows (probe of
191
+ // /backend-api/codex/models?client_version=0.155.0, 2026-09-23). Unmeasured here, so they take
192
+ // the row's own ceiling rather than the GPT-5.6 family's measured 922,000.
193
+ [NATIVE_GPT6_SOL_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
194
+ [NATIVE_GPT6_LUNA_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
195
+ // Astra Minor borrows Astra's row, so it inherits Astra's numbers. No account we hold can reach
196
+ // it, so this is inheritance, not a measurement.
197
+ [NATIVE_GPT6_ASTRA_MINOR_MODEL]: { contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 },
180
198
  };
181
199
 
200
+ // Snapshot rows plus roster-captured rows the snapshot lacks (see pinned-models.ts).
182
201
  const PINNED_UPSTREAM_MODELS: Map<string, RawEntry> = new Map(
183
- ((upstreamModelsSnapshot as unknown as { models?: RawEntry[] }).models ?? [])
184
- .flatMap(model => typeof model.slug === "string" ? [[model.slug, model] as const] : []),
202
+ (pinnedNativeModelRows() as unknown as ReadonlyArray<RawEntry>)
203
+ // Upstream stopped shipping top-level `base_instructions` (openai/codex #43604); every row
204
+ // still carries `model_messages.instructions_template`. Derive at projection time so the
205
+ // pinned JSON stays byte-identical to upstream while `hasNativeCatalogRowShape` and the alias
206
+ // rewrite in `upstreamNativeEntryForSlug` keep seeing the field they test for.
207
+ .flatMap(model => typeof model.slug === "string" ? [[model.slug, withDerivedBaseInstructions(model)] as const] : []),
185
208
  );
186
209
 
187
210
  function pinnedNativeCapabilityEntry(slug: string): RawEntry | undefined {
@@ -536,14 +559,19 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined {
536
559
  // reserved for slugs that genuinely borrow another model's identity. The allowlist is explicit
537
560
  // rather than "has a pinned entry", which would also admit gpt-5.5/gpt-5.2/codex-auto-review into
538
561
  // the sync-replacement authority this map carries.
539
- if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) {
562
+ // Keyed on the SOURCE so an alias of a self-described row (gpt-6-astra-minor -> gpt-6-astra)
563
+ // is admitted the same way an alias of a GPT-5.6 row (Daybreak -> Sol) always was; for a
564
+ // self-described slug itself the source is the slug, so nothing else changes.
565
+ if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(sourceSlug)) {
540
566
  return undefined;
541
567
  }
542
568
  const source = PINNED_UPSTREAM_MODELS.get(sourceSlug);
543
569
  if (!source) return undefined;
544
570
  if (slug === sourceSlug) return withDerivedBaseInstructions(source);
545
571
 
546
- const alias = structuredClone(source) as RawEntry;
572
+ // Derive before cloning: Astra ships only model_messages, and an alias row without
573
+ // base_instructions fails the native row-shape checks just as its source would.
574
+ const alias = structuredClone(withDerivedBaseInstructions(source)) as RawEntry;
547
575
  alias.slug = slug;
548
576
  const presentation = nativeOpenAiAliasPresentation(slug);
549
577
  if (!presentation) return undefined; // an alias with no product identity must not ship a wrong one
@@ -22,6 +22,32 @@ export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest";
22
22
  */
23
23
  export const NATIVE_GPT6_ASTRA_MODEL = "gpt-6-astra";
24
24
 
25
+ /**
26
+ * GPT-6 Sol and Luna, announced 2026-09-22 (https://openai.com/index/introducing-gpt-6-sol-and-luna/).
27
+ *
28
+ * SELF-DESCRIBED: the authenticated roster probe on 2026-09-23
29
+ * (`/backend-api/codex/models?client_version=0.155.0`, main account) returned a full row for each,
30
+ * pinned verbatim in `src/codex/data/roster-pinned-models.json` because codex-rs has not bundled
31
+ * them yet. Sol ships low..ultra; Luna ships low..max and must not be widened to ultra.
32
+ *
33
+ * Not account-gated, for the same owner decision that ungated `gpt-6-astra`: the rows list 24
34
+ * plans, and hiding a flagship until a roster confirms it reads as opencodex losing the model.
35
+ * Listing them means the request dispatches and the user sees the real upstream status.
36
+ */
37
+ export const NATIVE_GPT6_SOL_MODEL = "gpt-6-sol";
38
+ export const NATIVE_GPT6_LUNA_MODEL = "gpt-6-luna";
39
+
40
+ /**
41
+ * Unreleased GPT-6 Astra variant. No public row exists anywhere — neither the codex-rs bundle nor
42
+ * the 2026-09-23 main-account roster probe carries it — so it is ACCOUNT-GATED: hidden and
43
+ * request-refused until an authenticated `/models` roster lists it for that account. Absence is
44
+ * the only signal that exists for it, which is exactly the Daybreak Blue situation.
45
+ *
46
+ * Capability metadata is borrowed from `gpt-6-astra` (a capability alias); presentation is its
47
+ * own. No minimum client version is recorded for it: none has been measured.
48
+ */
49
+ export const NATIVE_GPT6_ASTRA_MINOR_MODEL = "gpt-6-astra-minor";
50
+
25
51
  /**
26
52
  * Native ChatGPT/Codex ids whose availability is proven per authenticated account.
27
53
  *
@@ -49,6 +75,8 @@ export const NATIVE_GPT6_ASTRA_MODEL = "gpt-6-astra";
49
75
  */
50
76
  export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
51
77
  NATIVE_DAYBREAK_BLUE_MODEL,
78
+ // Same footing as Daybreak: no shipped row, so absence is the only evidence available.
79
+ NATIVE_GPT6_ASTRA_MINOR_MODEL,
52
80
  ]);
53
81
 
54
82
  /**
@@ -65,6 +93,7 @@ export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
65
93
  */
66
94
  const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Object.freeze({
67
95
  [NATIVE_DAYBREAK_BLUE_MODEL]: "gpt-5.6-sol",
96
+ [NATIVE_GPT6_ASTRA_MINOR_MODEL]: NATIVE_GPT6_ASTRA_MODEL,
68
97
  });
69
98
 
70
99
  /**
@@ -72,14 +101,17 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
72
101
  *
73
102
  * Membership authorizes `upstreamNativeEntryForSlug` to return the pinned entry directly. It is
74
103
  * an explicit list, not a structural `PINNED_UPSTREAM_MODELS.has(slug)` predicate: the pin also
75
- * holds `gpt-5.5`, `gpt-5.2` and `codex-auto-review`, and admitting those into
104
+ * holds `gpt-5.5`, `codex-auto-review` and the Daybreak rows, and admitting those into
76
105
  * `UPSTREAM_NATIVE_ENTRIES` would newly authorize replacing their persisted catalog rows during
77
106
  * sync — an invariant that map's own comment reserves for the GPT-5.6 family. The snapshot
78
107
  * keeps rows this runtime does not expose, which is exactly why presence in the pin cannot be
79
- * the predicate: `gpt-5.4` and `gpt-5.4-mini` are still pinned after their retirement.
108
+ * the predicate: `gpt-5.4` is still pinned (hidden, with an upgrade to Terra) after its retirement.
80
109
  */
81
110
  export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
82
111
  NATIVE_GPT6_ASTRA_MODEL,
112
+ // Rows come from roster-pinned-models.json via pinnedNativeModelRows(), not the codex-rs pin.
113
+ NATIVE_GPT6_SOL_MODEL,
114
+ NATIVE_GPT6_LUNA_MODEL,
83
115
  ]);
84
116
 
85
117
  /**
@@ -132,6 +164,10 @@ export const NATIVE_OPENAI_ALIAS_PRESENTATION: Readonly<Record<string, { display
132
164
  displayName: "Daybreak Blue",
133
165
  description: "Frontier general-purpose model with safeguards for defensive cybersecurity work.",
134
166
  },
167
+ [NATIVE_GPT6_ASTRA_MINOR_MODEL]: {
168
+ displayName: "GPT-6-Astra-Minor",
169
+ description: "Unreleased GPT-6 Astra variant; shown only when your account's Codex roster lists it.",
170
+ },
135
171
  });
136
172
 
137
173
  export function nativeOpenAiAliasPresentation(slug: string): { displayName: string; description: string } | undefined {
@@ -159,6 +195,8 @@ export const NATIVE_OPENAI_MODELS = [
159
195
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
160
196
  NATIVE_DAYBREAK_BLUE_MODEL,
161
197
  NATIVE_GPT6_ASTRA_MODEL,
198
+ NATIVE_GPT6_SOL_MODEL, NATIVE_GPT6_LUNA_MODEL,
199
+ NATIVE_GPT6_ASTRA_MINOR_MODEL,
162
200
  ];
163
201
 
164
202
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
@@ -204,4 +242,7 @@ export const NATIVE_MAIN_DRAIN_SENTINEL_MODELS: ReadonlySet<string> = new Set([
204
242
  "gpt-5.6-terra",
205
243
  "gpt-5.6-luna",
206
244
  NATIVE_GPT6_ASTRA_MODEL,
245
+ // Astra Minor arrives through the gated spread above; Sol and Luna are ungated flagships.
246
+ NATIVE_GPT6_SOL_MODEL,
247
+ NATIVE_GPT6_LUNA_MODEL,
207
248
  ]);