@bitkyc08/opencodex 2.33.0 → 2.34.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 (159) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-C4TMRloX.js +112 -0
  3. package/gui/dist/assets/index-DQ-Ie18T.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  17. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  18. package/src/adapters/cursor/native-exec-network.ts +1 -1
  19. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  20. package/src/adapters/cursor/native-exec.ts +20 -0
  21. package/src/adapters/cursor/protobuf-request.ts +68 -14
  22. package/src/adapters/cursor/request-builder.ts +29 -6
  23. package/src/adapters/cursor/tool-definitions.ts +11 -2
  24. package/src/adapters/cursor/tool-result-normalize.ts +32 -1
  25. package/src/adapters/cursor/types.ts +20 -0
  26. package/src/adapters/cursor.ts +189 -37
  27. package/src/adapters/google-antigravity-replay.ts +102 -23
  28. package/src/adapters/google-errors.ts +44 -12
  29. package/src/adapters/google.ts +115 -16
  30. package/src/adapters/image.ts +1 -1
  31. package/src/adapters/kiro-constants.ts +15 -0
  32. package/src/adapters/kiro-tools.ts +43 -15
  33. package/src/adapters/kiro.ts +54 -9
  34. package/src/adapters/openai-chat.ts +286 -242
  35. package/src/adapters/openai-responses.ts +334 -24
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -24
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +32 -4
  44. package/src/cli/account-extended.ts +1 -1
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/init.ts +1 -0
  51. package/src/cli/models-runtime.ts +95 -0
  52. package/src/cli/models.ts +13 -7
  53. package/src/cli/provider-runtime.ts +16 -2
  54. package/src/cli/registry.ts +6 -1
  55. package/src/cli/v2.ts +34 -10
  56. package/src/codex/account-pause.ts +2 -1
  57. package/src/codex/account-priority.ts +3 -2
  58. package/src/codex/app-server-processes.ts +80 -6
  59. package/src/codex/auth-api.ts +46 -8
  60. package/src/codex/auth-context.ts +21 -18
  61. package/src/codex/catalog/aggregation.ts +6 -0
  62. package/src/codex/catalog/native-models.ts +5 -2
  63. package/src/codex/catalog/parsing.ts +16 -0
  64. package/src/codex/catalog/provider-fetch.ts +20 -3
  65. package/src/codex/catalog/sync.ts +127 -2
  66. package/src/codex/catalog.ts +1 -1
  67. package/src/codex/codex-write-lock.ts +3 -1
  68. package/src/codex/convergence-types.ts +1 -1
  69. package/src/codex/convergence.ts +22 -2
  70. package/src/codex/desired-state.ts +2 -2
  71. package/src/codex/desktop-app-restart.ts +18 -5
  72. package/src/codex/inject-coordination.ts +83 -0
  73. package/src/codex/inject.ts +14 -1
  74. package/src/codex/log-guard/inspect.ts +22 -4
  75. package/src/codex/model-entitlements.ts +9 -2
  76. package/src/codex/prompt-layers.ts +371 -25
  77. package/src/codex/prompt-text-probe.ts +238 -0
  78. package/src/codex/quota.ts +91 -10
  79. package/src/codex/routing.ts +9 -0
  80. package/src/codex/subagent-model-fallback.ts +104 -25
  81. package/src/codex/transition-state.ts +107 -8
  82. package/src/combos/types.ts +10 -0
  83. package/src/compatibility/openai-responses.ts +33 -1
  84. package/src/config/rebase-provenance.ts +68 -0
  85. package/src/config.ts +107 -9
  86. package/src/generated/compatibility-version.json +191 -139
  87. package/src/images/loop.ts +5 -4
  88. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  89. package/src/lab/fabric/producer-child.ts +1 -1
  90. package/src/lib/config-ownership.ts +20 -0
  91. package/src/lib/errors.ts +11 -2
  92. package/src/lib/package-tree-integrity.ts +101 -0
  93. package/src/oauth/generic-account-failover.ts +231 -0
  94. package/src/oauth/index.ts +57 -5
  95. package/src/oauth/key-providers.ts +10 -1
  96. package/src/oauth/kiro.ts +45 -0
  97. package/src/oauth/types.ts +15 -0
  98. package/src/providers/command-code-efforts.ts +38 -6
  99. package/src/providers/context-cap.ts +4 -3
  100. package/src/providers/default-aliases.ts +65 -0
  101. package/src/providers/derive.ts +28 -0
  102. package/src/providers/fastwire.ts +7 -1
  103. package/src/providers/model-presets.ts +119 -0
  104. package/src/providers/new-model-policy.ts +146 -0
  105. package/src/providers/provider-id-rewrite.ts +2 -1
  106. package/src/providers/quota.ts +65 -7
  107. package/src/providers/registry.ts +160 -68
  108. package/src/providers/slug-codec.ts +52 -0
  109. package/src/responses/code-mode-helper-compat.ts +50 -0
  110. package/src/responses/custom-tool-compat.ts +34 -10
  111. package/src/responses/parser.ts +4 -0
  112. package/src/responses/schema.ts +5 -1
  113. package/src/responses/thought-signature-replay.ts +17 -0
  114. package/src/router.ts +40 -2
  115. package/src/routing/analytics.ts +1 -0
  116. package/src/routing/quota.ts +10 -0
  117. package/src/server/auth-cors.ts +4 -0
  118. package/src/server/chat-completions.ts +24 -16
  119. package/src/server/chat-native-sse.ts +3 -3
  120. package/src/server/chat-native.ts +30 -11
  121. package/src/server/claude-messages.ts +1 -1
  122. package/src/server/index.ts +77 -11
  123. package/src/server/lifecycle.ts +52 -1
  124. package/src/server/management/agent-settings-routes.ts +31 -15
  125. package/src/server/management/codex-prompt-routes.ts +570 -0
  126. package/src/server/management/combo-routes.ts +2 -1
  127. package/src/server/management/config-routes.ts +27 -9
  128. package/src/server/management/context.ts +9 -0
  129. package/src/server/management/model-routes.ts +266 -0
  130. package/src/server/management/provider-routes.ts +6 -0
  131. package/src/server/management/routing-profile-routes.ts +2 -2
  132. package/src/server/management-api.ts +2 -0
  133. package/src/server/relay-eager.ts +147 -20
  134. package/src/server/relay.ts +251 -19
  135. package/src/server/request-log-conversation.ts +21 -0
  136. package/src/server/request-log.ts +41 -15
  137. package/src/server/responses/collaboration.ts +37 -3
  138. package/src/server/responses/combo-stream-preflight.ts +10 -3
  139. package/src/server/responses/core.ts +494 -114
  140. package/src/server/responses/empty-completion-guard.ts +35 -0
  141. package/src/server/responses/fetch-helpers.ts +2 -1
  142. package/src/server/responses/input-admission.ts +3 -1
  143. package/src/server/responses/passthrough-error.ts +33 -9
  144. package/src/server/responses/responses-field-backfill.ts +105 -13
  145. package/src/server/responses-custom-tool-repair.ts +52 -7
  146. package/src/server/responses-terminal-repair.ts +25 -4
  147. package/src/server/sse-frame-buffer.ts +31 -4
  148. package/src/server/ws-bridge.ts +14 -2
  149. package/src/types/config.ts +53 -0
  150. package/src/types/provider.ts +64 -0
  151. package/src/types/request.ts +11 -3
  152. package/src/types/tools.ts +7 -5
  153. package/src/types.ts +1 -0
  154. package/src/usage/expected-prices.ts +43 -24
  155. package/src/usage/log.ts +3 -0
  156. package/src/web-search/exa-executor.ts +40 -9
  157. package/src/web-search/loop.ts +5 -4
  158. package/gui/dist/assets/index-23-Lf7jR.js +0 -102
  159. package/gui/dist/assets/index-DxJMDyOr.css +0 -1
@@ -10,7 +10,17 @@
10
10
  * Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md §1.
11
11
  */
12
12
  import { randomUUID } from "node:crypto";
13
- import { chmodSync, lstatSync, realpathSync } from "node:fs";
13
+ import {
14
+ chmodSync,
15
+ closeSync,
16
+ fsyncSync,
17
+ linkSync,
18
+ lstatSync,
19
+ openSync,
20
+ realpathSync,
21
+ unlinkSync,
22
+ } from "node:fs";
23
+ import { basename, dirname, join } from "node:path";
14
24
 
15
25
  import { Database } from "bun:sqlite";
16
26
 
@@ -38,7 +48,7 @@ import {
38
48
  } from "./user-identity";
39
49
 
40
50
  export const CODEX_COORDINATOR_SCHEMA_VERSION = 1;
41
- const DURABLE_HISTORY_STATUSES = new Set(["converged", "pending", "running", "blocked", "unknown"]);
51
+ const DURABLE_HISTORY_STATUSES = new Set(["adoption-pending", "converged", "pending", "running", "blocked", "unknown"]);
42
52
  const DURABLE_HISTORY_REASONS = new Set([
43
53
  "db-busy",
44
54
  "permission",
@@ -66,7 +76,7 @@ const CREATE_TRANSITION_TABLE = `
66
76
  history_pending_rows INTEGER,
67
77
  history_backup_entries INTEGER,
68
78
  updated_at TEXT NOT NULL,
69
- CHECK (history_status IN ('converged', 'pending', 'running', 'blocked', 'unknown')),
79
+ CHECK (history_status IN ('adoption-pending', 'converged', 'pending', 'running', 'blocked', 'unknown')),
70
80
  CHECK (history_reason IS NULL OR history_reason IN
71
81
  ('db-busy', 'permission', 'unreadable', 'schema', 'timeout',
72
82
  'shutdown-cancelled', 'worker-died', 'overtaken', 'record-write-failed')),
@@ -75,14 +85,20 @@ const CREATE_TRANSITION_TABLE = `
75
85
  CHECK ((native_generation = 0 AND current_tx_id IS NULL)
76
86
  OR (native_generation > 0 AND length(trim(current_tx_id)) > 0)),
77
87
  CHECK ((native_generation = 0
88
+ AND history_status = 'unknown'
78
89
  AND history_tx_id IS NULL
79
90
  AND history_direction IS NULL
80
91
  AND history_authority_snapshot_id IS NULL)
92
+ OR (native_generation = 0
93
+ AND history_status = 'adoption-pending'
94
+ AND length(trim(history_tx_id)) > 0
95
+ AND history_direction IS NOT NULL
96
+ AND length(trim(history_authority_snapshot_id)) > 0)
81
97
  OR (native_generation > 0
82
98
  AND history_tx_id = current_tx_id
83
99
  AND history_direction IS NOT NULL
84
100
  AND length(trim(history_authority_snapshot_id)) > 0)),
85
- CHECK (native_generation > 0 OR
101
+ CHECK (native_generation > 0 OR history_status = 'adoption-pending' OR
86
102
  (history_status = 'unknown'
87
103
  AND history_reason IS NULL
88
104
  AND history_attempts = 0
@@ -100,6 +116,15 @@ const INITIALIZE_TRANSITION_ROW = `
100
116
  history_backup_entries, updated_at
101
117
  ) VALUES (1, 0, NULL, 'unknown', NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, ?)`;
102
118
 
119
+ const INITIALIZE_ADOPTION_ROW = `
120
+ INSERT INTO codex_transition_state (
121
+ singleton, native_generation, current_tx_id,
122
+ history_status, history_reason, history_attempts,
123
+ history_next_retry_at, history_tx_id, history_direction,
124
+ history_authority_snapshot_id, history_pending_rows,
125
+ history_backup_entries, updated_at
126
+ ) VALUES (1, 0, NULL, 'adoption-pending', NULL, 0, NULL, ?, ?, ?, NULL, NULL, ?)`;
127
+
103
128
  const SELECT_TRANSITION_ROW = `
104
129
  SELECT native_generation, current_tx_id,
105
130
  history_status, history_reason, history_attempts,
@@ -210,8 +235,15 @@ function rowToState(row: TransitionRow | null): CodexTransitionState {
210
235
 
211
236
  const generation = row.native_generation;
212
237
  if (generation === 0) {
213
- if (row.current_tx_id !== null || row.history_tx_id !== null
214
- || row.history_direction !== null || row.history_authority_snapshot_id !== null) {
238
+ const ordinaryInitial = row.history_status === "unknown"
239
+ && row.history_tx_id === null
240
+ && row.history_direction === null
241
+ && row.history_authority_snapshot_id === null;
242
+ const adoptionInitial = row.history_status === "adoption-pending"
243
+ && Boolean(row.history_tx_id?.trim())
244
+ && (row.history_direction === "apply" || row.history_direction === "remove")
245
+ && Boolean(row.history_authority_snapshot_id?.trim());
246
+ if (row.current_tx_id !== null || (!ordinaryInitial && !adoptionInitial)) {
215
247
  throw new CodexCoordinatorTransactionError("The initial coordinator row contains transition metadata.");
216
248
  }
217
249
  } else if (!row.current_tx_id?.trim()
@@ -234,13 +266,73 @@ function rowToState(row: TransitionRow | null): CodexTransitionState {
234
266
  nativeGeneration: generation,
235
267
  currentTxId: row.current_tx_id,
236
268
  history,
237
- historySchedule: generation === 0 ? null : {
269
+ historySchedule: generation === 0 && row.history_status !== "adoption-pending" ? null : {
238
270
  direction: row.history_direction as "apply" | "remove",
239
271
  authoritySnapshotId: row.history_authority_snapshot_id as string,
240
272
  },
241
273
  };
242
274
  }
243
275
 
276
+ export type CodexCoordinatorAdoptionCheckpoint = "temp-created" | "temp-committed" | "published";
277
+
278
+ export interface CodexCoordinatorAdoptionOptions {
279
+ readonly direction: "apply" | "remove";
280
+ readonly onCheckpoint?: (checkpoint: CodexCoordinatorAdoptionCheckpoint) => void;
281
+ }
282
+
283
+ function fsyncPath(path: string): void {
284
+ const fd = openSync(path, "r");
285
+ try { fsyncSync(fd); } finally { closeSync(fd); }
286
+ }
287
+
288
+ function publishAdoptionDatabase(
289
+ finalDatabasePath: string,
290
+ options: CodexCoordinatorAdoptionOptions,
291
+ ): void {
292
+ const parent = dirname(finalDatabasePath);
293
+ const tempPath = join(parent, `.${basename(finalDatabasePath)}.adopt-${process.pid}-${randomUUID()}.tmp`);
294
+ let tempPresent = false;
295
+ try {
296
+ const fd = openSync(tempPath, "wx", 0o600);
297
+ closeSync(fd);
298
+ tempPresent = true;
299
+ options.onCheckpoint?.("temp-created");
300
+
301
+ const temp = new Database(tempPath, { readwrite: true, create: false });
302
+ try {
303
+ temp.exec("PRAGMA journal_mode = DELETE; BEGIN IMMEDIATE");
304
+ temp.exec(CREATE_TRANSITION_TABLE);
305
+ temp.query(INITIALIZE_ADOPTION_ROW).run(
306
+ randomUUID(),
307
+ options.direction,
308
+ randomUUID(),
309
+ new Date().toISOString(),
310
+ );
311
+ temp.exec(`PRAGMA user_version = ${CODEX_COORDINATOR_SCHEMA_VERSION}; COMMIT`);
312
+ readCodexCoordinatorState(temp);
313
+ } catch (error) {
314
+ try { temp.exec("ROLLBACK"); } catch { /* commit may already have completed */ }
315
+ throw error;
316
+ } finally {
317
+ temp.close();
318
+ }
319
+ fsyncPath(tempPath);
320
+ options.onCheckpoint?.("temp-committed");
321
+
322
+ linkSync(tempPath, finalDatabasePath);
323
+ options.onCheckpoint?.("published");
324
+ if (process.platform !== "win32") fsyncPath(parent);
325
+ } catch (error) {
326
+ if (errorCode(error) !== "EEXIST") throw error;
327
+ } finally {
328
+ if (tempPresent) {
329
+ try { unlinkSync(tempPath); } catch (error) {
330
+ if (errorCode(error) !== "ENOENT") throw error;
331
+ }
332
+ }
333
+ }
334
+ }
335
+
244
336
  export function readCodexCoordinatorState(database: Database): CodexTransitionState {
245
337
  const row = database.query<TransitionRow, []>(SELECT_TRANSITION_ROW).get();
246
338
  return rowToState(row);
@@ -346,7 +438,10 @@ function createCapability(
346
438
  };
347
439
  }
348
440
 
349
- export function openCodexCoordinatorTransaction(finalDatabasePath: string): CodexCoordinatorTransactionController {
441
+ export function openCodexCoordinatorTransaction(
442
+ finalDatabasePath: string,
443
+ adoption?: CodexCoordinatorAdoptionOptions,
444
+ ): CodexCoordinatorTransactionController {
350
445
  let database: Database | undefined;
351
446
  let transactionOpen = false;
352
447
  let closed = false;
@@ -389,6 +484,10 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code
389
484
  if (errorCode(cause) !== "ENOENT") throw cause;
390
485
  databaseWasAbsent = true;
391
486
  }
487
+ if (databaseWasAbsent && adoption) {
488
+ publishAdoptionDatabase(finalDatabasePath, adoption);
489
+ databaseWasAbsent = false;
490
+ }
392
491
  database = new Database(finalDatabasePath, { create: true });
393
492
  if (databaseWasAbsent) {
394
493
  try { chmodSync(finalDatabasePath, 0o600); } catch { /* Windows applies ACLs in WP11. */ }
@@ -203,6 +203,16 @@ export function comboConfigIssues(
203
203
  }
204
204
 
205
205
  const body = raw as Record<string, unknown>;
206
+ if (typeof body.alias === "string") {
207
+ const alias = body.alias.toLowerCase();
208
+ for (const [providerName, provider] of Object.entries(providers)) {
209
+ if (provider.alias?.toLowerCase() === alias) {
210
+ issues.push({ path: ["alias"], message: `alias "${body.alias}" is already used by provider "${providerName}"` });
211
+ }
212
+ const model = Object.entries(provider.modelAliases ?? {}).find(([, value]) => value.toLowerCase() === alias);
213
+ if (model) issues.push({ path: ["alias"], message: `alias "${body.alias}" is already used by model "${providerName}/${model[0]}"` });
214
+ }
215
+ }
206
216
  if (body.strategy !== undefined
207
217
  && body.strategy !== "failover"
208
218
  && body.strategy !== "round-robin") {
@@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1";
9
9
  export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({
10
10
  schemaVersion: 1,
11
11
  id: "openai.codex-forward.gpt-5-6-sol.responses",
12
- version: "1.0.0",
12
+ version: "1.2.0",
13
13
  subject: {
14
14
  providerId: "openai",
15
15
  baseUrl: "https://chatgpt.com/backend-api/codex",
@@ -69,6 +69,38 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife
69
69
  limitation: "The field is removed before dispatch.",
70
70
  evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["metadata-removed"] }],
71
71
  },
72
+ {
73
+ id: "system-input-messages",
74
+ feature: "request.input.system_messages",
75
+ disposition: "translated",
76
+ summary: "Text-only input system messages are folded into top-level instructions.",
77
+ limitation: "The destination does not accept system-role input items; multimodal system messages stay unchanged rather than being dropped.",
78
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["system-message-folded", "system-message-removed"] }],
79
+ },
80
+ {
81
+ id: "truncation",
82
+ feature: "request.truncation",
83
+ disposition: "unsupported",
84
+ summary: "The ChatGPT Codex forward route does not receive truncation.",
85
+ limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.",
86
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["truncation-removed"] }],
87
+ },
88
+ {
89
+ id: "prompt-cache-breakpoint",
90
+ feature: "request.prompt_cache_breakpoint",
91
+ disposition: "unsupported",
92
+ summary: "Client-only prompt cache breakpoint markers do not reach the forward backend.",
93
+ limitation: "Markers are removed recursively from input within bounded depth and node budgets.",
94
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-breakpoint-removed"] }],
95
+ },
96
+ {
97
+ id: "unstored-item-reference",
98
+ feature: "continuation.item_reference",
99
+ disposition: "degraded",
100
+ summary: "Unpersisted item references are omitted from store-false continuations.",
101
+ limitation: "The referenced item cannot exist at the backend when store is false; tool call_id pairs remain unchanged.",
102
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["unstored-item-reference-removed"] }],
103
+ },
72
104
  {
73
105
  id: "prompt-cache-retention",
74
106
  feature: "request.prompt_cache_retention",
@@ -0,0 +1,68 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ const pendingTopLevelDeletions = new WeakMap<OcxConfig, Set<string>>();
4
+ export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance";
5
+
6
+ export function parsedConfigRebaseDeletionKeys(config: OcxConfig): Set<string> | null {
7
+ const value = config.configRebaseProvenance;
8
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
9
+ const record = value as Record<string, unknown>;
10
+ if (record.version !== 1 || !Array.isArray(record.deletedTopLevelKeys)) return null;
11
+ if (!record.deletedTopLevelKeys.every(key => typeof key === "string" && key !== CONFIG_REBASE_PROVENANCE_KEY)) {
12
+ return null;
13
+ }
14
+ return new Set(record.deletedTopLevelKeys as string[]);
15
+ }
16
+
17
+ export function configRebaseDeletionKeys(config: OcxConfig): Set<string> {
18
+ const deleted = new Set([
19
+ ...(parsedConfigRebaseDeletionKeys(config) ?? []),
20
+ ...(pendingTopLevelDeletions.get(config) ?? []),
21
+ ]);
22
+ const record = config as unknown as Record<string, unknown>;
23
+ for (const key of [...deleted]) {
24
+ if (Object.hasOwn(record, key) && record[key] !== undefined) deleted.delete(key);
25
+ }
26
+ return deleted;
27
+ }
28
+
29
+ export function configHasRebaseProvenance(config: OcxConfig): boolean {
30
+ return parsedConfigRebaseDeletionKeys(config) !== null || pendingTopLevelDeletions.has(config);
31
+ }
32
+
33
+ export function projectConfigRebaseProvenance(config: OcxConfig): OcxConfig {
34
+ const pending = pendingTopLevelDeletions.get(config);
35
+ const parsed = parsedConfigRebaseDeletionKeys(config);
36
+ // Preserve unknown future metadata byte-for-value. It carries no authority here.
37
+ if (config.configRebaseProvenance !== undefined && parsed === null) return config;
38
+ const deleted = new Set([...(parsed ?? []), ...(pending ?? [])]);
39
+ const record = config as unknown as Record<string, unknown>;
40
+ for (const key of [...deleted]) {
41
+ if (Object.hasOwn(record, key) && record[key] !== undefined) deleted.delete(key);
42
+ }
43
+ // A SHALLOW copy, deliberately. A provider entry may carry a non-cloneable value — a test
44
+ // fixture injects its own `fetch`, and `structuredClone` throws DataCloneError on a function,
45
+ // which took every provider-probe and CLI-parity suite down. Only the provenance key is
46
+ // rewritten here, so nothing below the top level needs to be copied at all; the deep clone
47
+ // was doing work this projection never asked for.
48
+ const projected = { ...config };
49
+ if (deleted.size === 0) delete projected.configRebaseProvenance;
50
+ else projected.configRebaseProvenance = {
51
+ version: 1,
52
+ deletedTopLevelKeys: [...deleted].sort(),
53
+ };
54
+ return projected;
55
+ }
56
+
57
+ /** Delete one top-level config key and retain the writer's explicit intent for rebasing. */
58
+ export function deleteConfigTopLevelKey<K extends keyof OcxConfig>(config: OcxConfig, key: K): void {
59
+ delete config[key];
60
+ if (key === CONFIG_REBASE_PROVENANCE_KEY) return;
61
+ const deleted = pendingTopLevelDeletions.get(config) ?? new Set<string>();
62
+ deleted.add(key);
63
+ pendingTopLevelDeletions.set(config, deleted);
64
+ }
65
+
66
+ export function clearPendingConfigTopLevelDeletions(config: OcxConfig): void {
67
+ pendingTopLevelDeletions.delete(config);
68
+ }
package/src/config.ts CHANGED
@@ -60,6 +60,7 @@ import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
60
60
  import { providerDestinationConfigError } from "./lib/destination-policy";
61
61
  import { redactSecretString } from "./lib/redact";
62
62
  import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
63
+ import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases";
63
64
  import {
64
65
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
65
66
  OPENAI_PROVIDER_TIER_VERSION,
@@ -141,6 +142,15 @@ export {
141
142
  writeRuntimePort,
142
143
  type RuntimePortState,
143
144
  } from "./config/process-state";
145
+ import {
146
+ clearPendingConfigTopLevelDeletions,
147
+ configHasRebaseProvenance,
148
+ configRebaseDeletionKeys,
149
+ CONFIG_REBASE_PROVENANCE_KEY,
150
+ deleteConfigTopLevelKey,
151
+ projectConfigRebaseProvenance,
152
+ } from "./config/rebase-provenance";
153
+ export { deleteConfigTopLevelKey } from "./config/rebase-provenance";
144
154
 
145
155
  export class OpenAiTierBackupCleanupError extends Error {
146
156
  constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
@@ -479,6 +489,9 @@ const fastWireSchema = z.object({
479
489
  const providerConfigSchema = z.object({
480
490
  adapter: z.string().min(1),
481
491
  baseUrl: z.string().min(1),
492
+ alias: z.string().optional(),
493
+ modelAliases: z.record(z.string(), z.string()).optional(),
494
+ defaultAliases: z.boolean().optional(),
482
495
  requestPacing: requestPacingSchema.optional().catch(undefined),
483
496
  mcpMaxTools: z.number().int().positive().optional(),
484
497
  mcpMaxSchemaBytes: z.number().int().positive().optional(),
@@ -865,6 +878,10 @@ const configSchema = z.object({
865
878
  ]).optional().catch(undefined),
866
879
  providers: z.record(z.string(), providerConfigSchema),
867
880
  defaultProvider: z.string().min(1).default("openai"),
881
+ defaultModelAliases: z.boolean().optional(),
882
+ // Future versions remain opaque through passthrough-compatible whole-config saves.
883
+ // Only version 1 grants deletion authority in the rebase path.
884
+ configRebaseProvenance: z.unknown().optional(),
868
885
  // A retry can be billable, so absence and malformed hand edits both stay off.
869
886
  emptyCompletionRetry: z.boolean().optional().catch(false),
870
887
  // A malformed hand edit must not silently stop opening the browser: fall back
@@ -904,6 +921,9 @@ const configSchema = z.object({
904
921
  // A malformed hand edit must degrade to false without discarding providers, accounts,
905
922
  // or the exact selector map. Live writes remain strict.
906
923
  codexAccountPickerEnabled: z.boolean().optional().catch(false),
924
+ // Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the
925
+ // whole config. Hidden is also the default, so `catch(false)` and the default agree.
926
+ showCodexSparkQuota: z.boolean().optional().catch(false),
907
927
  // Model ids excluded from the Grok Build managed block (dashboard switches).
908
928
  grokExcludedModels: z.array(z.string()).optional(),
909
929
  // Invalid values degrade to undefined ("auto") instead of failing the whole
@@ -1127,6 +1147,17 @@ const configSchema = z.object({
1127
1147
  message: reasoningSummariesError,
1128
1148
  });
1129
1149
  }
1150
+ const verbositySupportError = booleanRecordConfigError(
1151
+ (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity,
1152
+ "modelSupportsVerbosity",
1153
+ );
1154
+ if (verbositySupportError) {
1155
+ ctx.addIssue({
1156
+ code: "custom",
1157
+ path: ["providers", redactSecretString(name), "modelSupportsVerbosity"],
1158
+ message: verbositySupportError,
1159
+ });
1160
+ }
1130
1161
  const serviceTierModelsError = booleanRecordConfigError(
1131
1162
  (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier,
1132
1163
  "modelSupportsServiceTier",
@@ -1783,6 +1814,7 @@ export function loadConfig(): OcxConfig {
1783
1814
  try {
1784
1815
  const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
1785
1816
  const parsed = JSON.parse(raw);
1817
+ sanitizeAliasesForLoad(parsed);
1786
1818
  sanitizeRetryOn429ForLoad(parsed);
1787
1819
  sanitizeModelCostsForLoad(parsed);
1788
1820
  const result = configSchema.safeParse(parsed);
@@ -1854,6 +1886,43 @@ export function loadConfig(): OcxConfig {
1854
1886
  }
1855
1887
  }
1856
1888
 
1889
+ /** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */
1890
+ function sanitizeAliasesForLoad(raw: unknown): void {
1891
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
1892
+ const root = raw as Record<string, unknown>;
1893
+ if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
1894
+ const providers = root.providers as Record<string, Record<string, unknown>>;
1895
+ const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase()));
1896
+ const claimedProviders = new Set<string>();
1897
+ const comboAliases = new Set(Object.values((root.combos as Record<string, { alias?: unknown }> | undefined) ?? {})
1898
+ .map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean));
1899
+ const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record<string, unknown> | undefined) ?? {}).map(name => name.toLowerCase()));
1900
+ for (const provider of Object.values(providers)) {
1901
+ const alias = provider.alias;
1902
+ if (typeof alias !== "string" || !isValidProviderName(alias)
1903
+ || providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase())
1904
+ || comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) {
1905
+ if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json");
1906
+ delete provider.alias;
1907
+ } else claimedProviders.add(alias.toLowerCase());
1908
+ if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) {
1909
+ if (provider.modelAliases !== undefined) delete provider.modelAliases;
1910
+ continue;
1911
+ }
1912
+ const aliases = provider.modelAliases as Record<string, unknown>;
1913
+ const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase()));
1914
+ const claimed = new Set<string>();
1915
+ for (const [id, value] of Object.entries(aliases)) {
1916
+ const lower = typeof value === "string" ? value.toLowerCase() : "";
1917
+ if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower)
1918
+ || nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) {
1919
+ console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`);
1920
+ delete aliases[id];
1921
+ } else claimed.add(lower);
1922
+ }
1923
+ }
1924
+ }
1925
+
1857
1926
  /** Refresh the user cost-overlay registry from `config` and return it unchanged. */
1858
1927
  function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
1859
1928
  refreshUserCostOverlays(config);
@@ -2445,7 +2514,12 @@ function persistConfigUnlocked(config: OcxConfig): boolean {
2445
2514
  // External editors can add provider rows the live config deliberately does
2446
2515
  // not route with yet; merge them at the serialization boundary so an
2447
2516
  // unrelated in-process save cannot erase the provider or its overlay.
2517
+ // Provider preservation reads symbol-keyed live-owner state, which structuredClone
2518
+ // intentionally drops. Resolve that ownership before projecting JSON provenance.
2519
+ const provenanceProjection = projectConfigRebaseProvenance(config);
2448
2520
  const persisted = withPreservedDiskOnlyProviders(config);
2521
+ if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
2522
+ else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
2449
2523
  const bytes = JSON.stringify(persisted, null, 2) + "\n";
2450
2524
  let unchanged = false;
2451
2525
  try {
@@ -2473,9 +2547,15 @@ export function saveConfig(config: OcxConfig): void {
2473
2547
  // Keep the real-home assertion ahead of even lock-directory preparation.
2474
2548
  assertNotRealHomeUnderTest(getConfigDir());
2475
2549
  withConfigMutationLockSync(() => {
2476
- const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config);
2477
- if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
2478
- adoptCustomModelCatalogMigration(config, projected);
2550
+ const withProvenance = projectCustomModelCatalogMigration(
2551
+ readRawConfigJson(),
2552
+ projectConfigRebaseProvenance(config),
2553
+ );
2554
+ if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite();
2555
+ adoptCustomModelCatalogMigration(config, withProvenance);
2556
+ if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
2557
+ else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance);
2558
+ clearPendingConfigTopLevelDeletions(config);
2479
2559
  });
2480
2560
  }
2481
2561
 
@@ -2594,7 +2674,6 @@ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
2594
2674
  * reconciliation paths below.
2595
2675
  */
2596
2676
  const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
2597
-
2598
2677
  /**
2599
2678
  * The live config retains the address of the socket Bun actually opened, while
2600
2679
  * this map retains the operator's desired address for the next process start.
@@ -2898,6 +2977,8 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
2898
2977
  if (baseline && onDisk !== undefined) {
2899
2978
  const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
2900
2979
  if (persistedDiagnostics.source === "file") {
2980
+ const deletedKeys = configRebaseDeletionKeys(config);
2981
+ const provenanceExists = configHasRebaseProvenance(config);
2901
2982
  // Only keys this live config is actually known to have diverged on may be
2902
2983
  // rebased. The baseline is captured once when the server arms it, so any key
2903
2984
  // that appeared on disk afterwards — through saveConfig(), a hand edit, or
@@ -2911,8 +2992,11 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
2911
2992
  const rebaseableKeys = new Set([
2912
2993
  ...Object.keys(baseline as unknown as Record<string, unknown>),
2913
2994
  ...Object.keys(config as unknown as Record<string, unknown>),
2995
+ ...(provenanceExists
2996
+ ? Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)
2997
+ : []),
2914
2998
  ]);
2915
- const skipped = new Set(["hostname", "port", "claudeCode"]);
2999
+ const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]);
2916
3000
  for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
2917
3001
  if (!rebaseableKeys.has(key)) skipped.add(key);
2918
3002
  }
@@ -2922,6 +3006,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
2922
3006
  persistedDiagnostics.config as unknown as Record<string, unknown>,
2923
3007
  skipped,
2924
3008
  );
3009
+ for (const key of deletedKeys) delete (config as unknown as Record<string, unknown>)[key];
2925
3010
  }
2926
3011
  }
2927
3012
  if (claudeCodeBaseline.has(config)) {
@@ -2935,10 +3020,13 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
2935
3020
  }
2936
3021
  }
2937
3022
  }
3023
+ const provenanceProjection = projectConfigRebaseProvenance(config);
2938
3024
  const projectedConfig = projectCustomModelCatalogMigration(
2939
3025
  onDisk,
2940
3026
  config,
2941
3027
  );
3028
+ if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance;
3029
+ else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
2942
3030
  const persistedBinding = bindingBaseline && onDisk
2943
3031
  ? readPersistedServerBinding(onDisk, bindingBaseline)
2944
3032
  : bindingBaseline;
@@ -2956,8 +3044,11 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
2956
3044
  claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
2957
3045
  }
2958
3046
  if (liveConfigBaseline.has(config)) {
2959
- liveConfigBaseline.set(config, structuredClone(config));
3047
+ if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
3048
+ else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
3049
+ liveConfigBaseline.set(config, structuredClone(projectedConfig));
2960
3050
  }
3051
+ clearPendingConfigTopLevelDeletions(config);
2961
3052
  });
2962
3053
  }
2963
3054
 
@@ -3033,10 +3124,17 @@ export function applyProxyEnv(config: OcxConfig): void {
3033
3124
  const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
3034
3125
  const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
3035
3126
  const seen = new Set(entries.map(e => e.toLowerCase()));
3036
- for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
3037
- if (!seen.has(host)) {
3127
+ // Configured entries first, then loopback: loopback is unconditional, so appending it last
3128
+ // keeps it present even when the operator lists a loopback host themselves.
3129
+ const raw = config.noProxy;
3130
+ const configured = (Array.isArray(raw) ? raw : (resolveEnvValue(raw) ?? "").split(","))
3131
+ .map(entry => entry.trim())
3132
+ .filter(Boolean);
3133
+ for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) {
3134
+ const key = host.toLowerCase();
3135
+ if (!seen.has(key)) {
3038
3136
  entries.push(host);
3039
- seen.add(host);
3137
+ seen.add(key);
3040
3138
  }
3041
3139
  }
3042
3140
  process.env.NO_PROXY = entries.join(",");