@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
@@ -25,6 +25,7 @@ import { isAbsolute, join, resolve } from "node:path";
25
25
  import { shouldInjectApiAuthHeader } from "../codex/inject";
26
26
  import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
27
27
  import { providerCodexAccountMode } from "../providers/registry";
28
+ import { sanitizeCodexReasoningEfforts } from "../reasoning-effort";
28
29
  import { probeHostname } from "../server/proxy-liveness";
29
30
  import type { OcxConfig } from "../types";
30
31
 
@@ -192,6 +193,27 @@ function ompProfileName(env: OpencodeLaunchEnv): string | undefined {
192
193
  return profile;
193
194
  }
194
195
 
196
+ /**
197
+ * Pi resolves its agent directory from `PI_CODING_AGENT_DIR`, falling back to
198
+ * `~/.pi/agent`. `ompAgentDir` below already reads that variable — OMP is a Pi
199
+ * derivative — so Pi's own resolver honoring it is what makes the two agree
200
+ * rather than a new claim about Pi's contract.
201
+ *
202
+ * A relative override is refused for the same reason MCode's and ZCode's are: a
203
+ * background proxy and a foreground client can have different working
204
+ * directories, and would otherwise disagree about which file is named.
205
+ */
206
+ export function piAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
207
+ const override = env.PI_CODING_AGENT_DIR?.trim();
208
+ if (override) return absoluteClientPath(override, home, "PI_CODING_AGENT_DIR");
209
+ return join(home, ".pi", "agent");
210
+ }
211
+
212
+ /** Pi's canonical custom-provider catalog. */
213
+ export function piConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
214
+ return join(piAgentDir(env, home), "models.json");
215
+ }
216
+
195
217
  /** Resolve the global Oh My Pi agent directory using OMP's own env precedence. */
196
218
  export function ompAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
197
219
  const profile = ompProfileName(env);
@@ -432,6 +454,26 @@ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
432
454
  return join(zcodeHomeDir(env, home), "v2", "config.json");
433
455
  }
434
456
 
457
+ /**
458
+ * Prime Agent resolves its agent directory from `PRIME_AGENT_CODING_AGENT_DIR`
459
+ * — the brand-derived spelling of the `PI_CODING_AGENT_DIR` that `ompAgentDir`
460
+ * already honors, because the agent builds that variable name from its own
461
+ * `piConfig.name` — and otherwise falls back to `~/.prime/agent`. Relative
462
+ * overrides are refused for the same reason as MCode's and ZCode's: a
463
+ * background proxy and a foreground client can have different working
464
+ * directories.
465
+ */
466
+ export function primeAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
467
+ const override = env.PRIME_AGENT_CODING_AGENT_DIR?.trim();
468
+ if (override) return absoluteClientPath(override, home, "PRIME_AGENT_CODING_AGENT_DIR");
469
+ return join(home, ".prime", "agent");
470
+ }
471
+
472
+ /** Prime Agent's canonical custom-provider catalog. */
473
+ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
474
+ return join(primeAgentDir(env, home), "models.json");
475
+ }
476
+
435
477
  /**
436
478
  * One proxy-routed model destined for a client config. Deliberately narrower than
437
479
  * `CatalogModel` so a serializer cannot reach for a field that does not survive the
@@ -473,7 +515,8 @@ export type ExportClientId =
473
515
  | "gajae"
474
516
  | "dsh"
475
517
  | "mcode"
476
- | "zcode";
518
+ | "zcode"
519
+ | "prime";
477
520
 
478
521
  export interface ExportClientSpec {
479
522
  id: ExportClientId;
@@ -886,7 +929,14 @@ export interface McodeProviderBlock {
886
929
  baseURL: string;
887
930
  authMode: "api-key";
888
931
  };
889
- models: Record<string, Record<string, never>>;
932
+ models: Record<string, McodeModelEntry>;
933
+ }
934
+
935
+ export interface McodeModelEntry {
936
+ /** MCode uses this value for context accounting and compaction. */
937
+ limit?: { context: number };
938
+ /** MCode exposes these exact levels in `/model` and sends the selected effort. */
939
+ thinking?: { effortOptions: string[] };
890
940
  }
891
941
 
892
942
  export interface McodeGeneratedConfig {
@@ -895,10 +945,10 @@ export interface McodeGeneratedConfig {
895
945
 
896
946
  /**
897
947
  * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated
898
- * live against ZCode 3.7.7). `kind: "anthropic"` selects the Anthropic
899
- * Messages protocol, which the proxy serves at `/v1/messages`. `apiKeyRequired`
900
- * keeps ZCode's UI from prompting for a key it does not need on loopback; the
901
- * serialized key is always the non-secret loopback placeholder.
948
+ * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the
949
+ * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`.
950
+ * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on
951
+ * loopback; the serialized key is always the non-secret loopback placeholder.
902
952
  */
903
953
  export interface ZcodeModelEntry {
904
954
  name?: string;
@@ -908,7 +958,7 @@ export interface ZcodeModelEntry {
908
958
 
909
959
  export interface ZcodeProviderBlock {
910
960
  name: "OpenCodex";
911
- kind: "anthropic";
961
+ kind: "openai-compatible";
912
962
  enabled: true;
913
963
  source: "custom";
914
964
  options: {
@@ -1231,12 +1281,29 @@ function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig {
1231
1281
 
1232
1282
  /**
1233
1283
  * MiniMax Code's `provider add` command persists custom providers under
1234
- * `custom_provider.<id>`. Do not emit `defaultModel`: connecting a client must
1235
- * not silently replace the user's current model selection.
1284
+ * `custom_provider.<id>`. Its current model schema reads `limit.context` for
1285
+ * context accounting and `thinking.effortOptions` for the `/model` effort
1286
+ * control. Do not emit the removed `thinking.effort` / `defaultEffort` fields:
1287
+ * MCode 0.1.6 migrates those into options and keeps the selected effort in the
1288
+ * session. Do not emit `defaultModel` either: connecting a client must not
1289
+ * silently replace the user's current model selection.
1236
1290
  */
1237
1291
  function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig {
1238
- const models: Record<string, Record<string, never>> = {};
1239
- for (const model of normalizeExportModels(ctx.models)) models[model.namespaced] = {};
1292
+ const models: Record<string, McodeModelEntry> = {};
1293
+ for (const model of normalizeExportModels(ctx.models)) {
1294
+ const entry: McodeModelEntry = {};
1295
+ const context = authoritativeContextWindow(model.contextWindow);
1296
+ if (context !== undefined) entry.limit = { context };
1297
+ // `none` is an internal Codex catalog sentinel, not an MCode effort. MCode
1298
+ // forwards every option as `output_config.effort` while keeping adaptive
1299
+ // thinking enabled, and the Anthropic ingress deliberately accepts only
1300
+ // minimal..ultra. Advertising `none` would therefore create a selectable
1301
+ // value that cannot disable reasoning and is not forwarded as an effort.
1302
+ const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts)
1303
+ ?.filter(effort => effort !== "none");
1304
+ if (efforts && efforts.length > 0) entry.thinking = { effortOptions: efforts };
1305
+ models[model.namespaced] = entry;
1306
+ }
1240
1307
  return {
1241
1308
  custom_provider: {
1242
1309
  [OPENCODE_PROVIDER_ID]: {
@@ -1256,10 +1323,10 @@ function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig {
1256
1323
  }
1257
1324
 
1258
1325
  /**
1259
- * ZCode dials the Anthropic Messages surface, so `baseURL` is the proxy origin
1260
- * without the `/v1` suffix (ZCode appends `/v1/messages` itself the same
1261
- * shape its builtin Z.ai providers use). Model ids are the proxy's canonical
1262
- * `provider/id` selectors, which `/v1/messages` resolves directly. Context
1326
+ * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which
1327
+ * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1`
1328
+ * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical
1329
+ * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context
1263
1330
  * limits follow the authoritative-window rule: a model without one ships
1264
1331
  * without `limit` rather than guessing. Modalities are ZCode's observed
1265
1332
  * `text`-floor vocabulary; image-capable rows advertise image input.
@@ -1288,12 +1355,12 @@ function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig {
1288
1355
  provider: {
1289
1356
  [OPENCODE_PROVIDER_ID]: {
1290
1357
  name: "OpenCodex",
1291
- kind: "anthropic",
1358
+ kind: "openai-compatible",
1292
1359
  enabled: true,
1293
1360
  source: "custom",
1294
1361
  options: {
1295
1362
  apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
1296
- baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""),
1363
+ baseURL: ctx.baseUrl.replace(/\/v1\/?$/, "") + "/v1",
1297
1364
  apiKeyRequired: true,
1298
1365
  },
1299
1366
  models,
@@ -1353,8 +1420,7 @@ function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLim
1353
1420
 
1354
1421
  function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
1355
1422
  const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {});
1356
- // MCode's custom-provider schema does not expose per-model context limits.
1357
- return { modelCount: models.length, modelsWithoutLimits: 0 };
1423
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length };
1358
1424
  }
1359
1425
 
1360
1426
  function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
@@ -1428,6 +1494,24 @@ function buildZcodeContribution(ctx: ExportContext): ManagedContribution {
1428
1494
  return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]);
1429
1495
  }
1430
1496
 
1497
+ /**
1498
+ * Prime Agent (PrimeIntellect) is the pi coding agent shipped under a different
1499
+ * brand rather than a lookalike: its package declares a `piConfig` block, and
1500
+ * the agent derives its config directory (`.prime/agent`) and env prefix from
1501
+ * that block alone. `models.json` is therefore the SAME contract Pi reads, so
1502
+ * this client reuses Pi's builder and summarizer verbatim. Restating the shape
1503
+ * here would create a second copy of one fact, which is exactly how the
1504
+ * "anything that is not OpenCode must be Pi" summarizer bug happened.
1505
+ *
1506
+ * The one thing that could still differ is the path we own, and it does not:
1507
+ * Prime keeps our entries under the same `providers.<id>` key. So the only new
1508
+ * code is stamping the right client id on the ownership record.
1509
+ */
1510
+ function buildPrimeContribution(ctx: ExportContext): ManagedContribution {
1511
+ const doc = buildPiClientConfig(ctx);
1512
+ return singleFragment("prime", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1513
+ }
1514
+
1431
1515
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1432
1516
  opencode: {
1433
1517
  id: "opencode",
@@ -1445,7 +1529,7 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1445
1529
  pi: {
1446
1530
  id: "pi",
1447
1531
  filename: "pi-models.json",
1448
- destination: () => join(homedir(), ".pi", "agent", "models.json"),
1532
+ destination: env => piConfigPath(env),
1449
1533
  apiKeyEnv: "",
1450
1534
  exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.",
1451
1535
  build: buildPiClientConfig,
@@ -1566,6 +1650,21 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1566
1650
  // remote binds refuse — same reasoning as MCode.
1567
1651
  loopbackOnly: true,
1568
1652
  },
1653
+ prime: {
1654
+ id: "prime",
1655
+ filename: "prime-models.json",
1656
+ destination: env => primeConfigPath(env),
1657
+ apiKeyEnv: "",
1658
+ exportHint: "Prime Agent reads a non-secret placeholder from models.json; loopback needs no key.",
1659
+ build: buildPiClientConfig,
1660
+ format: "json",
1661
+ summarize: summarizePi,
1662
+ buildContribution: buildPrimeContribution,
1663
+ // Prime's provider block does accept `headers`, so a dedicated admission
1664
+ // header has somewhere to live, but remote credential wiring is deferred
1665
+ // from this initial loopback-only integration — same stance as OMP's.
1666
+ loopbackOnly: true,
1667
+ },
1569
1668
  };
1570
1669
 
1571
1670
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Opt-in Codex affinity diagnostics for account-switch compatibility failures.
3
+ *
4
+ * The provider debug stream may compare values only within the current process. It never emits
5
+ * raw header values, credentials, account identifiers, or durable unsalted hashes.
6
+ */
7
+ import { createHmac, randomBytes } from "node:crypto";
8
+ import { isDebugEnabled } from "../lib/debug-settings";
9
+ import { debugProviderDiagnostic } from "../lib/debug";
10
+
11
+ const MAX_TAGGED_VALUE_BYTES = 16 * 1024;
12
+ const RUN_KEY = randomBytes(32);
13
+ let nextSequence = 0;
14
+
15
+ const SAFE_AFFINITY_HEADERS = [
16
+ "openai-beta",
17
+ "originator",
18
+ "session_id",
19
+ "session-id",
20
+ "thread-id",
21
+ "x-client-request-id",
22
+ "x-codex-beta-features",
23
+ "x-codex-installation-id",
24
+ "x-codex-parent-thread-id",
25
+ "x-codex-turn-metadata",
26
+ "x-codex-turn-state",
27
+ "x-codex-window-id",
28
+ "x-openai-subagent",
29
+ "x-responsesapi-include-timing-metrics",
30
+ ] as const;
31
+
32
+ const KNOWN_TURN_FIELDS = [
33
+ "forked_from_thread_id",
34
+ "parent_thread_id",
35
+ "request_kind",
36
+ "session_id",
37
+ "subagent_kind",
38
+ "thread_id",
39
+ ] as const;
40
+
41
+ type SizeBucket = "0" | "1-31" | "32-127" | "128-511" | "512-2047" | "2048-16384" | "oversized";
42
+
43
+ export interface CodexAffinityValueTag {
44
+ name: string;
45
+ size: SizeBucket;
46
+ tag?: string;
47
+ }
48
+
49
+ export interface CodexAffinityJsonSummary {
50
+ shape: "absent" | "malformed" | "oversized" | "array" | "scalar" | "object";
51
+ knownFields?: Array<CodexAffinityValueTag & { kind: "string" | "number" | "boolean" | "null" | "array" | "object" }>;
52
+ unknownFieldCount?: number;
53
+ }
54
+
55
+ export interface CodexAffinityDiagnosticInput {
56
+ inboundHeaders: Headers;
57
+ outboundHeaders: HeadersInit;
58
+ authKind: "main" | "pool" | "main-pool";
59
+ accountMode: "direct" | "pool" | undefined;
60
+ fixedAccount: boolean;
61
+ credentialSubstituted: boolean;
62
+ accountGatedModel: boolean;
63
+ wireModelNormalized: boolean;
64
+ status: number;
65
+ }
66
+
67
+ function sizeBucket(bytes: number): SizeBucket {
68
+ if (bytes === 0) return "0";
69
+ if (bytes <= 31) return "1-31";
70
+ if (bytes <= 127) return "32-127";
71
+ if (bytes <= 511) return "128-511";
72
+ if (bytes <= 2047) return "512-2047";
73
+ if (bytes <= MAX_TAGGED_VALUE_BYTES) return "2048-16384";
74
+ return "oversized";
75
+ }
76
+
77
+ function valueTag(name: string, value: string): CodexAffinityValueTag {
78
+ const bytes = Buffer.byteLength(value, "utf8");
79
+ const size = sizeBucket(bytes);
80
+ if (size === "oversized") return { name, size };
81
+ const tag = createHmac("sha256", RUN_KEY)
82
+ .update(name)
83
+ .update("\0")
84
+ .update(value)
85
+ .digest("hex")
86
+ .slice(0, 12);
87
+ return { name, size, tag };
88
+ }
89
+
90
+ function headerTags(headers: Headers): CodexAffinityValueTag[] {
91
+ const rows: CodexAffinityValueTag[] = [];
92
+ for (const name of SAFE_AFFINITY_HEADERS) {
93
+ const value = headers.get(name);
94
+ if (value !== null) rows.push(valueTag(name, value));
95
+ }
96
+ return rows;
97
+ }
98
+
99
+ function jsonSummary(headers: Headers, name: "x-codex-turn-metadata" | "x-codex-turn-state"): CodexAffinityJsonSummary {
100
+ const raw = headers.get(name);
101
+ if (raw === null) return { shape: "absent" };
102
+ if (Buffer.byteLength(raw, "utf8") > MAX_TAGGED_VALUE_BYTES) return { shape: "oversized" };
103
+ let parsed: unknown;
104
+ try {
105
+ parsed = JSON.parse(raw) as unknown;
106
+ } catch {
107
+ return { shape: "malformed" };
108
+ }
109
+ if (Array.isArray(parsed)) return { shape: "array" };
110
+ if (!parsed || typeof parsed !== "object") return { shape: "scalar" };
111
+ const record = parsed as Record<string, unknown>;
112
+ const knownFields: NonNullable<CodexAffinityJsonSummary["knownFields"]> = [];
113
+ for (const field of KNOWN_TURN_FIELDS) {
114
+ if (!Object.hasOwn(record, field)) continue;
115
+ const value = record[field];
116
+ if (value === null) {
117
+ knownFields.push({ name: field, kind: "null", size: "0" });
118
+ } else {
119
+ const scalarKind = typeof value;
120
+ if (scalarKind === "string" || scalarKind === "number" || scalarKind === "boolean") {
121
+ knownFields.push({ ...valueTag(`${name}.${field}`, String(value)), name: field, kind: scalarKind });
122
+ continue;
123
+ }
124
+ knownFields.push({ name: field, kind: Array.isArray(value) ? "array" : "object", size: "0" });
125
+ }
126
+ }
127
+ const known = new Set<string>(KNOWN_TURN_FIELDS);
128
+ const unknownFieldCount = Object.keys(record).filter(key => !known.has(key)).length;
129
+ return {
130
+ shape: "object",
131
+ ...(knownFields.length > 0 ? { knownFields } : {}),
132
+ ...(unknownFieldCount > 0 ? { unknownFieldCount } : {}),
133
+ };
134
+ }
135
+
136
+ /** Emit one observation-only, privacy-bounded provider-debug record. */
137
+ export function captureCodexAffinityDiagnostic(input: CodexAffinityDiagnosticInput): void {
138
+ if (!isDebugEnabled()) return;
139
+ try {
140
+ const outbound = new Headers(input.outboundHeaders);
141
+ debugProviderDiagnostic("codex", "affinity", {
142
+ sequence: ++nextSequence,
143
+ authKind: input.authKind,
144
+ accountMode: input.accountMode ?? "unset",
145
+ fixedAccount: input.fixedAccount,
146
+ credentialSubstituted: input.credentialSubstituted,
147
+ accountGatedModel: input.accountGatedModel,
148
+ wireModelNormalized: input.wireModelNormalized,
149
+ status: input.status,
150
+ inbound: headerTags(input.inboundHeaders),
151
+ outbound: headerTags(outbound),
152
+ inboundTurnMetadata: jsonSummary(input.inboundHeaders, "x-codex-turn-metadata"),
153
+ outboundTurnMetadata: jsonSummary(outbound, "x-codex-turn-metadata"),
154
+ inboundTurnState: jsonSummary(input.inboundHeaders, "x-codex-turn-state"),
155
+ outboundTurnState: jsonSummary(outbound, "x-codex-turn-state"),
156
+ });
157
+ } catch {
158
+ // Diagnostics must never affect request handling.
159
+ }
160
+ }
161
+
162
+ export const CODEX_AFFINITY_DEBUG_SAFE_HEADERS = SAFE_AFFINITY_HEADERS;
@@ -482,25 +482,47 @@ function setRootModelProvider(content: string): string {
482
482
  }
483
483
 
484
484
  function readRootModelCatalogPath(content: string): string | null {
485
- return readRootTomlString(content, "model_catalog_json");
485
+ const lines = content.split("\n");
486
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
487
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
488
+ const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
489
+ let ownedCatalogPath: string | null = null;
490
+ for (let index = 0; index < rootEnd; index += 1) {
491
+ const match = modelCatalogAssignment.exec(lines[index]);
492
+ if (!match) continue;
493
+ const catalogPath = parseTomlString(match[1]);
494
+ if (!isOpencodexCatalogPath(catalogPath)) return catalogPath;
495
+ ownedCatalogPath ??= catalogPath;
496
+ }
497
+ return ownedCatalogPath;
486
498
  }
487
499
 
488
500
  function setRootModelCatalogPath(content: string, catalogPath: string): string {
489
501
  const lines = content.split("\n");
490
502
  const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
491
503
  const key = `model_catalog_json = ${tomlString(catalogPath)}`;
504
+ const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
492
505
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
506
+ const ownedAssignments: number[] = [];
507
+ let hasUserAssignment = false;
493
508
  for (let i = 0; i < rootEnd; i++) {
494
- const m = lines[i].match(
495
- /^\s*model_catalog_json\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/,
496
- );
509
+ const m = modelCatalogAssignment.exec(lines[i]);
497
510
  if (!m) continue;
498
511
  const existing = parseTomlString(m[1]);
499
512
  if (isOpencodexCatalogPath(existing)) {
500
- lines[i] = key;
501
- return lines.join("\n");
513
+ ownedAssignments.push(i);
514
+ } else {
515
+ hasUserAssignment = true;
502
516
  }
503
- return content;
517
+ }
518
+ if (hasUserAssignment) {
519
+ const owned = new Set(ownedAssignments);
520
+ return lines.filter((_, index) => !owned.has(index)).join("\n");
521
+ }
522
+ if (ownedAssignments.length > 0) {
523
+ lines[ownedAssignments[0]] = key;
524
+ const duplicates = new Set(ownedAssignments.slice(1));
525
+ return lines.filter((_, index) => !duplicates.has(index)).join("\n");
504
526
  }
505
527
  if (firstTable === -1) {
506
528
  return content.replace(/\n+$/, "") + "\n" + key + "\n";
@@ -583,12 +605,14 @@ function isOpencodexCatalogPath(path: string): boolean {
583
605
  }
584
606
 
585
607
  function stripOpencodexCatalogPath(content: string): string {
586
- return content
587
- .split("\n")
588
- .filter((line) => {
589
- const m = line.match(
590
- /^\s*model_catalog_json\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/,
591
- );
608
+ const modelCatalogAssignment = tomlStringPattern("model_catalog_json");
609
+ const lines = content.split("\n");
610
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
611
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
612
+ return lines
613
+ .filter((line, index) => {
614
+ if (index >= rootEnd) return true;
615
+ const m = modelCatalogAssignment.exec(line);
592
616
  return !m || !isOpencodexCatalogPath(parseTomlString(m[1]));
593
617
  })
594
618
  .join("\n");
@@ -894,7 +918,15 @@ export async function injectCodexConfig(
894
918
  });
895
919
  atomicWriteFile(CODEX_CONFIG_PATH, content);
896
920
  atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
897
- markJournalInjectedState(content, profileContent);
921
+ markJournalInjectedState(content, profileContent, {
922
+ // A root override is ours only in loopback Design B when no user-owned value won.
923
+ injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl
924
+ ? null
925
+ : rootTomlString(content, "openai_base_url"),
926
+ // This is the catalog artifact selected for this injection, even when config.toml
927
+ // already points at that path and therefore needs no textual rewrite.
928
+ injectedCatalogPath: catalogPath,
929
+ });
898
930
  };
899
931
 
900
932
  /*
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
- import { hasInjectedCodexRouting, rootTomlString } from "./injected-marker";
5
+ import { hasInjectedCodexRouting } from "./injected-marker";
6
6
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
7
7
 
8
8
  /**
@@ -108,16 +108,30 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
108
108
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
109
109
  }
110
110
 
111
- export function markJournalInjectedState(config: string, profile: string | null): void {
111
+ export interface InjectedJournalOwnership {
112
+ injectedOpenaiBaseUrl: string | null;
113
+ injectedCatalogPath: string | null;
114
+ }
115
+
116
+ export function markJournalInjectedState(
117
+ config: string,
118
+ profile: string | null,
119
+ ownership: InjectedJournalOwnership,
120
+ ): void {
112
121
  const journal = readJournal();
113
122
  if (!journal) return;
114
- if (journal.injectedConfigHash) return;
115
- journal.injectedConfigHash = sha256(config) ?? undefined;
123
+ // The first exact injected config is the only safe whole-snapshot restore boundary for
124
+ // the first native snapshot. A later reinjection may preserve user edits made while routed;
125
+ // hashing those newer bytes and restoring the first snapshot would delete those edits.
126
+ // Keep the first hash so changed/reinjected configs take the owned-field fallback path.
127
+ journal.injectedConfigHash ??= sha256(config) ?? undefined;
128
+ // The profile file is wholly generated by OpenCodex, so its latest exact hash remains safe
129
+ // to refresh and lets restore remove the latest generated profile after a port change.
116
130
  journal.injectedProfileHash = sha256(profile);
117
- // Read from the bytes we are about to install, not from the file: another writer may
118
- // already have rewritten it, and then the recorded value would describe their config.
119
- journal.injectedOpenaiBaseUrl = rootTomlString(config, "openai_base_url");
120
- journal.injectedCatalogPath = rootTomlString(config, "model_catalog_json");
131
+ // Only the caller knows which values it actually owns. Deriving these from the final TOML
132
+ // would mistake a preserved user override for injected routing.
133
+ journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
134
+ journal.injectedCatalogPath = ownership.injectedCatalogPath;
121
135
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
122
136
  }
123
137
 
package/src/config.ts CHANGED
@@ -715,6 +715,7 @@ const providerConfigSchema = z.object({
715
715
  supportsServiceTier: z.boolean().optional(),
716
716
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
717
717
  preserveResponsesReasoningContent: z.boolean().optional(),
718
+ decodesNativeCompactionBlobs: z.boolean().optional(),
718
719
  allowPrivateNetwork: z.boolean().optional(),
719
720
  // The management API accepts `null` as "clear this", so a config written before the POST
720
721
  // canonicalization below can hold one on disk. Rejecting it here would send the operator