@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -3,11 +3,19 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
5
  import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject";
6
+ import {
7
+ grokDefaultReasoningEffort,
8
+ grokReasoningEffortOption,
9
+ sanitizeGrokReasoningEfforts,
10
+ } from "./effort";
6
11
 
7
12
  export interface GrokInjectModel {
8
13
  id: string;
9
14
  name?: string;
10
15
  contextWindow?: number;
16
+ /** Catalog ladder. Empty or absent omits every thinking-intensity field. */
17
+ reasoningEfforts?: string[];
18
+ defaultReasoningEffort?: string;
11
19
  }
12
20
 
13
21
  export interface GrokInjectResult {
@@ -19,10 +27,11 @@ export interface GrokInjectResult {
19
27
 
20
28
  const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
21
29
  const END_MARKER = "# <<< opencodex managed block <<<";
22
- // grok 0.2.101 verified live (2026-07-23): [model_providers.<id>] inheritance parses but the
23
- // inherited base_url is NOT applied to inference routing — the turn falls through to the default
24
- // cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its
25
- // own base_url/api_backend/api_key and no [model_providers] table is emitted at all.
30
+ // Grok 0.2.109 (2026-07-21) shipped working [model_providers.<id>] inheritance: base_url,
31
+ // api_backend, api_key, and extra_headers declared on the provider are applied to inference
32
+ // routing for inheriting models (verified in grok-build's with_provider_defaults
33
+ // resolve_model_list sampling_config_for_model SamplingClient chain). We emit one shared
34
+ // [model_providers.opencodex] table and each [model.*] references it via model_provider.
26
35
 
27
36
  /**
28
37
  * INTERNAL API shared with `./inspect` (WP2, devlog 260803_integrations_toggle_all/012).
@@ -71,30 +80,216 @@ export function findManagedRegion(content: string): ManagedRegion | null {
71
80
  * `[model.<alias>]` header must be canonicalized before comparison.
72
81
  */
73
82
  const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
83
+ const DOTTED_KEY = String.raw`${KEY_SEGMENT}(?:[ \t]*\.[ \t]*${KEY_SEGMENT})*`;
84
+ /** One complete TOML table-header line; paired brackets reject array-value lookalikes. */
85
+ const TABLE_HEADER_LINE = new RegExp(
86
+ String.raw`^[ \t]*(?:\[\[[ \t]*(${DOTTED_KEY})[ \t]*\]\]|\[[ \t]*(${DOTTED_KEY})[ \t]*\])[ \t]*(?:#[^\r\n]*)?$`,
87
+ );
88
+
89
+ interface TomlTableHeader {
90
+ index: number;
91
+ length: number;
92
+ segments: string[];
93
+ array: boolean;
94
+ }
95
+
96
+ interface TomlStructure {
97
+ view: string;
98
+ headers: TomlTableHeader[];
99
+ containerRootLineStarts: Set<number>;
100
+ }
101
+
102
+ /** End of a TOML multi-line basic/literal string, or EOF when it is unclosed. */
103
+ function tomlMultilineStringEnd(content: string, start: number, quote: '"' | "'"): number {
104
+ let cursor = start + 3;
105
+ while (cursor < content.length) {
106
+ if (quote === '"' && content[cursor] === "\\") {
107
+ cursor += 2;
108
+ continue;
109
+ }
110
+ if (content[cursor] === quote
111
+ && content[cursor + 1] === quote
112
+ && content[cursor + 2] === quote) {
113
+ let end = cursor + 3;
114
+ // TOML permits one or two quote characters immediately before the closing delimiter.
115
+ if (content[end] === quote) {
116
+ end += 1;
117
+ if (content[end] === quote) end += 1;
118
+ }
119
+ return end;
120
+ }
121
+ cursor += 1;
122
+ }
123
+ return content.length;
124
+ }
125
+
126
+ /** Find one TOML string value's exact source span; semantic decoding uses Bun's parser. */
127
+ function tomlStringSpanAt(content: string, start: number): { end: number } | null {
128
+ const quote = content[start];
129
+ if (quote !== '"' && quote !== "'") return null;
130
+ if (content[start + 1] === quote && content[start + 2] === quote) {
131
+ const end = tomlMultilineStringEnd(content, start, quote);
132
+ const token = content.slice(start, end);
133
+ if (token.length < 6 || !token.endsWith(quote.repeat(3))) return null;
134
+ return { end };
135
+ }
136
+
137
+ for (let cursor = start + 1; cursor < content.length; cursor += 1) {
138
+ const char = content[cursor]!;
139
+ if (char === "\r" || char === "\n") return null;
140
+ if (quote === '"' && char === "\\") {
141
+ cursor += 1;
142
+ continue;
143
+ }
144
+ if (char === quote) {
145
+ return { end: cursor + 1 };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** Find the matching end of one inline table / array while skipping strings and comments. */
152
+ function tomlContainerEnd(content: string, start: number): number | null {
153
+ const opener = content[start];
154
+ if (opener !== "{" && opener !== "[") return null;
155
+ const stack: string[] = [opener];
156
+ for (let index = start + 1; index < content.length;) {
157
+ const char = content[index]!;
158
+ if (char === "#") {
159
+ const newline = content.indexOf("\n", index);
160
+ index = newline === -1 ? content.length : newline + 1;
161
+ continue;
162
+ }
163
+ if (char === '"' || char === "'") {
164
+ const span = tomlStringSpanAt(content, index);
165
+ if (span === null) return null;
166
+ index = span.end;
167
+ continue;
168
+ }
169
+ if (char === "{" || char === "[") stack.push(char);
170
+ else if (char === "}" || char === "]") {
171
+ const expected = char === "}" ? "{" : "[";
172
+ if (stack.pop() !== expected) return null;
173
+ if (stack.length === 0) return index + 1;
174
+ }
175
+ index += 1;
176
+ }
177
+ return null;
178
+ }
179
+
74
180
  /**
75
- * User-owned model table headers. Also matches array-of-table (`[[model.x]]`) and sub-table
76
- * (`[model.x.sub]`) spellings. `[[model.x]]` genuinely collides with a generated `[model.x]`,
77
- * and one collision makes grok reject the ENTIRE config layer ("duplicate key"), taking every
78
- * unrelated user setting with it; `[model.x.sub]` does not strictly collide, but reserving it
79
- * costs only a suffixed alias and keeps us clear of the user's namespace.
80
- *
81
- * Every character class here is newline-free ON PURPOSE. With `[^\]]*` the optional sub-table
82
- * tail runs past the end of its own line, so an unclosed `[model.…` inside a multiline string
83
- * swallows the following lines — including a real `[model.<alias>]` header, which then goes
84
- * unreserved and produces the very duplicate-key config this scan exists to prevent.
181
+ * A same-length lexical projection for structural scans. Triple-quoted string bytes become
182
+ * spaces while line endings and every byte outside those values keep their original offsets.
85
183
  */
86
- const MODEL_TABLE_HEADER = new RegExp(
87
- String.raw`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`,
88
- "gm",
89
- );
184
+ function tomlStructuralView(content: string): string {
185
+ let state: "code" | "comment" | "basic" | "literal" = "code";
186
+ let cursor = 0;
187
+ let output = "";
188
+ for (let index = 0; index < content.length;) {
189
+ const char = content[index]!;
190
+ if (state === "comment") {
191
+ if (char === "\n") state = "code";
192
+ index += 1;
193
+ continue;
194
+ }
195
+ if (state === "basic") {
196
+ if (char === "\\") index += 2;
197
+ else {
198
+ if (char === '"') state = "code";
199
+ index += 1;
200
+ }
201
+ continue;
202
+ }
203
+ if (state === "literal") {
204
+ if (char === "'") state = "code";
205
+ index += 1;
206
+ continue;
207
+ }
208
+ if (char === "#") {
209
+ state = "comment";
210
+ index += 1;
211
+ continue;
212
+ }
213
+ if (char === '"' || char === "'") {
214
+ if (content[index + 1] === char && content[index + 2] === char) {
215
+ const end = tomlMultilineStringEnd(content, index, char);
216
+ output += content.slice(cursor, index);
217
+ output += content.slice(index, end).replace(/[^\r\n]/g, " ");
218
+ cursor = end;
219
+ index = end;
220
+ continue;
221
+ }
222
+ state = char === '"' ? "basic" : "literal";
223
+ }
224
+ index += 1;
225
+ }
226
+ return output.length === 0 ? content : output + content.slice(cursor);
227
+ }
228
+
229
+ /** Update array / inline-table nesting for one non-header line in the structural view. */
230
+ function tomlContainerDepthAfterLine(line: string, initialDepth: number): number {
231
+ let depth = initialDepth;
232
+ let state: "code" | "basic" | "literal" = "code";
233
+ for (let index = 0; index < line.length;) {
234
+ const char = line[index]!;
235
+ if (state === "basic") {
236
+ if (char === "\\") index += 2;
237
+ else {
238
+ if (char === '"') state = "code";
239
+ index += 1;
240
+ }
241
+ continue;
242
+ }
243
+ if (state === "literal") {
244
+ if (char === "'") state = "code";
245
+ index += 1;
246
+ continue;
247
+ }
248
+ if (char === "#") break;
249
+ if (char === '"' || char === "'") {
250
+ state = char === '"' ? "basic" : "literal";
251
+ index += 1;
252
+ continue;
253
+ }
254
+ if (char === "[" || char === "{") depth += 1;
255
+ else if (char === "]" || char === "}") depth = Math.max(0, depth - 1);
256
+ index += 1;
257
+ }
258
+ return depth;
259
+ }
90
260
 
91
261
  /**
92
- * ANY table header, capturing its full dotted key. Used to compute table SPANS: a table
93
- * body runs from its own header to the next header of any kind, so the orphan sweep can
94
- * remove a whole table instead of a guessed line range (a partial removal would re-parent
95
- * the leftover keys onto the preceding table).
262
+ * Find real table headers and assignment-eligible lines while excluding arrays, inline tables,
263
+ * comments, and multi-line strings. Offsets remain exact because `view` is length-preserving.
96
264
  */
97
- const ANY_TABLE_HEADER = /^[ \t]*\[\[?[ \t]*([^\]\r\n]*?)[ \t]*\]\]?[ \t]*(?:#.*)?$/gm;
265
+ function analyzeTomlStructure(content: string): TomlStructure {
266
+ const view = tomlStructuralView(content);
267
+ const headers: TomlTableHeader[] = [];
268
+ const containerRootLineStarts = new Set<number>();
269
+ let depth = 0;
270
+ for (let lineStart = 0; lineStart <= view.length;) {
271
+ const newline = view.indexOf("\n", lineStart);
272
+ const lineEnd = newline === -1 ? view.length : newline;
273
+ const rawLine = view.slice(lineStart, lineEnd);
274
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
275
+ const header = depth === 0 ? TABLE_HEADER_LINE.exec(line) : null;
276
+ if (header) {
277
+ const dottedKey = header[1] ?? header[2]!;
278
+ headers.push({
279
+ index: lineStart,
280
+ length: header[0].length,
281
+ segments: canonicalDottedKey(dottedKey),
282
+ array: header[1] !== undefined,
283
+ });
284
+ } else {
285
+ if (depth === 0) containerRootLineStarts.add(lineStart);
286
+ depth = tomlContainerDepthAfterLine(line, depth);
287
+ }
288
+ if (newline === -1) break;
289
+ lineStart = newline + 1;
290
+ }
291
+ return { view, headers, containerRootLineStarts };
292
+ }
98
293
 
99
294
  /** Resolve a header key segment (bare / basic / literal) to the key it actually addresses. */
100
295
  function canonicalKeySegment(raw: string): string {
@@ -103,6 +298,12 @@ function canonicalKeySegment(raw: string): string {
103
298
  return raw;
104
299
  }
105
300
 
301
+ /** Split a TOML dotted key without treating dots inside quoted segments as separators. */
302
+ function canonicalDottedKey(raw: string): string[] {
303
+ return [...raw.matchAll(new RegExp(KEY_SEGMENT, "g"))]
304
+ .map(match => canonicalKeySegment(match[0]!));
305
+ }
306
+
106
307
  /**
107
308
  * `[model.<alias>]` table headers the USER owns (outside our fence) — reserved for collisions.
108
309
  * TOML admits equivalent header spellings for BOTH segments (`["model"."ocx-mine"]`,
@@ -114,40 +315,71 @@ function userModelAliases(content: string, region: ManagedRegion | null): Set<st
114
315
  ? content.slice(0, region.start) + content.slice(region.end)
115
316
  : content;
116
317
  const aliases = new Set<string>();
117
- for (const match of outsideManagedRegion.matchAll(MODEL_TABLE_HEADER)) {
118
- if (canonicalKeySegment(match[1]!) !== "model") continue;
119
- aliases.add(canonicalKeySegment(match[2]!));
318
+ for (const header of analyzeTomlStructure(outsideManagedRegion).headers) {
319
+ if (header.segments[0] !== "model" || header.segments.length < 2) continue;
320
+ aliases.add(header.segments[1]!);
120
321
  }
121
322
  return aliases;
122
323
  }
123
324
 
124
- /**
125
- * The api_key literal every generated entry carries. It is the STRONG ownership signal:
126
- * a value we mint, that a human has no reason to type by hand.
127
- */
325
+ /** The api_key literal every generated entry carries. It is necessary, but not ownership alone. */
128
326
  const OPENCODEX_API_KEY = "opencodex-loopback";
327
+ const OPENCODEX_GROK_MARKER = "x-opencodex-grok";
328
+
329
+ /** The provider id opencodex owns inside ~/.grok/config.toml. */
330
+ const OPENCODEX_PROVIDER_ID = "opencodex";
129
331
 
130
332
  /** A plain `[model.<alias>]` table outside the fence that opencodex itself wrote. */
131
333
  interface OrphanTable {
132
334
  alias: string;
133
335
  /** The model id this entry routes to — used to find its replacement alias. */
134
- modelId: string | undefined;
336
+ modelId: string;
337
+ /** Explicit markers authorize teardown; legacy fingerprints authorize replacement only. */
338
+ ownership: "explicit" | "legacy";
135
339
  /** Offsets into the NORMALIZED content: header start .. next header start (or EOF). */
136
340
  start: number;
137
341
  end: number;
342
+ /** Re-serialized child tables may be separated from the parent by unrelated tables. */
343
+ additionalRanges: Array<{ start: number; end: number }>;
138
344
  }
139
345
 
140
346
  /** `key = "value"` / `key = value` pairs at the top level of one table body. */
141
347
  function tableBodyKeys(body: string): Map<string, string> {
142
348
  const keys = new Map<string, string>();
143
- for (const line of body.split("\n")) {
144
- const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/.exec(line);
145
- if (!match) continue;
349
+ const structure = analyzeTomlStructure(body);
350
+ // Bare keys only: a quoted dotted segment could otherwise split a quoted value
351
+ // containing a dot.
352
+ const assignment =
353
+ /^[ \t]*([A-Za-z0-9_-]+(?:[ \t]*\.[ \t]*[A-Za-z0-9_-]+)*)[ \t]*=[ \t]*(.*?)[ \t]*$/gm;
354
+ for (const match of structure.view.matchAll(assignment)) {
355
+ if (!structure.containerRootLineStarts.has(match.index!)) continue;
356
+ const path = match[1]!.split(".").map(part => part.trim());
146
357
  const raw = match[2]!;
147
- const value = raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2
358
+ const value = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')
148
359
  ? decodeTomlBasicString(raw.slice(1, -1))
149
- : raw;
150
- if (!keys.has(match[1]!)) keys.set(match[1]!, value);
360
+ : raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")
361
+ ? raw.slice(1, -1) // TOML literal strings do not process escapes.
362
+ : raw;
363
+ if (path.length === 1) {
364
+ if (!keys.has(path[0]!)) keys.set(path[0]!, value);
365
+ continue;
366
+ }
367
+ // Dotted keys re-open a nested namespace: `extra_headers.k = v` is the key `k` of the
368
+ // sub-table `extra_headers`, which a folded-body reader must be able to see. Rebuild
369
+ // the inline-table spelling that hasInlineOwnershipMarker matches (bare word booleans
370
+ // and numbers keep their TOML spelling — no quoting, so the regex is unchanged).
371
+ let suffix = value;
372
+ for (let level = path.length - 1; level >= 1; level -= 1) {
373
+ const prefixKey = path.slice(0, level).join(".");
374
+ const inner = `${JSON.stringify(path[level]!)} = ${suffix}`;
375
+ suffix = `{ ${inner} }`;
376
+ const existing = keys.get(prefixKey);
377
+ if (existing === undefined || !existing.startsWith("{")) {
378
+ keys.set(prefixKey, suffix);
379
+ } else {
380
+ keys.set(prefixKey, `{ ${existing.slice(2, -2)}, ${inner} }`);
381
+ }
382
+ }
151
383
  }
152
384
  return keys;
153
385
  }
@@ -162,6 +394,46 @@ function isLoopbackBaseUrl(value: string | undefined): boolean {
162
394
  }
163
395
  }
164
396
 
397
+ /** Exact marker emitted inside every modern generated model table. */
398
+ function hasInlineOwnershipMarker(value: string | undefined): boolean {
399
+ // The reconstructed fold of a Grok dotted re-serialization writes bare `1` for the
400
+ // boolean literal, so both `= "1"` and `= 1` spellings are accepted here.
401
+ return value !== undefined
402
+ && /^\{[ \t]*["']x-opencodex-grok["'][ \t]*=[ \t]*(?:"1"|'1'|1)[ \t]*\}$/.test(value);
403
+ }
404
+
405
+ /** Historical deterministic alias, including collision suffixes allocated by the writer. */
406
+ function isGeneratedAliasForModel(alias: string, modelId: string): boolean {
407
+ const base = `ocx-${modelId.replace(/[^A-Za-z0-9_-]/g, "-")}`;
408
+ if (alias === base) return true;
409
+ if (!alias.startsWith(`${base}-`)) return false;
410
+ const suffix = alias.slice(base.length + 1);
411
+ return /^[1-9][0-9]*$/.test(suffix) && Number(suffix) >= 2;
412
+ }
413
+
414
+ /** Pre-marker auto-generated row shape. Manual rows never carried the generated name. */
415
+ function isLegacyGeneratedTable(alias: string, keys: ReadonlyMap<string, string>): boolean {
416
+ const modelId = keys.get("model");
417
+ return modelId !== undefined
418
+ && modelId.length > 0
419
+ && keys.get("api_backend") === "chat_completions"
420
+ && keys.get("name") === `OCX ${modelId}`
421
+ && isGeneratedAliasForModel(alias, modelId);
422
+ }
423
+
424
+ /** Classify a direct provider/model id without stealing a slash-shaped configured combo alias. */
425
+ function isDisabledProviderModelId(
426
+ modelId: string,
427
+ disabledProviderNamespaces: ReadonlySet<string> | undefined,
428
+ comboPublicModelIds: ReadonlySet<string> | undefined,
429
+ ): boolean {
430
+ if (!disabledProviderNamespaces || comboPublicModelIds?.has(modelId)) return false;
431
+ const slash = modelId.indexOf("/");
432
+ return slash > 0
433
+ && slash < modelId.length - 1
434
+ && disabledProviderNamespaces.has(modelId.slice(0, slash));
435
+ }
436
+
165
437
  /**
166
438
  * Model tables OUTSIDE the fence that opencodex itself wrote (#511).
167
439
  *
@@ -172,14 +444,34 @@ function isLoopbackBaseUrl(value: string | undefined): boolean {
172
444
  * resolves the original, finds no `context_window`, and falls back to its own 200k.
173
445
  *
174
446
  * Ownership is CONJUNCTIVE and deliberately strict, because a false positive deletes a
175
- * hand-written user model:
447
+ * hand-written user model. The public manual recipe intentionally uses the same loopback key,
448
+ * endpoint, and Responses backend, so those fields are not ownership proof. We additionally
449
+ * require either the durable generated marker or the exact pre-marker legacy fingerprint:
176
450
  * - a plain `[model.x]` header (never `[[model.x]]` / `[model.x.sub]` — those spellings
177
451
  * mark human authorship and stay reserved);
178
452
  * - `api_key` equal to our own literal;
179
453
  * - a loopback `base_url`, so an entry that merely copied our key while pointing at a
180
454
  * remote host is left alone.
455
+ * - `x-opencodex-grok = "1"` in generated inline/child extra_headers, OR the historical
456
+ * chat_completions + `name = "OCX <model>"` + deterministic generated alias shape.
457
+ * - PROVIDER-INHERITANCE shape: `model_provider = "opencodex"` with no api_key/base_url of
458
+ * its own, adopting the verdict of the `[model_providers.opencodex]` table it references
459
+ * (the current block shape carries no per-model evidence; this mirrors Codex-side
460
+ * classifyCodexRouting).
181
461
  * A loopback base_url ALONE is not enough: aiming your own model at the local proxy is a
182
462
  * legitimate thing to do.
463
+ *
464
+ * Also sweeps orphaned `[model_providers.opencodex]` blocks from a previous managed block
465
+ * that used the provider-inheritance shape. Grok's re-serializer promotes the inline
466
+ * `extra_headers = { ... }` into a separate `[model_providers.<id>.extra_headers]`
467
+ * sub-table, which can split the parent's body (its own keys then live inside the child's
468
+ * span) and may interleave user tables between the parent and its children, so each
469
+ * provider's body is FOLDED with all its same-provider descendants before judging, and
470
+ * the removal span covers them by their exact ranges. The fenced provider is excluded
471
+ * from that sweep (the splice owns it) but still counts as ownership evidence, so
472
+ * teardown does not orphan models that inherit from it. The
473
+ * durable marker lives on the provider (never on the inheriting model), so the sweep is
474
+ * what keeps explicit ownership of inherited entries verifiable after a rewrite.
183
475
  */
184
476
  function findOpencodexOrphans(content: string, region: ManagedRegion | null): OrphanTable[] {
185
477
  const orphans: OrphanTable[] = [];
@@ -194,14 +486,64 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or
194
486
  const clampEnd = (start: number, end: number): number =>
195
487
  fenceStart >= 0 && start < fenceStart ? Math.min(end, fenceStart) : end;
196
488
  // Collect every table header first: a table body runs to the NEXT header, whatever it is.
197
- const headers: Array<{ index: number; length: number; segments: string[]; array: boolean }> = [];
198
- for (const match of content.matchAll(ANY_TABLE_HEADER)) {
199
- headers.push({
200
- index: match.index!,
201
- length: match[0].length,
202
- segments: match[1]!.split(".").map(part => canonicalKeySegment(part.trim())),
203
- array: match[0].trimStart().startsWith("[["),
204
- });
489
+ const headers = analyzeTomlStructure(content).headers;
490
+ // [model_providers.<id>] tables outside the fence, folded with their own sub-tables (see
491
+ // the function doc). A table passing the predicate (our api_key literal + a loopback
492
+ // base_url + the durable marker inline or in a re-serialized child) contributes to
493
+ // `ownedProviderIds` for the model scan below; one with OUR id is additionally swept as
494
+ // an orphan of a previous managed block (a leftover here collides with the regenerated
495
+ // block's provider table — duplicate key — and alias rewriting skips provider orphans
496
+ // because they have no alias and no model id). The dot-terminated prefix keeps a user's
497
+ // `[model_providers.opencodex_backup]` out of scope.
498
+ const ownedProviderIds = new Set<string>();
499
+ for (const [position, header] of headers.entries()) {
500
+ if (header.array || header.segments.length !== 2 || header.segments[0] !== "model_providers") continue;
501
+ // Inside the fence the regular splice owns the table, but it is still ownership
502
+ // evidence: models kept outside the fence after a Grok rewrite (retired ids) inherit
503
+ // their verdict from the fenced provider, so classification must happen while the
504
+ // fence still exists or teardown leaves them with a dangling model_provider reference.
505
+ const insideRegion = region !== null
506
+ && header.index >= region.start && header.index < region.end;
507
+ const end = clampEnd(header.index, headers[position + 1]?.index ?? content.length);
508
+ let body = content.slice(header.index + header.length, end);
509
+ // Re-serialized children may sit non-contiguously (a user table can interleave), so
510
+ // fold every same-provider descendant globally, like the model scan below, and remove
511
+ // them by their exact ranges. Never fold across the fence: a pre-fence parent must
512
+ // judge on pre-fence bytes only, and fenced or below-fence content is not the orphan's.
513
+ const additionalRanges: Array<{ start: number; end: number }> = [];
514
+ for (let next = 0; next < headers.length; next += 1) {
515
+ if (next === position) continue;
516
+ const child = headers[next]!;
517
+ if (region && child.index >= region.start && child.index < region.end) continue;
518
+ if (fenceStart >= 0
519
+ && (header.index < fenceStart) !== (child.index < fenceStart)) continue;
520
+ if (child.segments.length <= 2
521
+ || child.segments[0] !== "model_providers"
522
+ || child.segments[1] !== header.segments[1]) continue;
523
+ const childEnd = clampEnd(child.index, headers[next + 1]?.index ?? content.length);
524
+ body += "\n" + content.slice(child.index + child.length, childEnd);
525
+ additionalRanges.push({ start: child.index, end: childEnd });
526
+ }
527
+ const keys = tableBodyKeys(body);
528
+ if (keys.get("api_key") !== OPENCODEX_API_KEY) continue;
529
+ if (!isLoopbackBaseUrl(keys.get("base_url"))) continue;
530
+ // The durable marker may sit inline on the provider, or be promoted by Grok's
531
+ // re-serializer into `[model_providers.<id>.extra_headers]` — where the folded body
532
+ // shows it as a bare `x-opencodex-grok = "1"` assignment. Both forms decide.
533
+ if (!hasInlineOwnershipMarker(keys.get("extra_headers"))
534
+ && keys.get(OPENCODEX_GROK_MARKER) !== "1") continue;
535
+ ownedProviderIds.add(header.segments[1]!);
536
+ if (insideRegion) continue;
537
+ if (header.segments[1] === OPENCODEX_PROVIDER_ID) {
538
+ orphans.push({
539
+ alias: "",
540
+ modelId: "",
541
+ ownership: "explicit",
542
+ start: header.index,
543
+ end,
544
+ additionalRanges,
545
+ });
546
+ }
205
547
  }
206
548
  for (const [position, header] of headers.entries()) {
207
549
  if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue;
@@ -209,52 +551,413 @@ function findOpencodexOrphans(content: string, region: ManagedRegion | null): Or
209
551
  if (region && header.index >= region.start && header.index < region.end) continue;
210
552
  const bodyEnd = clampEnd(header.index, headers[position + 1]?.index ?? content.length);
211
553
  const keys = tableBodyKeys(content.slice(header.index + header.length, bodyEnd));
212
- if (keys.get("api_key") !== OPENCODEX_API_KEY) continue;
213
- if (!isLoopbackBaseUrl(keys.get("base_url"))) continue;
214
- // Swallow the entry's OWN sub-tables (`[model.<alias>.extra_headers]`). Grok writes
215
- // them when it re-serializes the file, and leaving one behind keeps the alias
216
- // reserved by `userModelAliases` so the sweep would remove the parent and STILL
217
- // allocate a suffixed duplicate, which is the exact #511 loop we came to close.
218
- let end = bodyEnd;
219
- for (let next = position + 1; next < headers.length; next += 1) {
554
+ const modelId = keys.get("model");
555
+ if (!modelId) continue;
556
+ // Two shapes carry our ownership signal. The current managed block routes every model
557
+ // through a shared provider table (`model_provider = "opencodex"`), so a re-serialized
558
+ // unfenced entry has NO api_key/base_url of its own the evidence lives on the provider
559
+ // table it references (Codex-side precedent: classifyCodexRouting follows model_provider
560
+ // for the same reason). Inheritance is accepted only from a provider that itself passed
561
+ // the strict predicate above, and only for rows whose alias carries the generated
562
+ // fingerprint: a user is free to reference the managed provider from their own
563
+ // [model.*] table, and inheritance alone must not grant removal authority over it.
564
+ const providerId = keys.get("model_provider");
565
+ const inheritedOwned =
566
+ providerId === OPENCODEX_PROVIDER_ID
567
+ && ownedProviderIds.has(OPENCODEX_PROVIDER_ID)
568
+ && isGeneratedAliasForModel(header.segments[1]!, modelId);
569
+ if (!inheritedOwned) {
570
+ if (keys.get("api_key") !== OPENCODEX_API_KEY) continue;
571
+ if (!isLoopbackBaseUrl(keys.get("base_url"))) continue;
572
+ }
573
+ let hasOwnershipMarker = hasInlineOwnershipMarker(keys.get("extra_headers"));
574
+ // Swallow the entry's OWN sub-tables (`[model.<alias>.extra_headers]`, and after #1756
575
+ // `[[model.<alias>.reasoning_efforts]]`). Grok may re-serialize them non-contiguously,
576
+ // so collect exact descendant spans globally rather than stopping at the first
577
+ // unrelated table.
578
+ const additionalRanges: Array<{ start: number; end: number }> = [];
579
+ for (let next = 0; next < headers.length; next += 1) {
580
+ if (next === position) continue;
220
581
  const child = headers[next]!;
221
- // Only a PRE-fence parent may be cut short by the fence. Without the parent test a
222
- // below-fence orphan would break on its first child (every index is past the fence),
223
- // leaving the sub-table behind to keep the alias reserved — the -2 loop again.
224
- if (fenceStart >= 0 && header.index < fenceStart && child.index >= fenceStart) break;
225
- if (child.segments.length <= 2) break;
226
- if (child.segments[0] !== "model" || child.segments[1] !== header.segments[1]) break;
227
- end = clampEnd(header.index, headers[next + 1]?.index ?? content.length);
582
+ if (region && child.index >= region.start && child.index < region.end) continue;
583
+ if (child.segments.length <= 2
584
+ || child.segments[0] !== "model"
585
+ || child.segments[1] !== header.segments[1]) continue;
586
+ const childEnd = clampEnd(child.index, headers[next + 1]?.index ?? content.length);
587
+ additionalRanges.push({ start: child.index, end: childEnd });
588
+ if (!child.array && child.segments.length === 3 && child.segments[2] === "extra_headers") {
589
+ const childKeys = tableBodyKeys(content.slice(child.index + child.length, childEnd));
590
+ if (childKeys.get(OPENCODEX_GROK_MARKER) === "1") hasOwnershipMarker = true;
591
+ }
228
592
  }
229
- orphans.push({ alias: header.segments[1]!, modelId: keys.get("model"), start: header.index, end });
593
+ const legacyGenerated = isLegacyGeneratedTable(header.segments[1]!, keys);
594
+ // An inherited model has no per-model marker; its verdict comes from the provider
595
+ // table it references, which only lands here when that provider proved durable
596
+ // ownership. A legacy-fingerprint model keeps dev's conservative classification.
597
+ const ownership: "explicit" | "legacy" = inheritedOwned || hasOwnershipMarker
598
+ ? "explicit"
599
+ : "legacy";
600
+ if (!hasOwnershipMarker && !legacyGenerated && !inheritedOwned) continue;
601
+ orphans.push({
602
+ alias: header.segments[1]!,
603
+ modelId,
604
+ ownership,
605
+ start: header.index,
606
+ end: bodyEnd,
607
+ additionalRanges,
608
+ });
230
609
  }
231
610
  return orphans;
232
611
  }
233
612
 
234
- /** Remove whole tables, back to front so earlier offsets stay valid. */
613
+ function orphanRanges(orphans: readonly OrphanTable[]): Array<{ start: number; end: number }> {
614
+ const unique = new Map<string, { start: number; end: number }>();
615
+ for (const orphan of orphans) {
616
+ for (const range of [{ start: orphan.start, end: orphan.end }, ...orphan.additionalRanges]) {
617
+ unique.set(`${range.start}:${range.end}`, range);
618
+ }
619
+ }
620
+ return [...unique.values()];
621
+ }
622
+
623
+ /** Remove exact whole-table ranges, back to front so earlier offsets stay valid. */
624
+ function removeTableRanges(content: string, ranges: readonly { start: number; end: number }[]): string {
625
+ let next = content;
626
+ const unique = new Map(ranges.map(range => [`${range.start}:${range.end}`, range]));
627
+ for (const range of [...unique.values()].sort((a, b) => b.start - a.start)) {
628
+ next = next.slice(0, range.start) + next.slice(range.end);
629
+ }
630
+ return next;
631
+ }
632
+
235
633
  function removeOrphanTables(content: string, orphans: OrphanTable[]): string {
634
+ return removeTableRanges(content, orphanRanges(orphans));
635
+ }
636
+
637
+ /** Model aliases and routed ids owned by one complete managed region. */
638
+ function managedModelAliases(content: string, region: ManagedRegion | null): Map<string, string> {
639
+ const models = new Map<string, string>();
640
+ if (!region) return models;
641
+ const structure = analyzeTomlStructure(content);
642
+ for (const [position, header] of structure.headers.entries()) {
643
+ if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue;
644
+ if (header.index < region.start || header.index >= region.end) continue;
645
+ const bodyEnd = Math.min(structure.headers[position + 1]?.index ?? content.length, region.end);
646
+ const modelId = tableBodyKeys(content.slice(header.index + header.length, bodyEnd)).get("model");
647
+ if (modelId !== undefined) models.set(header.segments[1]!, modelId);
648
+ }
649
+ return models;
650
+ }
651
+
652
+ /** Read one exact path from an already parsed TOML document. */
653
+ type TomlPathSegment = string | number;
654
+
655
+ function tomlPathString(document: unknown, path: readonly TomlPathSegment[]): string | null {
656
+ let value = document;
657
+ for (const segment of path) {
658
+ if (typeof segment === "number") {
659
+ if (!Array.isArray(value)) return null;
660
+ value = value[segment];
661
+ } else {
662
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
663
+ value = (value as Record<string, unknown>)[segment];
664
+ }
665
+ }
666
+ return typeof value === "string" ? value : null;
667
+ }
668
+
669
+ /** Parse a probe document and read one exact semantic path. */
670
+ function parsedTomlPathString(content: string, path: readonly TomlPathSegment[]): string | null {
671
+ try {
672
+ return tomlPathString(Bun.TOML.parse(content), path);
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+
678
+ type ModelReferencePatternSegment = string | "*";
679
+
680
+ interface ModelReferencePath {
681
+ path: readonly ModelReferencePatternSegment[];
682
+ /** A structured reference assignment that can be removed whole without losing sibling config. */
683
+ removableContainerPath?: readonly string[];
684
+ }
685
+
686
+ /** Grok config values whose strings resolve through the `[model.<alias>]` catalog. */
687
+ const MODEL_REFERENCE_PATHS: readonly ModelReferencePath[] = [
688
+ { path: ["models", "default"] },
689
+ { path: ["models", "web_search"] },
690
+ { path: ["models", "session_summary"] },
691
+ { path: ["models", "image_description"] },
692
+ { path: ["models", "prompt_suggestion"] },
693
+ { path: ["ui", "fork_secondary_model"] },
694
+ { path: ["subagents", "models", "*"] },
695
+ { path: ["subagents", "roles", "*", "model"] },
696
+ { path: ["subagents", "personas", "*", "model"] },
697
+ { path: ["auto_mode", "classifier_model"] },
698
+ {
699
+ path: ["goal", "planner_model", "model"],
700
+ removableContainerPath: ["goal", "planner_model"],
701
+ },
702
+ {
703
+ path: ["goal", "strategist_model", "model"],
704
+ removableContainerPath: ["goal", "strategist_model"],
705
+ },
706
+ {
707
+ path: ["goal", "skeptic_models", "*", "model"],
708
+ removableContainerPath: ["goal", "skeptic_models"],
709
+ },
710
+ ];
711
+
712
+ interface AliasReference {
713
+ path: TomlPathSegment[];
714
+ alias: string;
715
+ removableContainerPath?: readonly string[];
716
+ }
717
+
718
+ function collectAliasReferences(document: unknown): AliasReference[] {
719
+ const references: AliasReference[] = [];
720
+ const visit = (
721
+ value: unknown,
722
+ pattern: readonly ModelReferencePatternSegment[],
723
+ patternIndex: number,
724
+ path: TomlPathSegment[],
725
+ removableContainerPath: readonly string[] | undefined,
726
+ ): void => {
727
+ if (patternIndex === pattern.length) {
728
+ if (typeof value === "string") {
729
+ references.push({
730
+ path,
731
+ alias: value,
732
+ ...(removableContainerPath ? { removableContainerPath } : {}),
733
+ });
734
+ }
735
+ return;
736
+ }
737
+ const segment = pattern[patternIndex]!;
738
+ if (segment === "*") {
739
+ if (Array.isArray(value)) {
740
+ for (const [index, item] of value.entries()) {
741
+ visit(item, pattern, patternIndex + 1, [...path, index], removableContainerPath);
742
+ }
743
+ } else if (typeof value === "object" && value !== null) {
744
+ for (const [key, item] of Object.entries(value)) {
745
+ visit(item, pattern, patternIndex + 1, [...path, key], removableContainerPath);
746
+ }
747
+ }
748
+ return;
749
+ }
750
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
751
+ visit(
752
+ (value as Record<string, unknown>)[segment],
753
+ pattern,
754
+ patternIndex + 1,
755
+ [...path, segment],
756
+ removableContainerPath,
757
+ );
758
+ };
759
+
760
+ for (const reference of MODEL_REFERENCE_PATHS) {
761
+ visit(document, reference.path, 0, [], reference.removableContainerPath);
762
+ }
763
+ return references;
764
+ }
765
+
766
+ function sourcePath(path: readonly TomlPathSegment[]): string[] {
767
+ return path.filter((segment): segment is string => typeof segment === "string");
768
+ }
769
+
770
+ function pathsEqual(left: readonly string[], right: readonly string[]): boolean {
771
+ return left.length === right.length && left.every((segment, index) => segment === right[index]);
772
+ }
773
+
774
+ function pathStartsWith(path: readonly string[], prefix: readonly string[]): boolean {
775
+ return path.length >= prefix.length
776
+ && prefix.every((segment, index) => segment === path[index]);
777
+ }
778
+
779
+ function tomlContainerStringSpans(
780
+ content: string,
781
+ start: number,
782
+ end: number,
783
+ ): Array<{ start: number; end: number }> {
784
+ const spans: Array<{ start: number; end: number }> = [];
785
+ for (let index = start + 1; index < end - 1;) {
786
+ const char = content[index]!;
787
+ if (char === "#") {
788
+ const newline = content.indexOf("\n", index);
789
+ index = newline === -1 || newline >= end ? end : newline + 1;
790
+ continue;
791
+ }
792
+ if (char === '"' || char === "'") {
793
+ const span = tomlStringSpanAt(content, index);
794
+ if (span === null || span.end > end) return [];
795
+ spans.push({ start: index, end: span.end });
796
+ index = span.end;
797
+ continue;
798
+ }
799
+ index += 1;
800
+ }
801
+ return spans;
802
+ }
803
+
804
+ interface AliasReferenceCandidate {
805
+ valueStart: number;
806
+ valueEnd: number;
807
+ assignmentPath: string[];
808
+ directLine: { start: number; end: number } | null;
809
+ containerLine: { start: number; end: number } | null;
810
+ }
811
+
812
+ /** Rename or remove every declared semantic model reference without touching user prose. */
813
+ function transformAliasReferences(
814
+ content: string,
815
+ replacements: ReadonlyMap<string, string | null>,
816
+ allowRootDotted = true,
817
+ ): string {
818
+ if (replacements.size === 0) return content;
819
+ let document: unknown;
820
+ try {
821
+ document = Bun.TOML.parse(content);
822
+ } catch {
823
+ throw new Error(
824
+ "Grok config rewrite refused: Bun could not parse the TOML document safely.",
825
+ );
826
+ }
827
+ const references = collectAliasReferences(document);
828
+ const targets = references.filter(reference => replacements.has(reference.alias));
829
+ if (targets.length === 0) return content;
830
+ const structure = analyzeTomlStructure(content);
831
+ const edits: Array<{ start: number; end: number; replacement: string }> = [];
832
+ const candidates: AliasReferenceCandidate[] = [];
833
+ const assignment = new RegExp(String.raw`^([ \t]*(${DOTTED_KEY})[ \t]*=)`, "gm");
834
+ let headerPosition = -1;
835
+ for (const match of structure.view.matchAll(assignment)) {
836
+ const assignmentStart = match.index!;
837
+ if (!structure.containerRootLineStarts.has(assignmentStart)) continue;
838
+ while ((structure.headers[headerPosition + 1]?.index ?? Number.POSITIVE_INFINITY)
839
+ < assignmentStart) headerPosition += 1;
840
+ const currentHeader = headerPosition >= 0 ? structure.headers[headerPosition]! : null;
841
+ if (!allowRootDotted && currentHeader === null) continue;
842
+ const segments = canonicalDottedKey(match[2]!);
843
+ const assignmentPath = [...(currentHeader?.segments ?? []), ...segments];
844
+ let valueStart = assignmentStart + match[1]!.length;
845
+ while (content[valueStart] === " " || content[valueStart] === "\t") valueStart += 1;
846
+ const directTargets = targets.filter(target => pathsEqual(sourcePath(target.path), assignmentPath));
847
+ if (directTargets.length > 0) {
848
+ const value = tomlStringSpanAt(content, valueStart);
849
+ if (value !== null) {
850
+ const suffix = /^[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(content.slice(value.end));
851
+ if (suffix !== null) {
852
+ candidates.push({
853
+ valueStart,
854
+ valueEnd: value.end,
855
+ assignmentPath,
856
+ directLine: { start: assignmentStart, end: value.end + suffix[0].length },
857
+ containerLine: null,
858
+ });
859
+ continue;
860
+ }
861
+ }
862
+ }
863
+
864
+ const containerTargets = targets.filter(target =>
865
+ pathStartsWith(sourcePath(target.path), assignmentPath));
866
+ if (containerTargets.length === 0 || (content[valueStart] !== "{" && content[valueStart] !== "[")) continue;
867
+ const containerEnd = tomlContainerEnd(content, valueStart);
868
+ if (containerEnd === null) continue;
869
+ const suffix = /^[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(content.slice(containerEnd));
870
+ if (suffix === null) continue;
871
+ const containerLine = { start: assignmentStart, end: containerEnd + suffix[0].length };
872
+ for (const value of tomlContainerStringSpans(content, valueStart, containerEnd)) {
873
+ candidates.push({
874
+ valueStart: value.start,
875
+ valueEnd: value.end,
876
+ assignmentPath,
877
+ directLine: null,
878
+ containerLine,
879
+ });
880
+ }
881
+ }
882
+
883
+ for (const [targetIndex, target] of targets.entries()) {
884
+ const replacement = replacements.get(target.alias)!;
885
+ const targetSourcePath = sourcePath(target.path);
886
+ const probeCandidates = candidates.filter(candidate =>
887
+ pathsEqual(candidate.assignmentPath, targetSourcePath)
888
+ || pathStartsWith(targetSourcePath, candidate.assignmentPath));
889
+ if (probeCandidates.length === 0 && !allowRootDotted) continue;
890
+ if (probeCandidates.length === 0 || probeCandidates.length > 128) {
891
+ throw new Error(
892
+ "Grok config rewrite refused: the model-reference source could not be bounded safely.",
893
+ );
894
+ }
895
+ let located = false;
896
+ for (const candidate of probeCandidates) {
897
+ let sentinel = `__opencodex_reference_probe_${targetIndex}_${candidate.valueStart}__`;
898
+ while (sentinel === target.alias) sentinel += "_";
899
+ const probe = content.slice(0, candidate.valueStart)
900
+ + tomlString(sentinel)
901
+ + content.slice(candidate.valueEnd);
902
+ if (parsedTomlPathString(probe, target.path) !== sentinel) continue;
903
+ if (replacement === null) {
904
+ let removal = candidate.directLine;
905
+ if (removal === null && candidate.containerLine !== null
906
+ && target.removableContainerPath
907
+ && pathsEqual(candidate.assignmentPath, target.removableContainerPath)) {
908
+ const containerReferences = references.filter(reference =>
909
+ pathStartsWith(sourcePath(reference.path), candidate.assignmentPath));
910
+ if (containerReferences.length > 0
911
+ && containerReferences.every(reference => replacements.get(reference.alias) === null)) {
912
+ removal = candidate.containerLine;
913
+ }
914
+ }
915
+ if (removal === null) {
916
+ throw new Error(
917
+ "Grok teardown refused: a model reference uses an inline TOML shape that cannot "
918
+ + "be removed without rewriting user-owned bytes.",
919
+ );
920
+ }
921
+ edits.push({ start: removal.start, end: removal.end, replacement: "" });
922
+ } else {
923
+ edits.push({
924
+ start: candidate.valueStart,
925
+ end: candidate.valueEnd,
926
+ replacement: tomlString(replacement),
927
+ });
928
+ }
929
+ located = true;
930
+ break;
931
+ }
932
+ if (!located) {
933
+ throw new Error(
934
+ "Grok config rewrite refused: the semantic model reference could not be located safely.",
935
+ );
936
+ }
937
+ }
236
938
  let next = content;
237
- for (const orphan of [...orphans].sort((a, b) => b.start - a.start)) {
238
- next = next.slice(0, orphan.start) + next.slice(orphan.end);
939
+ const uniqueEdits = new Map(edits.map(edit => [`${edit.start}:${edit.end}:${edit.replacement}`, edit]));
940
+ for (const edit of [...uniqueEdits.values()].sort((a, b) => b.start - a.start)) {
941
+ next = next.slice(0, edit.start) + edit.replacement + next.slice(edit.end);
239
942
  }
240
943
  return next;
241
944
  }
242
945
 
243
- /**
244
- * Repoint `default` / `fork_secondary_model` at the alias that survived.
245
- *
246
- * Removing an adopted orphan that `[models] default` names would leave Grok pointing at
247
- * a model that no longer exists — and on a real machine `default` DOES name one, so this
248
- * is the common path rather than an edge case.
249
- */
250
- function rewriteAliasReferences(content: string, renames: Map<string, string>): string {
251
- if (renames.size === 0) return content;
252
- return content.replace(
253
- /^([ \t]*(?:default|fork_secondary_model)[ \t]*=[ \t]*")([^"]*)(")/gm,
254
- (whole, prefix: string, value: string, suffix: string) => {
255
- const replacement = renames.get(value);
256
- return replacement ? `${prefix}${replacement}${suffix}` : whole;
257
- },
946
+ /** Repoint references at whichever alias survived orphan adoption, or remove them. */
947
+ function rewriteAliasReferences(content: string, replacements: Map<string, string | null>): string {
948
+ return transformAliasReferences(content, replacements);
949
+ }
950
+
951
+ /** Remove only references that name model aliases teardown actually swept. */
952
+ function removeAliasReferences(
953
+ content: string,
954
+ removedAliases: ReadonlySet<string>,
955
+ allowRootDotted = true,
956
+ ): string {
957
+ return transformAliasReferences(
958
+ content,
959
+ new Map([...removedAliases].map(alias => [alias, null] as const)),
960
+ allowRootDotted,
258
961
  );
259
962
  }
260
963
 
@@ -299,6 +1002,15 @@ export function buildGrokManagedBlock(
299
1002
  const baseUrl = `http://${host}:${port}/v1`;
300
1003
  const lines = [
301
1004
  BEGIN_MARKER,
1005
+ "",
1006
+ `[model_providers.${OPENCODEX_PROVIDER_ID}]`,
1007
+ `base_url = ${tomlString(baseUrl)}`,
1008
+ 'api_backend = "responses"',
1009
+ 'api_key = "opencodex-loopback"',
1010
+ // Best-effort attribution tag for the usage dashboard. Upstream Grok sends
1011
+ // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a
1012
+ // security boundary — any loopback client could send the same header.
1013
+ 'extra_headers = { "x-opencodex-grok" = "1" }',
302
1014
  ];
303
1015
  const aliasCounts = new Map<string, number>();
304
1016
  const taken = new Set(reservedAliases ?? []);
@@ -318,23 +1030,38 @@ export function buildGrokManagedBlock(
318
1030
  // Slot consumed, table not written: this is what keeps every other alias stable
319
1031
  // across selection changes.
320
1032
  if (excluded?.has(model.id)) continue;
321
- const isFirst = lines.length === 1;
322
1033
  lines.push(
323
- ...(isFirst ? [] : [""]),
1034
+ "",
324
1035
  `[model.${alias}]`,
325
1036
  `model = ${tomlString(model.id)}`,
326
- `base_url = ${tomlString(baseUrl)}`,
327
- 'api_backend = "responses"',
328
- 'api_key = "opencodex-loopback"',
1037
+ `model_provider = ${tomlString(OPENCODEX_PROVIDER_ID)}`,
329
1038
  `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`,
330
- // Best-effort attribution tag for the usage dashboard. Upstream Grok sends
331
- // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a
332
- // security boundary — any loopback client could send the same header.
333
- 'extra_headers = { "x-opencodex-grok" = "1" }',
334
1039
  );
335
1040
  if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) {
336
1041
  lines.push(`context_window = ${model.contextWindow}`);
337
1042
  }
1043
+ // Array-of-tables MUST follow every parent keyval (including inline extra_headers).
1044
+ // Keep every picker option inside Grok's accepted CLI vocabulary; ultra is Codex-only.
1045
+ const efforts = sanitizeGrokReasoningEfforts(model.reasoningEfforts);
1046
+ const defaultEffort = grokDefaultReasoningEffort(efforts, model.defaultReasoningEffort);
1047
+ if (defaultEffort !== undefined) {
1048
+ lines.push(
1049
+ "supports_reasoning_effort = true",
1050
+ `reasoning_effort = ${tomlString(defaultEffort)}`,
1051
+ );
1052
+ for (const effort of efforts) {
1053
+ const option = grokReasoningEffortOption(effort, effort === defaultEffort);
1054
+ lines.push(
1055
+ "",
1056
+ `[[model.${alias}.reasoning_efforts]]`,
1057
+ `id = ${tomlString(option.id)}`,
1058
+ `value = ${tomlString(option.value)}`,
1059
+ `label = ${tomlString(option.label)}`,
1060
+ `description = ${tomlString(option.description)}`,
1061
+ `default = ${option.default}`,
1062
+ );
1063
+ }
1064
+ }
338
1065
  }
339
1066
 
340
1067
  lines.push(END_MARKER);
@@ -344,7 +1071,17 @@ export function buildGrokManagedBlock(
344
1071
  export function injectGrokConfig(
345
1072
  port: number,
346
1073
  models: GrokInjectModel[],
347
- opts: { grokHome?: string; hostname?: string; excluded?: ReadonlySet<string> } = {},
1074
+ opts: {
1075
+ grokHome?: string;
1076
+ hostname?: string;
1077
+ excluded?: ReadonlySet<string>;
1078
+ /** Unfiltered known ids used only to distinguish hidden current models from retired ones. */
1079
+ catalogModelIds?: ReadonlySet<string>;
1080
+ /** Canonical provider keys disabled in config and therefore absent from catalog fetching. */
1081
+ disabledProviderNamespaces?: ReadonlySet<string>;
1082
+ /** Configured combo public ids that may syntactically resemble provider/model ids. */
1083
+ comboPublicModelIds?: ReadonlySet<string>;
1084
+ } = {},
348
1085
  ): GrokInjectResult {
349
1086
  const grokHome = resolveGrokHome(opts.grokHome);
350
1087
  if (!isDirectory(grokHome)) {
@@ -391,11 +1128,35 @@ export function injectGrokConfig(
391
1128
  // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the
392
1129
  // entire file.
393
1130
  if (originalRegion?.orphaned) return orphanedMarkerResult("injection");
1131
+ const previousManagedModels = managedModelAliases(originalContent, originalRegion);
394
1132
 
395
1133
  // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale
396
1134
  // duplicate is replaced instead of routed around forever. Runs inside the normalized
397
1135
  // window so the user's dominant EOL is still restored below.
398
- const orphans = findOpencodexOrphans(originalContent, originalRegion);
1136
+ // Durably marked rows use the full UNFILTERED catalog: explicitly excluded and otherwise
1137
+ // hidden current models must still lose stale generated tables. Ambiguous pre-marker legacy
1138
+ // rows are migrated only when this write emits their replacement. Direct callers that do not
1139
+ // have a separate catalog keep the historical `models` behavior.
1140
+ const catalogModelIds = opts.catalogModelIds ?? new Set(models.map(model => model.id));
1141
+ const emittedModelIds = new Set(models
1142
+ .filter(model => !opts.excluded?.has(model.id))
1143
+ .map(model => model.id));
1144
+ const orphans = findOpencodexOrphans(originalContent, originalRegion)
1145
+ .filter(orphan =>
1146
+ // A provider table carries no alias and no model id: its strict predicate (our key
1147
+ // + loopback + durable marker) is itself the deletion authority, and a leftover
1148
+ // collides with the regenerated provider table (duplicate key).
1149
+ orphan.alias === ""
1150
+ || (orphan.ownership === "legacy"
1151
+ // A legacy fingerprint is not durable deletion authority. Migrate it only when this
1152
+ // same write will replace the row with a marked managed table.
1153
+ ? emittedModelIds.has(orphan.modelId)
1154
+ : catalogModelIds.has(orphan.modelId)
1155
+ || isDisabledProviderModelId(
1156
+ orphan.modelId,
1157
+ opts.disabledProviderNamespaces,
1158
+ opts.comboPublicModelIds,
1159
+ )));
399
1160
  const content = removeOrphanTables(originalContent, orphans);
400
1161
  // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic,
401
1162
  // so the splice below cannot cut the file in the wrong place.
@@ -415,25 +1176,27 @@ export function injectGrokConfig(
415
1176
  nextContent = `${content}\n${block}\n`;
416
1177
  }
417
1178
 
418
- // Repoint `default` / `fork_secondary_model` at whichever alias survived. A removed
419
- // model with no replacement keeps its reference untouched a stale name in a working
420
- // file beats a dangling one.
421
- if (orphans.length > 0) {
422
- const survivors = new Map<string, string>();
423
- for (const match of nextContent.matchAll(MODEL_TABLE_HEADER)) {
424
- if (canonicalKeySegment(match[1]!) !== "model") continue;
425
- const alias = canonicalKeySegment(match[2]!);
426
- const body = nextContent.slice(match.index! + match[0].length);
427
- const modelId = tableBodyKeys(body.slice(0, body.search(/^[ \t]*\[/m) + 1 || body.length)).get("model");
428
- if (modelId !== undefined && !survivors.has(modelId)) survivors.set(modelId, alias);
429
- }
430
- const renames = new Map<string, string>();
431
- for (const orphan of orphans) {
432
- const replacement = orphan.modelId === undefined ? undefined : survivors.get(orphan.modelId);
433
- if (replacement && replacement !== orphan.alias) renames.set(orphan.alias, replacement);
434
- }
435
- nextContent = rewriteAliasReferences(nextContent, renames);
1179
+ // Repoint every model selector at whichever managed alias survived. Compare both swept
1180
+ // out-of-fence tables and the PREVIOUS managed block: ordinary exclusion removes only the
1181
+ // latter, so tying cleanup to `orphans` made the #2830 path dead code.
1182
+ const nextManagedModels = managedModelAliases(nextContent, findManagedRegion(nextContent));
1183
+ const survivors = new Map<string, string>();
1184
+ for (const [alias, modelId] of nextManagedModels) {
1185
+ if (!survivors.has(modelId)) survivors.set(modelId, alias);
436
1186
  }
1187
+ const replacements = new Map<string, string | null>();
1188
+ for (const removed of [
1189
+ // Provider orphans carry no alias and no model id: there is nothing to repoint, and
1190
+ // an empty alias must never enter the rename map.
1191
+ ...orphans.filter(orphan => orphan.alias !== "")
1192
+ .map(orphan => ({ alias: orphan.alias, modelId: orphan.modelId })),
1193
+ ...[...previousManagedModels].map(([alias, modelId]) => ({ alias, modelId })),
1194
+ ]) {
1195
+ if (nextManagedModels.get(removed.alias) === removed.modelId) continue;
1196
+ const replacement = survivors.get(removed.modelId) ?? null;
1197
+ if (replacement !== removed.alias) replacements.set(removed.alias, replacement);
1198
+ }
1199
+ nextContent = rewriteAliasReferences(nextContent, replacements);
437
1200
 
438
1201
  const output = applyEol(nextContent, eol);
439
1202
  if (output === rawContent) {
@@ -477,29 +1240,91 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes
477
1240
  const rawContent = readFileSync(configPath, "utf8");
478
1241
  const eol = dominantEol(rawContent);
479
1242
  const content = applyEol(rawContent, "\n");
480
- const region = findManagedRegion(content);
481
- if (!region) {
482
- return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." };
483
- }
484
- if (region.orphaned) return orphanedMarkerResult("cleanup");
485
-
486
- let removalEnd = region.end;
487
- if (content.startsWith("\n", removalEnd)) removalEnd += 1;
488
- let prefix = content.slice(0, region.start);
489
- const restOfFile = content.slice(removalEnd);
490
- // Undo the single separator newline injection added. Two cases, mirroring inject:
491
- // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
492
- // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
493
- // A block the user has appended content after is left alone: we never shrink their bytes.
494
- if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
495
- else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
496
- const stripped = prefix + restOfFile;
1243
+ const originalRegion = findManagedRegion(content);
1244
+ if (originalRegion?.orphaned) return orphanedMarkerResult("cleanup");
1245
+
1246
+ // Remove the fence against its ORIGINAL offsets first. A pre-fence orphan's span is clamped
1247
+ // at the fence start and can include the separator newline injection added. Sweeping that
1248
+ // orphan first and then applying this separator undo would remove one additional USER newline.
1249
+ let stripped: string;
1250
+ let orphanCount = 0;
1251
+ if (originalRegion) {
1252
+ const fullOrphans = findOpencodexOrphans(content, originalRegion)
1253
+ .filter(orphan => orphan.ownership === "explicit");
1254
+ let removalEnd = originalRegion.end;
1255
+ if (content.startsWith("\n", removalEnd)) removalEnd += 1;
1256
+ let prefix = content.slice(0, originalRegion.start);
1257
+ const restOfFile = content.slice(removalEnd);
1258
+ // Undo the single separator newline injection added. Two cases, mirroring inject:
1259
+ // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
1260
+ // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
1261
+ // A block the user has appended content after is left alone: we never shrink their bytes.
1262
+ if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
1263
+ else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
1264
+ // Keep the old fence boundary while sweeping. Concatenating first would let the last
1265
+ // pre-fence orphan absorb comment-only or bare-key user content appended after the fence.
1266
+ const prefixOrphans = findOpencodexOrphans(prefix, null)
1267
+ .filter(orphan => orphan.ownership === "explicit");
1268
+ const tailOrphans = findOpencodexOrphans(restOfFile, null)
1269
+ .filter(orphan => orphan.ownership === "explicit");
1270
+ const removedAliases = new Set(
1271
+ [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1272
+ .map(orphan => orphan.alias)
1273
+ // Provider orphans carry no alias; their ranges above already removed the table.
1274
+ .filter(alias => alias !== ""),
1275
+ );
1276
+ // The backup must cover every removed TABLE, not just aliased rows: provider-only
1277
+ // orphans carry no alias and would otherwise be swept without any backup.
1278
+ orphanCount = new Set(
1279
+ [...fullOrphans, ...prefixOrphans, ...tailOrphans]
1280
+ .flatMap(orphan => orphanRanges([orphan])),
1281
+ ).size;
1282
+ const fullRanges = orphanRanges(fullOrphans);
1283
+ const prefixRanges = [
1284
+ ...orphanRanges(prefixOrphans),
1285
+ ...fullRanges.filter(range => range.end <= originalRegion.start),
1286
+ ];
1287
+ const tailRanges = [
1288
+ ...orphanRanges(tailOrphans),
1289
+ ...fullRanges
1290
+ .filter(range => range.start >= removalEnd)
1291
+ .map(range => ({ start: range.start - removalEnd, end: range.end - removalEnd })),
1292
+ ];
1293
+ // Preserve the original fence as a structural boundary while cleaning references too.
1294
+ // Joining first can re-parent a headerless tail under the last table in `prefix`.
1295
+ stripped = removeAliasReferences(
1296
+ removeTableRanges(prefix, prefixRanges),
1297
+ removedAliases,
1298
+ ) + removeAliasReferences(
1299
+ removeTableRanges(restOfFile, tailRanges),
1300
+ removedAliases,
1301
+ false,
1302
+ );
1303
+ } else {
1304
+ // Retired or otherwise non-emitted OpenCodex tables may intentionally remain outside the
1305
+ // fence while the integration is enabled. Teardown owns those strictly identified tables
1306
+ // even after Grok has re-serialized the file and dropped our marker comments.
1307
+ const orphans = findOpencodexOrphans(content, null)
1308
+ .filter(orphan => orphan.ownership === "explicit");
1309
+ if (orphans.length === 0) {
1310
+ return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." };
1311
+ }
1312
+ orphanCount = orphans.length;
1313
+ stripped = removeOrphanTables(content, orphans);
1314
+ stripped = removeAliasReferences(
1315
+ stripped,
1316
+ new Set(orphans.map(orphan => orphan.alias).filter(alias => alias !== "")),
1317
+ );
1318
+ }
1319
+ if (orphanCount > 0) copyBackupOnce(configPath, join(grokHome, "config.toml.bak-opencodex"));
497
1320
  atomicWriteFile(configPath, applyEol(stripped, eol));
498
1321
 
499
1322
  return {
500
1323
  ok: true,
501
1324
  changed: true,
502
- message: "Removed the opencodex managed block from Grok config.",
1325
+ message: originalRegion
1326
+ ? "Removed the opencodex managed block from Grok config."
1327
+ : "Removed stale opencodex-managed model entries from Grok config.",
503
1328
  };
504
1329
  } catch (error) {
505
1330
  return errorResult("strip", error);