@juspay/neurolink 12.12.16 → 12.14.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.
@@ -11,13 +11,14 @@
11
11
  */
12
12
  import { spawn } from "node:child_process";
13
13
  import { homedir } from "node:os";
14
- import { dirname, join, resolve } from "node:path";
14
+ import { dirname, join } from "node:path";
15
15
  import { stripVTControlCharacters } from "node:util";
16
16
  import chalk from "chalk";
17
17
  import ora from "ora";
18
18
  import { buildProxyHealthResponse, createProxyReadinessState, markProxyDrainingForUpdate, markProxyReady, resumeProxyConnections, waitForProxyReadiness, } from "../../proxy/proxyHealth.js";
19
19
  import { logger } from "../../utils/logger.js";
20
20
  import { applyAllClients, restoreAllClients, } from "../proxy-clients/registry.js";
21
+ import { resolveProxyConfigPath } from "../../proxy/proxyConfig.js";
21
22
  import { redactUrlsInText, sanitizeForLog, } from "../../utils/logSanitize.js";
22
23
  import { withTimeout } from "../../utils/async/withTimeout.js";
23
24
  import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
@@ -2696,7 +2697,9 @@ async function startProxyRuntime(params) {
2696
2697
  logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(params.loadedEnvFile)}`);
2697
2698
  }
2698
2699
  if (!isDev) {
2699
- for (const result of await applyAllClients(url)) {
2700
+ for (const result of await applyAllClients(url, {
2701
+ configPath: params.configPath,
2702
+ })) {
2700
2703
  if (result.error) {
2701
2704
  // Visible, not debug-level. A client whose config could not be written
2702
2705
  // will keep talking to its own upstream, which looks like the proxy
@@ -2953,9 +2956,7 @@ async function startProxyCommandHandler(argv) {
2953
2956
  // content-filters. Runs once, after env load so it can be tuned via env.
2954
2957
  configureProxyKeepAliveDispatcher();
2955
2958
  const { neurolink, logsDir } = await createProxyNeurolinkRuntime(devPaths?.logsDir);
2956
- const configPath = argv.config
2957
- ? resolve(argv.config)
2958
- : join(homedir(), ".neurolink", "proxy-config.yaml");
2959
+ const configPath = resolveProxyConfigPath(argv.config);
2959
2960
  const runtimeConfigStore = await ProxyRuntimeConfigStore.create({
2960
2961
  configPath,
2961
2962
  configRequired: Boolean(argv.config),
@@ -3012,6 +3013,7 @@ async function startProxyCommandHandler(argv) {
3012
3013
  passthrough,
3013
3014
  logsDir,
3014
3015
  runtimeConfigStore,
3016
+ configPath,
3015
3017
  });
3016
3018
  }
3017
3019
  catch (error) {
@@ -4174,7 +4176,9 @@ export const proxySetupCommand = {
4174
4176
  const nextStep = stepNum + 1;
4175
4177
  console.info(chalk.blue(`\nStep ${nextStep}:`) + " Configuring Claude Code...");
4176
4178
  const url = `http://127.0.0.1:${port}`;
4177
- for (const result of await applyAllClients(url)) {
4179
+ for (const result of await applyAllClients(url, {
4180
+ configPath: resolveProxyConfigPath(argv.config),
4181
+ })) {
4178
4182
  if (result.error) {
4179
4183
  console.info(chalk.yellow(` ⚠ Could not auto-configure ${result.displayName}: ${result.error.message}`));
4180
4184
  // Claude Code is the one client whose manual fallback is a single
@@ -4364,9 +4368,7 @@ export const proxyInstallCommand = {
4364
4368
  });
4365
4369
  const envFile = envResolution.path;
4366
4370
  const explicitConfig = argv.config;
4367
- const configPath = explicitConfig
4368
- ? resolve(explicitConfig)
4369
- : join(homedir(), ".neurolink", "proxy-config.yaml");
4371
+ const configPath = resolveProxyConfigPath(explicitConfig);
4370
4372
  if (explicitConfig && !existsSync(configPath)) {
4371
4373
  console.info(chalk.red(`Proxy config file not found: ${configPath}`));
4372
4374
  process.exit(1);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Grok Build client configurator.
3
+ *
4
+ * Grok Build (`grok`, xAI's terminal agent) is a Codex-shaped TOML client
5
+ * that speaks three wire formats. Built-in `grok-4.6` / `grok-4.5` stay on
6
+ * xAI (`cli-chat-proxy.grok.com`, Responses API, 500k window). This writer
7
+ * does not remap those. It adds the proxy's advertised catalog as extra
8
+ * picker entries so Grok can send Claude traffic through `/v1/messages`
9
+ * (passthrough) and Gemini/OpenAI traffic through `/v1/chat/completions`
10
+ * (translation), each with a `context_window` that Grok's own compaction
11
+ * will honour — the proxy does not truncate.
12
+ *
13
+ * Snapshot lives in `~/.neurolink/`, following Codex, never inside
14
+ * `config.toml`. Grok's TOML parser is not a closed schema, but putting
15
+ * bookkeeping keys in the user's file is how OpenCode was bricked, and new
16
+ * writers do not rely on tolerance.
17
+ *
18
+ * Adaptive thinking: Grok's global `default_reasoning_effort = "xhigh"`
19
+ * becomes Anthropic `thinking.type = "adaptive"`. Haiku 4.5 rejects that
20
+ * with 400. Only Claude Opus/Sonnet 4.6 and 5.x keep reasoning enabled.
21
+ */
22
+ import type { CliGrokProxyModelSpec, CliProxyClientApplyOptions, CliProxyClientConfigurator, ModelMapping } from "../../types/index.js";
23
+ declare function getGrokConfigDir(): string;
24
+ declare function getGrokConfigPath(): string;
25
+ declare function getGrokSnapshotPath(): string;
26
+ declare function classifyGrokProxyModel(id: string, mapping?: ModelMapping): CliGrokProxyModelSpec;
27
+ declare function loadRoutedModelIds(configPath?: string): Promise<string[]>;
28
+ declare function catalogModelIds(configPath?: string): Promise<string[]>;
29
+ declare function buildGrokManagedBlock(baseUrl: string, configPath?: string): Promise<string>;
30
+ export declare function setGrokProxySettings(baseUrl: string, options?: CliProxyClientApplyOptions): Promise<boolean>;
31
+ export declare function clearGrokProxySettings(expectedBaseUrl?: string): Promise<boolean>;
32
+ export declare const grokConfigurator: CliProxyClientConfigurator;
33
+ /** Test-only export (CLAUDE.md rule 15 determinism exception). See openCode.ts. */
34
+ export declare const __grokTestHooks: {
35
+ getGrokConfigDir: typeof getGrokConfigDir;
36
+ getGrokConfigPath: typeof getGrokConfigPath;
37
+ getGrokSnapshotPath: typeof getGrokSnapshotPath;
38
+ setGrokProxySettings: typeof setGrokProxySettings;
39
+ clearGrokProxySettings: typeof clearGrokProxySettings;
40
+ classifyGrokProxyModel: typeof classifyGrokProxyModel;
41
+ loadRoutedModelIds: typeof loadRoutedModelIds;
42
+ catalogModelIds: typeof catalogModelIds;
43
+ buildGrokManagedBlock: typeof buildGrokManagedBlock;
44
+ };
45
+ export {};
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Grok Build client configurator.
3
+ *
4
+ * Grok Build (`grok`, xAI's terminal agent) is a Codex-shaped TOML client
5
+ * that speaks three wire formats. Built-in `grok-4.6` / `grok-4.5` stay on
6
+ * xAI (`cli-chat-proxy.grok.com`, Responses API, 500k window). This writer
7
+ * does not remap those. It adds the proxy's advertised catalog as extra
8
+ * picker entries so Grok can send Claude traffic through `/v1/messages`
9
+ * (passthrough) and Gemini/OpenAI traffic through `/v1/chat/completions`
10
+ * (translation), each with a `context_window` that Grok's own compaction
11
+ * will honour — the proxy does not truncate.
12
+ *
13
+ * Snapshot lives in `~/.neurolink/`, following Codex, never inside
14
+ * `config.toml`. Grok's TOML parser is not a closed schema, but putting
15
+ * bookkeeping keys in the user's file is how OpenCode was bricked, and new
16
+ * writers do not rely on tolerance.
17
+ *
18
+ * Adaptive thinking: Grok's global `default_reasoning_effort = "xhigh"`
19
+ * becomes Anthropic `thinking.type = "adaptive"`. Haiku 4.5 rejects that
20
+ * with 400. Only Claude Opus/Sonnet 4.6 and 5.x keep reasoning enabled.
21
+ */
22
+ import { readFileSync } from "fs";
23
+ import { homedir } from "os";
24
+ import { join } from "path";
25
+ import { logger } from "../../utils/logger.js";
26
+ import { getContextWindowSize } from "../../constants/contextWindows.js";
27
+ import { DEFAULT_PROXY_MODEL_IDS } from "../../constants/proxyModels.js";
28
+ import { defaultProxyConfigPath, parseProxyConfigString, } from "../../proxy/proxyConfig.js";
29
+ import { isUsableSnapshot, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
30
+ const GROK_BLOCK_BEGIN = "# >>> neurolink-proxy (managed) >>>";
31
+ const GROK_BLOCK_END = "# <<< neurolink-proxy (managed) <<<";
32
+ const PLACEHOLDER_KEY = "neurolink-proxy";
33
+ const ANTHROPIC_VERSION = "2023-06-01";
34
+ function getGrokConfigDir() {
35
+ const env = process.env.GROK_HOME;
36
+ return env !== undefined && env.trim().length > 0
37
+ ? env.trim()
38
+ : join(homedir(), ".grok");
39
+ }
40
+ function getGrokConfigPath() {
41
+ return join(getGrokConfigDir(), "config.toml");
42
+ }
43
+ function getGrokSnapshotPath() {
44
+ return join(homedir(), ".neurolink", "grok-proxy-snapshot.json");
45
+ }
46
+ function escapeRegExp(value) {
47
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+ }
49
+ function tomlKey(id) {
50
+ return /^[A-Za-z0-9_-]+$/.test(id) ? id : JSON.stringify(id);
51
+ }
52
+ function displayNameFor(id) {
53
+ const base = id.replace(/-\d{8}$/, "");
54
+ const pretty = base
55
+ .split("-")
56
+ .map((part) => /^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1))
57
+ .join(" ")
58
+ .replace(/(\d) (\d)/g, "$1.$2");
59
+ return `${pretty} (NeuroLink)`;
60
+ }
61
+ function providerForModelId(id) {
62
+ if (id.startsWith("claude-")) {
63
+ return "anthropic";
64
+ }
65
+ if (id.startsWith("gemini-")) {
66
+ return "vertex";
67
+ }
68
+ return "openai";
69
+ }
70
+ function providerFromMapping(provider, fallbackId) {
71
+ const normalized = provider.trim().toLowerCase();
72
+ if (normalized === "anthropic" || normalized === "claude") {
73
+ return "anthropic";
74
+ }
75
+ if (normalized === "openai") {
76
+ return "openai";
77
+ }
78
+ if (normalized === "vertex" ||
79
+ normalized === "google" ||
80
+ normalized === "google-ai" ||
81
+ normalized === "gemini") {
82
+ return "vertex";
83
+ }
84
+ return providerForModelId(fallbackId);
85
+ }
86
+ function isMissingFileError(error) {
87
+ if (!error || typeof error !== "object" || !("code" in error)) {
88
+ return false;
89
+ }
90
+ return error.code === "ENOENT";
91
+ }
92
+ /**
93
+ * Anthropic `thinking.type = "adaptive"` is what Grok emits for `xhigh`.
94
+ * Measured: Haiku 4.5 returns 400 "adaptive thinking is not supported".
95
+ * Opus/Sonnet 4.6 and the 5-series accept it.
96
+ */
97
+ function supportsAdaptiveThinking(id) {
98
+ return (/^claude-(opus|sonnet)-4-6$/.test(id) || /^claude-(opus|sonnet)-5/.test(id));
99
+ }
100
+ function classifyGrokProxyModel(id, mapping) {
101
+ const classifyId = mapping && mapping.to.trim().length > 0 ? mapping.to.trim() : id;
102
+ const provider = mapping
103
+ ? providerFromMapping(mapping.provider, classifyId)
104
+ : providerForModelId(id);
105
+ const windowProvider = mapping && mapping.provider.trim().length > 0
106
+ ? mapping.provider.trim()
107
+ : provider;
108
+ const apiBackend = provider === "anthropic" ? "messages" : "chat_completions";
109
+ const contextWindow = getContextWindowSize(windowProvider, classifyId);
110
+ return {
111
+ id,
112
+ name: displayNameFor(id),
113
+ apiBackend,
114
+ contextWindow,
115
+ maxCompletionTokens: contextWindow >= 1_000_000 ? 16_384 : 8_192,
116
+ supportsReasoningEffort: supportsAdaptiveThinking(classifyId),
117
+ };
118
+ }
119
+ async function loadRoutedMappings(configPath) {
120
+ const resolvedPath = configPath ?? defaultProxyConfigPath();
121
+ try {
122
+ const config = await parseProxyConfigString(readFileSync(resolvedPath, "utf8"));
123
+ return (config.routing?.modelMappings ?? []).filter((mapping) => mapping.from.trim().length > 0);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ async function loadRoutedModelIds(configPath) {
130
+ return (await loadRoutedMappings(configPath)).map((mapping) => mapping.from.trim());
131
+ }
132
+ async function catalogGrokSpecs(configPath) {
133
+ const seen = new Set();
134
+ const specs = [];
135
+ const routed = await loadRoutedMappings(configPath);
136
+ const routedByFrom = new Map(routed.map((mapping) => [mapping.from.trim(), mapping]));
137
+ for (const id of [
138
+ ...DEFAULT_PROXY_MODEL_IDS,
139
+ ...routed.map((mapping) => mapping.from.trim()),
140
+ ]) {
141
+ if (id.startsWith("grok-") || seen.has(id)) {
142
+ continue;
143
+ }
144
+ seen.add(id);
145
+ specs.push(classifyGrokProxyModel(id, routedByFrom.get(id)));
146
+ }
147
+ return specs;
148
+ }
149
+ async function catalogModelIds(configPath) {
150
+ return (await catalogGrokSpecs(configPath)).map((spec) => spec.id);
151
+ }
152
+ function buildGrokModelBlock(spec, baseUrl) {
153
+ const lines = [
154
+ `[model.${tomlKey(spec.id)}]`,
155
+ `model = ${JSON.stringify(spec.id)}`,
156
+ `name = ${JSON.stringify(spec.name)}`,
157
+ `base_url = ${JSON.stringify(baseUrl)}`,
158
+ `api_backend = ${JSON.stringify(spec.apiBackend)}`,
159
+ `context_window = ${spec.contextWindow}`,
160
+ `auto_compact_threshold_percent = 80`,
161
+ `max_completion_tokens = ${spec.maxCompletionTokens}`,
162
+ `supports_backend_search = false`,
163
+ ];
164
+ if (!spec.supportsReasoningEffort) {
165
+ lines.push("supports_reasoning_effort = false");
166
+ }
167
+ if (spec.apiBackend === "messages") {
168
+ lines.push(`extra_headers = { "x-api-key" = ${JSON.stringify(PLACEHOLDER_KEY)}, "anthropic-version" = ${JSON.stringify(ANTHROPIC_VERSION)} }`);
169
+ }
170
+ else {
171
+ lines.push(`api_key = ${JSON.stringify(PLACEHOLDER_KEY)}`);
172
+ }
173
+ return lines.join("\n");
174
+ }
175
+ async function buildGrokManagedBlock(baseUrl, configPath) {
176
+ const specs = await catalogGrokSpecs(configPath);
177
+ const body = specs
178
+ .map((spec) => buildGrokModelBlock(spec, baseUrl))
179
+ .join("\n\n");
180
+ return [
181
+ GROK_BLOCK_BEGIN,
182
+ "# Proxy catalog. Built-in grok-4.6 / grok-4.5 stay on xAI.",
183
+ "# context_window is Grok's compaction limit and must be <= upstream.",
184
+ "",
185
+ body,
186
+ GROK_BLOCK_END,
187
+ "",
188
+ ].join("\n");
189
+ }
190
+ function stripGrokManagedBlock(text) {
191
+ const blockRe = new RegExp(`\\n?${escapeRegExp(GROK_BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(GROK_BLOCK_END)}\\n?`, "g");
192
+ return text.replace(blockRe, "\n");
193
+ }
194
+ function extractManagedBaseUrl(text) {
195
+ const match = text.match(new RegExp(`${escapeRegExp(GROK_BLOCK_BEGIN)}[\\s\\S]*?base_url\\s*=\\s*"([^"]*)"`));
196
+ return match?.[1];
197
+ }
198
+ async function readGrokSnapshot() {
199
+ const fs = await import("fs");
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(fs.readFileSync(getGrokSnapshotPath(), "utf8"));
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ if (!isUsableSnapshot(parsed, "originalExisted")) {
208
+ logger.debug("[proxy] Grok: ignoring a malformed snapshot rather than treating it as empty");
209
+ return null;
210
+ }
211
+ const record = parsed;
212
+ const originalExisted = record.originalExisted;
213
+ const writtenBaseUrl = record.writtenBaseUrl;
214
+ if (typeof originalExisted !== "boolean") {
215
+ return null;
216
+ }
217
+ if (typeof writtenBaseUrl !== "string") {
218
+ return null;
219
+ }
220
+ return { originalExisted, writtenBaseUrl };
221
+ }
222
+ export async function setGrokProxySettings(baseUrl, options) {
223
+ const fs = await import("fs");
224
+ try {
225
+ fs.accessSync(getGrokConfigDir());
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ let original;
231
+ try {
232
+ original = fs.readFileSync(getGrokConfigPath(), "utf8");
233
+ }
234
+ catch (error) {
235
+ if (!isMissingFileError(error)) {
236
+ logger.warn("[proxy] Grok: unable to read config.toml; leaving it untouched");
237
+ return false;
238
+ }
239
+ original = null;
240
+ }
241
+ const existingSnapshot = await readGrokSnapshot();
242
+ if (existingSnapshot === null && fs.existsSync(getGrokSnapshotPath())) {
243
+ logger.warn("[proxy] Grok: snapshot file is unreadable; leaving config.toml untouched rather than overwriting with no way back");
244
+ return false;
245
+ }
246
+ const currentBlock = original
247
+ ? original.includes(GROK_BLOCK_BEGIN)
248
+ ? original.slice(original.indexOf(GROK_BLOCK_BEGIN), original.indexOf(GROK_BLOCK_END) === -1
249
+ ? original.length
250
+ : original.indexOf(GROK_BLOCK_END) + GROK_BLOCK_END.length)
251
+ : undefined
252
+ : undefined;
253
+ if (existingSnapshot === null ||
254
+ shouldCaptureSnapshot({
255
+ hasSnapshot: existingSnapshot !== null,
256
+ written: existingSnapshot?.writtenBaseUrl,
257
+ current: extractManagedBaseUrl(original ?? "") ?? currentBlock,
258
+ })) {
259
+ fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
260
+ await writeFileAtomic(getGrokSnapshotPath(), JSON.stringify({
261
+ originalExisted: existingSnapshot?.originalExisted ?? original !== null,
262
+ writtenBaseUrl: baseUrl,
263
+ }, null, 2), 0o600);
264
+ }
265
+ const withoutBlock = original ? stripGrokManagedBlock(original) : "";
266
+ const trimmed = withoutBlock.replace(/\s*$/, "\n");
267
+ const next = `${trimmed}\n${await buildGrokManagedBlock(baseUrl, options?.configPath)}`;
268
+ await writeFileAtomic(getGrokConfigPath(), next.startsWith("\n") && original === null
269
+ ? next.replace(/^\n+/, "")
270
+ : next, original === null ? 0o600 : undefined);
271
+ return true;
272
+ }
273
+ export async function clearGrokProxySettings(expectedBaseUrl) {
274
+ const fs = await import("fs");
275
+ let current;
276
+ try {
277
+ current = fs.readFileSync(getGrokConfigPath(), "utf8");
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ if (!current.includes(GROK_BLOCK_BEGIN)) {
283
+ return false;
284
+ }
285
+ const configuredUrl = extractManagedBaseUrl(current);
286
+ if (expectedBaseUrl &&
287
+ configuredUrl !== undefined &&
288
+ configuredUrl !== expectedBaseUrl) {
289
+ logger.debug("[proxy] Grok clear: base URL is not the one we wrote, leaving it intact");
290
+ return false;
291
+ }
292
+ const snapshot = await readGrokSnapshot();
293
+ if (snapshot === null) {
294
+ logger.warn("[proxy] Grok clear: no usable snapshot, leaving config.toml untouched rather than stripping a block we cannot prove we own");
295
+ return false;
296
+ }
297
+ const remainder = stripGrokManagedBlock(current).replace(/\s*$/, "\n");
298
+ if (!snapshot.originalExisted && remainder.trim().length === 0) {
299
+ fs.rmSync(getGrokConfigPath(), { force: true });
300
+ }
301
+ else {
302
+ await writeFileAtomic(getGrokConfigPath(), remainder);
303
+ }
304
+ try {
305
+ fs.rmSync(getGrokSnapshotPath(), { force: true });
306
+ }
307
+ catch {
308
+ // next apply overwrites
309
+ }
310
+ return true;
311
+ }
312
+ export const grokConfigurator = {
313
+ id: "grok",
314
+ displayName: "Grok Build",
315
+ detect: async () => {
316
+ const fs = await import("fs");
317
+ try {
318
+ fs.accessSync(getGrokConfigDir());
319
+ return true;
320
+ }
321
+ catch {
322
+ return false;
323
+ }
324
+ },
325
+ // Grok appends `/messages` or `/chat/completions` to `base_url`, so it
326
+ // takes the `/v1` door rather than the proxy root.
327
+ apply: (proxyBaseUrl, options) => setGrokProxySettings(`${proxyBaseUrl}/v1`, options),
328
+ restore: (proxyBaseUrl) => clearGrokProxySettings(`${proxyBaseUrl}/v1`),
329
+ };
330
+ /** Test-only export (CLAUDE.md rule 15 determinism exception). See openCode.ts. */
331
+ export const __grokTestHooks = {
332
+ getGrokConfigDir,
333
+ getGrokConfigPath,
334
+ getGrokSnapshotPath,
335
+ setGrokProxySettings,
336
+ clearGrokProxySettings,
337
+ classifyGrokProxyModel,
338
+ loadRoutedModelIds,
339
+ catalogModelIds,
340
+ buildGrokManagedBlock,
341
+ };
342
+ //# sourceMappingURL=grok.js.map
@@ -1,4 +1,4 @@
1
- import type { CliProxyClientApplyResult, CliProxyClientConfigurator, CliProxyClientRestoreResult } from "../../types/index.js";
1
+ import type { CliProxyClientApplyOptions, CliProxyClientApplyResult, CliProxyClientConfigurator, CliProxyClientRestoreResult } from "../../types/index.js";
2
2
  /**
3
3
  * Every CLI the proxy auto-configures, in apply order.
4
4
  *
@@ -14,6 +14,6 @@ export declare const PROXY_CLIENT_CONFIGURATORS: readonly CliProxyClientConfigur
14
14
  * how loudly to report — the daemon-start path logs failures at debug level
15
15
  * while the setup wizard prints a visible warning.
16
16
  */
17
- export declare function applyAllClients(proxyBaseUrl: string): Promise<CliProxyClientApplyResult[]>;
17
+ export declare function applyAllClients(proxyBaseUrl: string, options?: CliProxyClientApplyOptions): Promise<CliProxyClientApplyResult[]>;
18
18
  /** Restore every client's previous configuration. See applyAllClients. */
19
19
  export declare function restoreAllClients(proxyBaseUrl: string): Promise<CliProxyClientRestoreResult[]>;
@@ -5,6 +5,7 @@ import { codexConfigurator } from "./codex.js";
5
5
  import { qwenCodeConfigurator } from "./qwenCode.js";
6
6
  import { copilotConfigurator } from "./copilot.js";
7
7
  import { geminiConfigurator } from "./gemini.js";
8
+ import { grokConfigurator } from "./grok.js";
8
9
  /**
9
10
  * Every CLI the proxy auto-configures, in apply order.
10
11
  *
@@ -18,6 +19,7 @@ export const PROXY_CLIENT_CONFIGURATORS = [
18
19
  qwenCodeConfigurator,
19
20
  copilotConfigurator,
20
21
  geminiConfigurator,
22
+ grokConfigurator,
21
23
  ];
22
24
  /**
23
25
  * Point every detected client at the proxy.
@@ -27,12 +29,12 @@ export const PROXY_CLIENT_CONFIGURATORS = [
27
29
  * how loudly to report — the daemon-start path logs failures at debug level
28
30
  * while the setup wizard prints a visible warning.
29
31
  */
30
- export async function applyAllClients(proxyBaseUrl) {
32
+ export async function applyAllClients(proxyBaseUrl, options) {
31
33
  const results = [];
32
34
  for (const client of PROXY_CLIENT_CONFIGURATORS) {
33
35
  try {
34
36
  const applied = (await client.detect())
35
- ? await client.apply(proxyBaseUrl)
37
+ ? await client.apply(proxyBaseUrl, options)
36
38
  : false;
37
39
  // Only ask for a note when something was actually written: a note on a
38
40
  // client that was skipped would read as an instruction to act on a
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Model IDs the proxy advertises when no routing config narrows the list.
3
3
  *
4
- * Two consumers need the same answer and must not drift apart:
4
+ * Three consumers need the same answer and must not drift apart:
5
5
  *
6
6
  * - `proxyTranslationEngine` serves them from `GET /v1/models`.
7
7
  * - The OpenCode client configurator writes them into `provider.neurolink.
@@ -9,6 +9,12 @@
9
9
  * It does not call `/v1/models`, so an empty map means every model id is
10
10
  * unknown and `opencode run` fails with `ProviderModelNotFoundError`
11
11
  * before a request is ever made.
12
+ * - The Grok Build configurator writes the same ids (plus any
13
+ * `routing.model-mappings` in the active proxy config — default
14
+ * `~/.neurolink/proxy-config.yaml`, or the path passed to `--config`)
15
+ * into `~/.grok/config.toml` as `[model.<id>]` entries, with
16
+ * `api_backend` and `context_window` so Grok's compaction matches the
17
+ * upstream. Built-in grok-* ids are skipped.
12
18
  *
13
19
  * Format matches the IDs used throughout `src/lib/models/` and
14
20
  * `src/lib/constants/` (e.g. `claude-3-5-haiku-20241022`, not
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Model IDs the proxy advertises when no routing config narrows the list.
3
3
  *
4
- * Two consumers need the same answer and must not drift apart:
4
+ * Three consumers need the same answer and must not drift apart:
5
5
  *
6
6
  * - `proxyTranslationEngine` serves them from `GET /v1/models`.
7
7
  * - The OpenCode client configurator writes them into `provider.neurolink.
@@ -9,6 +9,12 @@
9
9
  * It does not call `/v1/models`, so an empty map means every model id is
10
10
  * unknown and `opencode run` fails with `ProviderModelNotFoundError`
11
11
  * before a request is ever made.
12
+ * - The Grok Build configurator writes the same ids (plus any
13
+ * `routing.model-mappings` in the active proxy config — default
14
+ * `~/.neurolink/proxy-config.yaml`, or the path passed to `--config`)
15
+ * into `~/.grok/config.toml` as `[model.<id>]` entries, with
16
+ * `api_backend` and `context_window` so Grok's compaction matches the
17
+ * upstream. Built-in grok-* ids are skipped.
12
18
  *
13
19
  * Format matches the IDs used throughout `src/lib/models/` and
14
20
  * `src/lib/constants/` (e.g. `claude-3-5-haiku-20241022`, not
@@ -41,6 +41,16 @@ export declare abstract class BaseProvider implements AIProvider {
41
41
  * @returns the current model's registered capability, or true when unknown
42
42
  */
43
43
  supportsTools(): boolean;
44
+ /**
45
+ * Whether this provider implements the opt-in `executionControl` contract.
46
+ *
47
+ * Default false, and that default is load-bearing: a provider that has not
48
+ * implemented the contract must REJECT it, not ignore it. Silently dropping
49
+ * a caller-set execution policy is invisible until a long turn dies at a
50
+ * ceiling its owner believed had been removed. Overridden only where the
51
+ * control is genuinely honoured end to end.
52
+ */
53
+ supportsExecutionControl(): boolean;
44
54
  /**
45
55
  * Apply the shared tool gate and optionally report registry-backed
46
56
  * suppression at the request entry point.
@@ -46,6 +46,7 @@ import { TelemetryHandler } from "./modules/TelemetryHandler.js";
46
46
  import { ToolsManager } from "./modules/ToolsManager.js";
47
47
  import { Utilities } from "./modules/Utilities.js";
48
48
  import { generateOnceNative } from "../utils/nativeSingleShot.js";
49
+ import { validateExecutionControl } from "../utils/parameterValidation.js";
49
50
  import { extractTokenUsage } from "../utils/tokenUtils.js";
50
51
  /**
51
52
  * Read the consumer-facing lifecycle callbacks buried inside a request's
@@ -182,6 +183,18 @@ export class BaseProvider {
182
183
  supportsTools() {
183
184
  return modelSupports("functionCalling", this.providerName, this.modelName);
184
185
  }
186
+ /**
187
+ * Whether this provider implements the opt-in `executionControl` contract.
188
+ *
189
+ * Default false, and that default is load-bearing: a provider that has not
190
+ * implemented the contract must REJECT it, not ignore it. Silently dropping
191
+ * a caller-set execution policy is invisible until a long turn dies at a
192
+ * ceiling its owner believed had been removed. Overridden only where the
193
+ * control is genuinely honoured end to end.
194
+ */
195
+ supportsExecutionControl() {
196
+ return false;
197
+ }
185
198
  /**
186
199
  * Apply the shared tool gate and optionally report registry-backed
187
200
  * suppression at the request entry point.
@@ -211,6 +224,14 @@ export class BaseProvider {
211
224
  // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
212
225
  await this.ensureModelLimits();
213
226
  let options = this.normalizeStreamOptions(optionsOrPrompt);
227
+ // Before anything else, and before a single byte leaves the process: an
228
+ // execution policy this provider cannot honour is an error, and a policy
229
+ // whose shape could be read two ways is an error. Both are silent bugs at
230
+ // the point they would otherwise matter.
231
+ validateExecutionControl(options.executionControl, this.providerName, this.supportsExecutionControl(), {
232
+ turnTimeoutMs: options.turnTimeoutMs,
233
+ toolTimeoutMs: options.toolTimeoutMs,
234
+ });
214
235
  logger.info(`Starting stream`, {
215
236
  provider: this.providerName,
216
237
  hasTools: !options.disableTools && this.supportsTools(),
@@ -27,9 +27,22 @@ export declare const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300000;
27
27
  * Default per-tool-execution timeout for native agentic loops. A tool that
28
28
  * exceeds it fails with an error tool_result and costs one step — the turn
29
29
  * continues instead of hanging on a wedged tool. Override per call with
30
- * `toolTimeoutMs`.
30
+ * `toolTimeoutMs`, or remove the bound entirely with `toolTimeoutMs: null`.
31
31
  */
32
32
  export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
33
+ /**
34
+ * Resolve a caller's `toolTimeoutMs` into the bound a loop should actually
35
+ * apply: a number of milliseconds, or `null` for no bound at all.
36
+ *
37
+ * The three-way distinction is the whole point, and `??` cannot express it:
38
+ * `undefined` means "no opinion, take the default", while `null` is a stated
39
+ * choice to run tools unbounded — the pre-existing behaviour of the loops that
40
+ * never had a per-tool timer, and the only way to say it, since a finite
41
+ * number is always a ceiling and `Infinity` silently desugars to `setTimeout`'s
42
+ * ~24.9-day cap. Every loop resolves it through here so `null` cannot come to
43
+ * mean "the default" on one provider and "unbounded" on another.
44
+ */
45
+ export declare function resolveToolTimeoutMs(toolTimeoutMs: number | null | undefined): number | null;
33
46
  /**
34
47
  * Default wrap-up lead applied when `turnTimeoutMs` is set but
35
48
  * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
@@ -122,9 +122,26 @@ export const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300_000;
122
122
  * Default per-tool-execution timeout for native agentic loops. A tool that
123
123
  * exceeds it fails with an error tool_result and costs one step — the turn
124
124
  * continues instead of hanging on a wedged tool. Override per call with
125
- * `toolTimeoutMs`.
125
+ * `toolTimeoutMs`, or remove the bound entirely with `toolTimeoutMs: null`.
126
126
  */
127
127
  export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
128
+ /**
129
+ * Resolve a caller's `toolTimeoutMs` into the bound a loop should actually
130
+ * apply: a number of milliseconds, or `null` for no bound at all.
131
+ *
132
+ * The three-way distinction is the whole point, and `??` cannot express it:
133
+ * `undefined` means "no opinion, take the default", while `null` is a stated
134
+ * choice to run tools unbounded — the pre-existing behaviour of the loops that
135
+ * never had a per-tool timer, and the only way to say it, since a finite
136
+ * number is always a ceiling and `Infinity` silently desugars to `setTimeout`'s
137
+ * ~24.9-day cap. Every loop resolves it through here so `null` cannot come to
138
+ * mean "the default" on one provider and "unbounded" on another.
139
+ */
140
+ export function resolveToolTimeoutMs(toolTimeoutMs) {
141
+ return toolTimeoutMs === null
142
+ ? null
143
+ : (toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS);
144
+ }
128
145
  /**
129
146
  * Default wrap-up lead applied when `turnTimeoutMs` is set but
130
147
  * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,