@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.
- package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DO8liQVL.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
- package/gui/dist/assets/index-DX0RGj3e.js +0 -112
package/src/grok/inject.ts
CHANGED
|
@@ -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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
|
118
|
-
if (
|
|
119
|
-
aliases.add(
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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('"')
|
|
358
|
+
const value = raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')
|
|
148
359
|
? decodeTomlBasicString(raw.slice(1, -1))
|
|
149
|
-
: raw
|
|
150
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
213
|
-
if (!
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
1034
|
+
"",
|
|
324
1035
|
`[model.${alias}]`,
|
|
325
1036
|
`model = ${tomlString(model.id)}`,
|
|
326
|
-
`
|
|
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: {
|
|
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
|
-
|
|
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
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
let
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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:
|
|
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);
|