@bitkyc08/opencodex 2.44.0 → 2.45.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 (38) hide show
  1. package/gui/dist/assets/index-CCfD72yq.js +115 -0
  2. package/gui/dist/assets/index-J96sug5C.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/openai-responses.ts +14 -0
  6. package/src/chat/outbound.ts +286 -35
  7. package/src/claude/compatibility.ts +192 -0
  8. package/src/claude/model-info.ts +14 -2
  9. package/src/cli/account-extended.ts +24 -1
  10. package/src/cli/init.ts +70 -13
  11. package/src/codex/catalog/provider-fetch.ts +38 -9
  12. package/src/codex/catalog/sync.ts +34 -2
  13. package/src/codex/catalog.ts +1 -1
  14. package/src/config/initialize.ts +132 -0
  15. package/src/config/rebase-provenance.ts +26 -0
  16. package/src/config.ts +50 -1
  17. package/src/generated/compatibility-version.json +41 -29
  18. package/src/lib/windows-secret-acl.ts +8 -4
  19. package/src/providers/quota.ts +26 -15
  20. package/src/responses/state.ts +48 -3
  21. package/src/server/chat-completions.ts +32 -36
  22. package/src/server/chat-native-sse.ts +23 -3
  23. package/src/server/chat-native.ts +15 -8
  24. package/src/server/claude-messages.ts +27 -0
  25. package/src/server/index.ts +5 -1
  26. package/src/server/management/agent-settings-routes.ts +101 -14
  27. package/src/server/management/logs-usage-routes.ts +9 -2
  28. package/src/server/request-log-cursor.ts +84 -0
  29. package/src/server/request-log.ts +46 -3
  30. package/src/server/responses/agent-task-recovery.ts +50 -14
  31. package/src/server/responses/codex-ws-exchange.ts +79 -0
  32. package/src/server/responses/compact.ts +39 -33
  33. package/src/server/responses/core.ts +43 -27
  34. package/src/storage/cleanup.ts +49 -35
  35. package/src/types/config.ts +4 -0
  36. package/src/usage/log.ts +22 -0
  37. package/gui/dist/assets/index-B7_K1Hsj.js +0 -115
  38. package/gui/dist/assets/index-ltx3L-WS.css +0 -1
@@ -0,0 +1,192 @@
1
+ /** Opt-in admission for the translated Messages path; no adapter or credential state. */
2
+ export type ClaudeCompatibilityMode = "shadow" | "enforce";
3
+
4
+ // False means deliberately tolerated degradation, not lossless representation.
5
+ const FEATURES = {
6
+ cache_control: false,
7
+ input_examples: false,
8
+ thinking_settings: false,
9
+ unknown_beta: false,
10
+ thinking_replay: true,
11
+ documents: true,
12
+ web_search_tool: true,
13
+ tool_search: true,
14
+ tool_reference: true,
15
+ deferred_tools: true,
16
+ strict_tools: true,
17
+ caller_mode: true,
18
+ structured_output: true,
19
+ service_tier: true,
20
+ mcp_tool: true,
21
+ code_execution: true,
22
+ computer_use: true,
23
+ server_tool: true,
24
+ context_management: true,
25
+ container: true,
26
+ inference_geo: true,
27
+ user_profile: true,
28
+ unknown_body_field: true,
29
+ unknown_content_block: true,
30
+ } as const;
31
+
32
+ export type ClaudeFeatureCode = keyof typeof FEATURES;
33
+ const FEATURE_CODES = Object.keys(FEATURES) as ClaudeFeatureCode[];
34
+ const MAX_FEATURE_CODES = 32;
35
+ const MAX_REASON_LENGTH = 512;
36
+ type Rec = Record<string, unknown>;
37
+ const isRec = (value: unknown): value is Rec =>
38
+ value !== null && typeof value === "object" && !Array.isArray(value);
39
+
40
+ export function isClaudeCompatibilityMode(value: unknown): value is ClaudeCompatibilityMode {
41
+ return value === "shadow" || value === "enforce";
42
+ }
43
+
44
+ /** Project only closed codes, including when reading an untrusted persisted row. */
45
+ export function normalizeClaudeFeatureCodes(value: unknown): ClaudeFeatureCode[] {
46
+ if (!Array.isArray(value)) return [];
47
+ const codes = new Set<ClaudeFeatureCode>();
48
+ for (const code of value) {
49
+ if (typeof code === "string" && Object.hasOwn(FEATURES, code)) codes.add(code as ClaudeFeatureCode);
50
+ }
51
+ return FEATURE_CODES.filter(code => codes.has(code)).sort().slice(0, MAX_FEATURE_CODES);
52
+ }
53
+
54
+ /** Never accept a caller-supplied reason, header value, model name or tool name. */
55
+ export function claudeCompatibilityReason(codes: readonly ClaudeFeatureCode[], shadow: boolean): string | undefined {
56
+ const unsupported = codes.filter(code => FEATURES[code]);
57
+ if (unsupported.length === 0) return undefined;
58
+ return `${shadow ? "shadow: would reject" : "unsupported translated Claude features"}: ${unsupported.join(", ")}`
59
+ .slice(0, MAX_REASON_LENGTH);
60
+ }
61
+
62
+ const BODY_FIELDS = new Set([
63
+ "model", "max_tokens", "messages", "system", "tools", "tool_choice", "thinking",
64
+ "output_config", "metadata", "service_tier", "stop_sequences", "stream",
65
+ "temperature", "top_p", "top_k", "cache_control", "context_management",
66
+ "container", "inference_geo", "user_profile_id", "mcp_servers", "defer_tools", "deferred_tools",
67
+ ]);
68
+
69
+ function activeDeferred(value: unknown): boolean {
70
+ return value === true || (Array.isArray(value) ? value.length > 0 : isRec(value) && Object.keys(value).length > 0);
71
+ }
72
+
73
+ function nonDirectCaller(value: unknown): boolean {
74
+ return value !== undefined && !(Array.isArray(value) && value.length === 1 && value[0] === "direct");
75
+ }
76
+
77
+ /** Complete finite detection. Only protocol content positions are visited, never schemas/arguments. */
78
+ function detectFeatures(body: unknown, anthropicBeta?: string): Set<ClaudeFeatureCode> {
79
+ const codes = new Set<ClaudeFeatureCode>();
80
+ // Header-only beta semantics are outside this policy. No header bytes become codes.
81
+ if (anthropicBeta?.trim()) codes.add("unknown_beta");
82
+ if (!isRec(body)) return codes; // The existing Messages parser owns malformed top-level input.
83
+ if (Object.keys(body).some(key => !BODY_FIELDS.has(key))) codes.add("unknown_body_field");
84
+ for (const [field, code] of [
85
+ ["cache_control", "cache_control"], ["context_management", "context_management"],
86
+ ["container", "container"], ["inference_geo", "inference_geo"],
87
+ ["user_profile_id", "user_profile"], ["mcp_servers", "mcp_tool"],
88
+ ] as const) {
89
+ if (Object.hasOwn(body, field)) codes.add(code);
90
+ }
91
+ if (body.service_tier !== undefined && body.service_tier !== null) codes.add("service_tier");
92
+ if (isRec(body.thinking)) codes.add("thinking_settings");
93
+ if (isRec(body.output_config)
94
+ && (body.output_config.format != null || body.output_config.output_format != null)) codes.add("structured_output");
95
+ if (activeDeferred(body.defer_tools) || activeDeferred(body.deferred_tools)) codes.add("deferred_tools");
96
+
97
+ if (Array.isArray(body.tools)) {
98
+ for (const tool of body.tools) {
99
+ if (!isRec(tool)) continue;
100
+ if (Object.hasOwn(tool, "cache_control")) codes.add("cache_control");
101
+ if (Object.hasOwn(tool, "input_examples")) codes.add("input_examples");
102
+ if (tool.strict === true) codes.add("strict_tools");
103
+ if (tool.defer === true || tool.defer_loading === true) codes.add("deferred_tools");
104
+ if (nonDirectCaller(tool.allowed_callers)) codes.add("caller_mode");
105
+ const type = tool.type;
106
+ // Ordinary client function names do not convey hosted execution semantics.
107
+ if (type === undefined || type === "function" || type === "custom") continue;
108
+ if (type === "mcp_toolset") codes.add("mcp_tool");
109
+ else if (typeof type === "string" && /^web_search_\d{8}$/.test(type)) codes.add("web_search_tool");
110
+ else if (typeof type === "string" && /^tool_search(?:_tool_(?:regex|bm25))?(?:_\d{8})?$/.test(type)) codes.add("tool_search");
111
+ else if (typeof type === "string" && /^code_execution_\d{8}$/.test(type)) codes.add("code_execution");
112
+ else if (typeof type === "string" && /^computer(?:_toolset)?_\d{8}$/.test(type)) codes.add("computer_use");
113
+ else codes.add("server_tool");
114
+ }
115
+ }
116
+
117
+ const scanBlock = (block: unknown, position: "message" | "system" | "result") => {
118
+ if (!isRec(block)) return;
119
+ if (Object.hasOwn(block, "cache_control")) codes.add("cache_control");
120
+ if (position === "system" && block.type !== "text") codes.add("unknown_content_block");
121
+ if (position === "result" && (typeof block.type !== "string" || !["text", "image", "document", "tool_reference"].includes(block.type))) {
122
+ codes.add("unknown_content_block");
123
+ }
124
+ switch (block.type) {
125
+ case "text":
126
+ case "image": break;
127
+ case "document": codes.add("documents"); break;
128
+ case "thinking":
129
+ case "redacted_thinking": codes.add("thinking_replay"); break;
130
+ case "tool_reference": codes.add("tool_reference"); break;
131
+ case "tool_search_tool_result": codes.add("tool_search"); break;
132
+ case "web_search_tool_result": codes.add("web_search_tool"); break;
133
+ case "code_execution_tool_result":
134
+ case "bash_code_execution_tool_result":
135
+ case "text_editor_code_execution_tool_result": codes.add("code_execution"); break;
136
+ case "mcp_tool_use":
137
+ case "mcp_tool_result": codes.add("mcp_tool"); break;
138
+ case "tool_use":
139
+ if (block.caller !== undefined && (!isRec(block.caller) || block.caller.type !== "direct")) codes.add("caller_mode");
140
+ break;
141
+ case "server_tool_use":
142
+ switch (block.name) {
143
+ case "tool_search":
144
+ case "tool_search_tool_regex":
145
+ case "tool_search_tool_bm25": codes.add("tool_search"); break;
146
+ case "web_search": codes.add("web_search_tool"); break;
147
+ case "code_execution": codes.add("code_execution"); break;
148
+ case "computer": codes.add("computer_use"); break;
149
+ default: codes.add("server_tool");
150
+ }
151
+ break;
152
+ case "tool_result": break; // Children are visited below at the one supported nesting level.
153
+ default: codes.add("unknown_content_block");
154
+ }
155
+ };
156
+ if (Array.isArray(body.system)) for (const block of body.system) scanBlock(block, "system");
157
+ if (Array.isArray(body.messages)) {
158
+ for (const message of body.messages) {
159
+ if (!isRec(message) || !Array.isArray(message.content)) continue;
160
+ for (const block of message.content) {
161
+ scanBlock(block, "message");
162
+ if (isRec(block) && block.type === "tool_result" && Array.isArray(block.content)) {
163
+ for (const child of block.content) scanBlock(child, "result");
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return codes;
169
+ }
170
+
171
+ export interface ClaudeCompatibilityResult {
172
+ featureCodes: ClaudeFeatureCode[];
173
+ compatible: boolean;
174
+ decision: "allow" | "shadow" | "reject";
175
+ reason?: string;
176
+ }
177
+
178
+ /** All translated targets share this policy; native passthrough never calls it. */
179
+ export function analyzeClaudeCompatibility(
180
+ body: unknown,
181
+ opts: { mode: ClaudeCompatibilityMode; anthropicBeta?: string },
182
+ ): ClaudeCompatibilityResult {
183
+ const detected = detectFeatures(body, opts.anthropicBeta);
184
+ const compatible = !FEATURE_CODES.some(code => detected.has(code) && FEATURES[code]);
185
+ const featureCodes = normalizeClaudeFeatureCodes([...detected]);
186
+ return {
187
+ featureCodes,
188
+ compatible,
189
+ decision: compatible ? "allow" : opts.mode === "shadow" ? "shadow" : "reject",
190
+ ...(!compatible ? { reason: claudeCompatibilityReason(featureCodes, opts.mode === "shadow") } : {}),
191
+ };
192
+ }
@@ -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, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog";
18
+ import { orderForModelPicker, catalogModelEfforts, nativeEffortClamp, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog";
19
19
  import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
20
20
  import { cursorFastIdFor } from "../adapters/cursor/catalog";
21
21
  import { desktop3pAlias } from "./desktop-3p";
@@ -114,6 +114,7 @@ export function buildAnthropicModelInfos(
114
114
  // Presence is the feature gate: the caller passes undefined when `fastRows` is off, so a
115
115
  // default install publishes nothing. The predicate answers ELIGIBILITY, not enablement.
116
116
  fastRows?: (model: CatalogModel | { provider: string; id: string }) => boolean,
117
+ ordering?: { modelPickerOrder?: readonly string[]; featured?: readonly string[] },
117
118
  ): AnthropicModelInfo[] {
118
119
  const out: AnthropicModelInfo[] = [];
119
120
  const seen = new Set<string>();
@@ -198,6 +199,8 @@ export function buildAnthropicModelInfos(
198
199
  // omitting it would leave this surface without the model the feature exists for.
199
200
  if (fastRows?.({ provider: "native", id: slug }) === true) pushFastVariant(info);
200
201
  }
202
+ const nativeEnd = out.length;
203
+ const routedGroups = new Map<CatalogModel, AnthropicModelInfo[]>();
201
204
  for (const m of routedModels) {
202
205
  // Global Fast has no toggle on this surface, so the fast identity is what gets listed —
203
206
  // a client here can only pick a listed id. Limited to the readable CLI style: Desktop 3P
@@ -211,6 +214,7 @@ export function buildAnthropicModelInfos(
211
214
  : aliasForRoute(m.provider, m.id);
212
215
  if (seen.has(id)) continue;
213
216
  seen.add(id);
217
+ const groupStart = out.length;
214
218
  const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : [];
215
219
  const imageInput = Array.isArray(m.inputModalities) ? m.inputModalities.includes("image") : false;
216
220
  // max_input_tokens is an input limit, so a row that publishes a lower input ceiling than
@@ -238,6 +242,14 @@ export function buildAnthropicModelInfos(
238
242
  // namespace with no config.providers entry, so the caller classifies it from the
239
243
  // aggregated supportsServiceTier the row already carries.
240
244
  if (fastRows?.(m) === true) pushFastVariant(info);
245
+ routedGroups.set(m, out.slice(groupStart));
241
246
  }
242
- return out;
247
+ if (!ordering?.modelPickerOrder?.length) return out;
248
+ // Sort only after deduplication, preserving the registry's original collision winner
249
+ // and keeping each model's base/1M/Fast siblings together.
250
+ return [
251
+ ...out.slice(0, nativeEnd),
252
+ ...orderForModelPicker([...routedGroups.keys()], ordering.modelPickerOrder, ordering.featured)
253
+ .flatMap(model => routedGroups.get(model)!),
254
+ ];
243
255
  }
@@ -367,6 +367,7 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
367
367
  if (threshold !== undefined && (!Number.isInteger(threshold) || threshold < 0 || threshold > 100)) {
368
368
  return usage("Error: threshold must be an integer 0-100");
369
369
  }
370
+ let settings: Record<string, unknown> = {};
370
371
  const baseUrl = await resolveBaseUrl(deps);
371
372
  if (!baseUrl) return proxyUnreachable();
372
373
  if (action === "status") {
@@ -378,13 +379,35 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise<
378
379
  if (response.status !== 200 || (!genericPool && typeof response.json.autoSwitchThreshold !== "number")) {
379
380
  return apiError(response.json, "failed to read auto-switch status", response.status);
380
381
  }
381
- threshold = typeof response.json.autoSwitchThreshold === "number" ? response.json.autoSwitchThreshold : 0;
382
+ settings = genericPool && (!response.json || typeof response.json !== "object" || Array.isArray(response.json))
383
+ ? {} : response.json;
384
+ threshold = typeof settings.autoSwitchThreshold === "number" ? settings.autoSwitchThreshold : 0;
382
385
  } else {
383
386
  const response = genericPool
384
387
  ? await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pool", { provider: name, autoSwitchThreshold: threshold })
385
388
  : await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold });
386
389
  if (response.status === 0) return proxyUnreachable(response.transportError);
387
390
  if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status);
391
+ settings = genericPool && (!response.json || typeof response.json !== "object" || Array.isArray(response.json))
392
+ ? {} : response.json;
393
+ }
394
+ if (genericPool) {
395
+ // Generic thresholds are stored independently of the enabled override. The
396
+ // latter may inherit global preference and never disables reactive rotation.
397
+ const stored = settings.autoSwitchThreshold;
398
+ const storedThreshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
399
+ ? stored : null;
400
+ const poolEnabled = typeof settings.enabled === "boolean" ? settings.enabled : null;
401
+ const inert = settings.inert === true ? true : null;
402
+ // This CLI understands only the current inert generic threshold contract.
403
+ const enabled = false;
404
+ if (wantsJson) {
405
+ console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2));
406
+ } else {
407
+ const value = storedThreshold === null ? "unset" : `${storedThreshold}%`;
408
+ console.log(`auto-switch: ${inert === true ? "inactive" : "unavailable"} (stored threshold ${value}; ${inert === true ? "not applied by this pool" : "threshold support is unknown"})`);
409
+ }
410
+ return 0;
388
411
  }
389
412
  const enabled = threshold! > 0;
390
413
  if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2));
package/src/cli/init.ts CHANGED
@@ -3,24 +3,44 @@ import { modelSelectionGuidance } from "./model-selection-guidance";
3
3
  import { initializeProviderModelSelection } from "../providers/initial-model-selection";
4
4
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
5
5
  import { injectCodexConfig } from "../codex/inject";
6
- import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, preserveOpenAiTierRollbackSnapshot, saveConfig } from "../config";
6
+ import { classifyOpenAiTierBackup, ConfigMutationLockError, getConfigPath, getDefaultConfig, initializePersistedConfigIfMissing, isValidProviderName, observeInitialConfigState, preserveOpenAiTierRollbackSnapshot } from "../config";
7
+ import { InitialConfigPublicationError } from "../config/initialize";
8
+ import { redactUserPath } from "../lib/redact";
7
9
  import { enrichProviderFromCatalog } from "../oauth/key-providers";
8
10
  import { deriveInitProviders } from "../providers/derive";
9
11
  import type { OcxConfig, OcxProviderConfig } from "../types";
10
12
 
11
- function createPrompt(): { ask(question: string): Promise<string>; close(): void } {
13
+ class InitCancelledError extends Error {
14
+ constructor(readonly exitCode: 1 | 130) {
15
+ super(exitCode === 130 ? "Setup cancelled." : "stdin reached EOF while waiting for input. Re-run `ocx init` in an interactive terminal.");
16
+ }
17
+ }
18
+
19
+ function createPrompt(): { ask(question: string): Promise<string>; throwIfCancelled(): void; close(): void } {
12
20
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
21
  let closed = false;
14
- rl.on("close", () => { closed = true; });
22
+ let cancellation: InitCancelledError | undefined;
23
+ const onInterrupt = () => {
24
+ cancellation = new InitCancelledError(130);
25
+ rl.close();
26
+ };
27
+ rl.on("SIGINT", onInterrupt);
28
+ process.on("SIGINT", onInterrupt);
29
+ rl.on("close", () => {
30
+ closed = true;
31
+ cancellation ??= new InitCancelledError(1);
32
+ process.off("SIGINT", onInterrupt);
33
+ rl.off("SIGINT", onInterrupt);
34
+ });
15
35
  return {
16
36
  ask(question: string): Promise<string> {
17
37
  return new Promise((resolve, reject) => {
18
38
  if (closed) {
19
- reject(new Error("stdin closed before the prompt could be answered"));
39
+ reject(cancellation ?? new InitCancelledError(1));
20
40
  return;
21
41
  }
22
42
  const onClose = () => {
23
- reject(new Error("stdin reached EOF while waiting for input"));
43
+ reject(cancellation ?? new InitCancelledError(1));
24
44
  };
25
45
  rl.once("close", onClose);
26
46
  rl.question(question, answer => {
@@ -29,6 +49,9 @@ function createPrompt(): { ask(question: string): Promise<string>; close(): void
29
49
  });
30
50
  });
31
51
  },
52
+ throwIfCancelled() {
53
+ if (cancellation) throw cancellation;
54
+ },
32
55
  close() {
33
56
  if (!closed) rl.close();
34
57
  },
@@ -88,7 +111,18 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):
88
111
  }
89
112
 
90
113
  export async function runInit(): Promise<void> {
114
+ const initial = observeInitialConfigState();
115
+ if (initial === "exists") {
116
+ console.log(`Keeping existing config at ${redactUserPath(getConfigPath())}. Use \`ocx config\` or the dashboard to update it.`);
117
+ return;
118
+ }
119
+ if (initial === "invalid") {
120
+ console.error(`Cannot initialize ${redactUserPath(getConfigPath())}: existing config is invalid, unreadable, or not a regular file. It has been preserved.`);
121
+ process.exitCode = 1;
122
+ return;
123
+ }
91
124
  const prompt = createPrompt();
125
+ let configCreated = false;
92
126
  try {
93
127
  console.log("\n🔧 opencodex (ocx) setup\n");
94
128
 
@@ -171,7 +205,14 @@ export async function runInit(): Promise<void> {
171
205
  modelDiscovery: { newModelPolicy: "off" },
172
206
  };
173
207
 
174
- saveConfig(config);
208
+ prompt.throwIfCancelled();
209
+ const outcome = initializePersistedConfigIfMissing(config);
210
+ if (outcome !== "created") {
211
+ console.error("Config appeared or changed while setup was running; keeping it and stopping setup.");
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ configCreated = true;
175
216
  // Init writes a fresh config, so a stale pre-migration backup from a previous
176
217
  // installation would make the next `ocx start` crash on a stale-backup
177
218
  // collision (issue #257). But only a STALE backup (unparseable, or already a
@@ -179,23 +220,34 @@ export async function runInit(): Promise<void> {
179
220
  // valid pre-migration (v1) config is a user-intentional rollback point and is
180
221
  // preserved by renaming it out of the collision path (sol review 260722).
181
222
  cleanupOpenAiTierBackupAfterInit();
182
- console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
223
+ console.log(`\n✅ Config saved to ${redactUserPath(getConfigPath())}`);
183
224
  if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
184
225
 
185
226
  const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
227
+ prompt.throwIfCancelled();
186
228
  if (injectAnswer.trim().toLowerCase() !== "n") {
187
229
  console.log("Fetching available models from provider...");
188
- const result = await injectCodexConfig(port, config);
230
+ const result = await injectCodexConfig(port, config, {
231
+ beforeClientWrite: () => prompt.throwIfCancelled(),
232
+ }).catch(error => {
233
+ // The injection/lock boundary may wrap the guard's cancellation error.
234
+ prompt.throwIfCancelled();
235
+ throw error;
236
+ });
237
+ prompt.throwIfCancelled();
189
238
  console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
190
239
  }
191
240
 
192
241
  const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
242
+ prompt.throwIfCancelled();
193
243
  if (shimAnswer.trim().toLowerCase() !== "n") {
194
244
  try {
195
245
  const { installCodexShim } = await import("../codex/shim");
246
+ prompt.throwIfCancelled();
196
247
  const result = installCodexShim();
197
248
  console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
198
249
  } catch (err) {
250
+ if (err instanceof InitCancelledError) throw err;
199
251
  console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
200
252
  }
201
253
  }
@@ -203,13 +255,18 @@ export async function runInit(): Promise<void> {
203
255
  console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
204
256
  for (const line of modelSelectionGuidance(providerName)) console.log(line);
205
257
  } catch (error) {
206
- const message = error instanceof Error ? error.message : String(error);
207
- if (/stdin (closed|reached EOF)/i.test(message)) {
208
- console.error(`\n❌ ${message}. Re-run \`ocx init\` in an interactive terminal.`);
258
+ if (error instanceof InitCancelledError) {
259
+ console.error(`\n❌ ${error.message}${configCreated ? " The created config has been kept." : ""}`);
260
+ process.exitCode = error.exitCode;
261
+ } else {
262
+ const message = error instanceof InitialConfigPublicationError
263
+ ? `${error.message}${error.publication !== "not-published" ? " Config may already exist; inspect it before retrying." : ""}${error.residualTemp ? " A temporary file could not be removed; inspect the config directory." : ""}`
264
+ : error instanceof ConfigMutationLockError
265
+ ? "Config initialization could not acquire its write lock. Retry when the other config operation finishes."
266
+ : `Setup did not finish.${configCreated ? " The created config has been kept." : " Check the config directory and setup inputs before retrying."}`;
267
+ console.error(`\n❌ ${message}`);
209
268
  process.exitCode = 1;
210
- return;
211
269
  }
212
- throw error;
213
270
  } finally {
214
271
  prompt.close();
215
272
  }
@@ -2130,6 +2130,31 @@ async function gatherRoutedModelsWithAuth(
2130
2130
  return models;
2131
2131
  }
2132
2132
 
2133
+ /** Bound a proven Codex-forward custom row without changing its stored configuration. */
2134
+ function boundCustomNativeReasoning(
2135
+ model: CatalogModel,
2136
+ allowed: readonly string[],
2137
+ nativeDefault: string | undefined,
2138
+ ): CatalogModel {
2139
+ if (allowed.length === 0 || model.reasoningEfforts === undefined) return model;
2140
+ const bounded = { ...model };
2141
+ if (model.reasoningEfforts.length === 0) {
2142
+ bounded.reasoningEfforts = [];
2143
+ delete bounded.defaultReasoningEffort;
2144
+ return bounded;
2145
+ }
2146
+ const declared = new Set(model.reasoningEfforts);
2147
+ const surviving = [...new Set(allowed)].filter(effort => declared.has(effort));
2148
+ const fallback = nativeDefault && allowed.includes(nativeDefault) ? nativeDefault : allowed[0]!;
2149
+ // A nonempty but incompatible declaration is not an explicit no-reasoning setting.
2150
+ bounded.reasoningEfforts = surviving.length > 0 ? surviving : [fallback];
2151
+ bounded.defaultReasoningEffort = model.defaultReasoningEffort
2152
+ && bounded.reasoningEfforts.includes(model.defaultReasoningEffort)
2153
+ ? model.defaultReasoningEffort
2154
+ : bounded.reasoningEfforts.includes(fallback) ? fallback : bounded.reasoningEfforts[0]!;
2155
+ return bounded;
2156
+ }
2157
+
2133
2158
  async function gatherRoutedModelsUncached(
2134
2159
  config: OcxConfig,
2135
2160
  capture: GatherFlightCapture,
@@ -2394,7 +2419,7 @@ async function gatherRoutedModelsUncached(
2394
2419
  ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
2395
2420
  // Native-alias defaults apply only where the custom row declares nothing: the explicit
2396
2421
  // spreads below must win (later in object order), so a stored `[]` stays empty and a
2397
- // declared ladder is never replaced by the alias's native ladder.
2422
+ // declared ladder is narrowed to proven native capabilities after the merge below.
2398
2423
  ...(codexForwardNativeCapabilityAlias
2399
2424
  ? {
2400
2425
  codexForwardNativeCapabilityAlias: true,
@@ -2409,7 +2434,8 @@ async function gatherRoutedModelsUncached(
2409
2434
  : {}),
2410
2435
  // Explicit custom-row ladder wins over the inherited provider row below: the merge only
2411
2436
  // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
2412
- // verbatim instead of being replaced by the replaced row's metadata.
2437
+ // instead of being replaced by that row's metadata. Only proven native aliases are
2438
+ // bounded against their own capability source after the merge.
2413
2439
  ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
2414
2440
  ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
2415
2441
  ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
@@ -2462,22 +2488,25 @@ async function gatherRoutedModelsUncached(
2462
2488
  ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
2463
2489
  ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
2464
2490
  } : base;
2491
+ const reasoningBounded = codexForwardNativeCapabilityAlias
2492
+ ? boundCustomNativeReasoning(merged, nativeReasoningEfforts(cm.modelId), nativeAliasDefaultEffort)
2493
+ : merged;
2465
2494
  // Vision-sidecar coverage only: when the enriched provider's shared predicate matches
2466
2495
  // noVisionModels or text-without-image modelInputModalities, advertise image input so the
2467
2496
  // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full
2468
2497
  // applyProviderConfigHints pass — custom rows are a
2469
2498
  // user override, so their explicit contextWindow / inputModalities / reasoning fields must be
2470
2499
  // preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
2471
- const mergedContext = typeof merged.contextWindow === "number" && merged.contextWindow > 0
2472
- ? merged.contextWindow
2500
+ const mergedContext = typeof reasoningBounded.contextWindow === "number" && reasoningBounded.contextWindow > 0
2501
+ ? reasoningBounded.contextWindow
2473
2502
  : undefined;
2474
- const boundedMergedMaxInput = typeof merged.maxInputTokens === "number" && merged.maxInputTokens > 0
2475
- ? (mergedContext !== undefined ? Math.min(merged.maxInputTokens, mergedContext) : merged.maxInputTokens)
2503
+ const boundedMergedMaxInput = typeof reasoningBounded.maxInputTokens === "number" && reasoningBounded.maxInputTokens > 0
2504
+ ? (mergedContext !== undefined ? Math.min(reasoningBounded.maxInputTokens, mergedContext) : reasoningBounded.maxInputTokens)
2476
2505
  : undefined;
2477
2506
  const mergedWithHardBounds = boundedMergedMaxInput !== undefined
2478
- && boundedMergedMaxInput !== merged.maxInputTokens
2479
- ? { ...merged, maxInputTokens: boundedMergedMaxInput }
2480
- : merged;
2507
+ && boundedMergedMaxInput !== reasoningBounded.maxInputTokens
2508
+ ? { ...reasoningBounded, maxInputTokens: boundedMergedMaxInput }
2509
+ : reasoningBounded;
2481
2510
  const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact]
2482
2511
  .filter((value): value is number => typeof value === "number" && value > 0);
2483
2512
  const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0
@@ -471,12 +471,14 @@ export function buildCatalogEntries(
471
471
  accountNativeSlugs?: readonly string[],
472
472
  accountNativeSlugsBySelector?: ReadonlyMap<string, readonly string[]>,
473
473
  keepNativeChatGptOnV1 = false,
474
+ modelPickerOrder: readonly string[] = [],
474
475
  ): RawEntry[] {
475
- return buildCatalogEntriesFromObservedState({
476
+ const entries = buildCatalogEntriesFromObservedState({
476
477
  template,
477
478
  gptSlugs,
478
479
  goModels,
479
480
  featured,
481
+ modelPickerOrder,
480
482
  wsEnabled,
481
483
  multiAgentMode,
482
484
  exactComboSlugs,
@@ -489,6 +491,8 @@ export function buildCatalogEntries(
489
491
  accountNativeSlugs,
490
492
  accountNativeSlugsBySelector,
491
493
  });
494
+ applyFullModelPickerOrder(entries, modelPickerOrder);
495
+ return entries;
492
496
  }
493
497
 
494
498
  /** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */
@@ -720,6 +724,30 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
720
724
  });
721
725
  }
722
726
 
727
+ /** Routed discovery projection; native groups and alias ownership belong to the caller. */
728
+ export function orderForModelPicker(
729
+ models: readonly CatalogModel[],
730
+ order: readonly string[] = [],
731
+ featured: readonly string[] = [],
732
+ ): CatalogModel[] {
733
+ const pickerOrder = normalizeModelPickerOrder(order);
734
+ if (pickerOrder.length === 0) return [...models];
735
+ const pickerRank = modelPickerRank(pickerOrder);
736
+ const featuredRank = modelPickerRank(featured);
737
+ const complete = pickerOrder.some(slug => !slug.includes("/"));
738
+ const rank = (model: CatalogModel): number => {
739
+ const slug = catalogModelSlug(model);
740
+ const featuredIndex = featuredRank(slug) ?? featuredRank(`${model.provider}/${model.id}`);
741
+ const natural = featuredIndex ?? 5;
742
+ const index = pickerRank(slug) ?? pickerRank(`${model.provider}/${model.id}`);
743
+ if (complete) return index ?? pickerOrder.length + natural;
744
+ // Preserve the legacy featured/alias bands, including unlisted rows before listed rows.
745
+ if (featuredIndex !== undefined || model.nativeAlias === true) return natural;
746
+ return index === undefined ? natural : PICKER_ORDER_PRIORITY_BASE + index;
747
+ };
748
+ return [...models].sort((a, b) => rank(a) - rank(b));
749
+ }
750
+
723
751
  /**
724
752
  * True when an existing catalog row was authored by OpenCodex routing (#855).
725
753
  * Every generated routed row — current full-slug form, the June–July 2026
@@ -875,6 +903,10 @@ export function mergeCatalogEntriesFromObservedState({
875
903
  const detachedBaselineCatalogModels = baselineCatalogModels
876
904
  .map(entry => structuredClone(entry) as RawEntry);
877
905
  const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry);
906
+ // Track this invocation's generated custom rows, not ownership markers read from disk.
907
+ // Their builder already finalized exact native ladders and ordinary routed mock tiers.
908
+ const freshCustomEntries = new Set(detachedRoutedEntries.filter(entry =>
909
+ entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND));
878
910
  const detachedAccountBoundEntries = accountBoundEntries
879
911
  .map(entry => structuredClone(entry) as RawEntry);
880
912
  const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey));
@@ -1195,7 +1227,7 @@ export function mergeCatalogEntriesFromObservedState({
1195
1227
  // Mock-max universality (260709): preserved routed entries from disk may predate
1196
1228
  // the max rung — ensure it here so subagent max spawns validate on every
1197
1229
  // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
1198
- if (!exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) {
1230
+ if (!freshCustomEntries.has(m) && !exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) {
1199
1231
  const levels = Array.isArray(e.supported_reasoning_levels)
1200
1232
  ? e.supported_reasoning_levels as Array<{ effort?: string }>
1201
1233
  : [];
@@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
9
9
  export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
10
10
  export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
11
- export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync";
11
+ export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, orderForModelPicker, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync";
12
12
  export type { ObservedCatalogMergeInput } from "./catalog/sync";
13
13
  export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
14
14
  export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models";