@juspay/neurolink 12.29.2 → 12.30.1
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/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +357 -355
- package/dist/cli/factories/commandFactory.js +2 -1
- package/dist/cli/proxy-clients/codex.js +112 -42
- package/dist/cli/proxy-clients/grok.d.ts +15 -1
- package/dist/cli/proxy-clients/grok.js +186 -28
- package/dist/cli/proxy-clients/snapshot.d.ts +12 -0
- package/dist/cli/proxy-clients/snapshot.js +37 -4
- package/dist/cli/proxy-clients/tomlSections.d.ts +74 -0
- package/dist/cli/proxy-clients/tomlSections.js +365 -0
- package/dist/core/baseProvider.js +1 -0
- package/dist/core/modules/MessageBuilder.js +8 -0
- package/dist/processors/media/VideoProcessor.d.ts +56 -4
- package/dist/processors/media/VideoProcessor.js +263 -19
- package/dist/types/file.d.ts +20 -2
- package/dist/types/generate.d.ts +20 -26
- package/dist/types/processor.d.ts +43 -0
- package/dist/types/proxyClient.d.ts +28 -3
- package/dist/types/stream.d.ts +11 -11
- package/dist/utils/fileDetector.d.ts +15 -0
- package/dist/utils/fileDetector.js +40 -7
- package/dist/utils/mediaDuration.d.ts +19 -0
- package/dist/utils/mediaDuration.js +23 -0
- package/dist/utils/messageBuilder.js +4 -15
- package/dist/utils/multimodalOptionsBuilder.d.ts +2 -0
- package/dist/utils/multimodalOptionsBuilder.js +7 -0
- package/docs-site/static/search-index.json +2 -2
- package/package.json +3 -2
|
@@ -171,7 +171,8 @@ export class CLICommandFactory {
|
|
|
171
171
|
"transcribe-audio": {
|
|
172
172
|
type: "boolean",
|
|
173
173
|
default: false,
|
|
174
|
-
description: "
|
|
174
|
+
description: "Transcribe the video's spoken audio (needs ffmpeg and OPENAI_API_KEY). " +
|
|
175
|
+
"Unnecessary for Gemini, which hears the clip directly",
|
|
175
176
|
},
|
|
176
177
|
file: {
|
|
177
178
|
type: "string",
|
|
@@ -8,6 +8,7 @@ import { homedir } from "os";
|
|
|
8
8
|
import { join } from "path";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
10
10
|
import { writeFileAtomic } from "./snapshot.js";
|
|
11
|
+
import { dottedTableIds, findDuplicateTable, ownedLineCount, readStringValue, splitTomlSections, } from "./tomlSections.js";
|
|
11
12
|
//
|
|
12
13
|
// Points the Codex CLI at the proxy by managing `~/.codex/config.toml`:
|
|
13
14
|
// - appends a marker-delimited `[model_providers.neurolink]` table
|
|
@@ -28,18 +29,67 @@ function getCodexSnapshotPath() {
|
|
|
28
29
|
}
|
|
29
30
|
const CODEX_BLOCK_BEGIN = "# >>> neurolink-proxy (managed) >>>";
|
|
30
31
|
const CODEX_BLOCK_END = "# <<< neurolink-proxy (managed) <<<";
|
|
32
|
+
const CODEX_MANAGED_COMMENT_LINES = new Set([
|
|
33
|
+
CODEX_BLOCK_BEGIN,
|
|
34
|
+
CODEX_BLOCK_END,
|
|
35
|
+
]);
|
|
36
|
+
const CODEX_PROVIDER_ID = "neurolink";
|
|
37
|
+
const CODEX_PROVIDER_NAME = "NeuroLink Proxy";
|
|
31
38
|
const CODEX_PROVIDER_LINE_RE = /^[ \t]*model_provider[ \t]*=.*$/m;
|
|
32
39
|
const CODEX_MODEL_LINE_RE = /^[ \t]*model[ \t]*=.*$/m;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const CODEX_MANAGED_SELECTOR_RE = /^[ \t]*model_provider[ \t]*=[ \t]*"neurolink"[ \t]*$\n?/m;
|
|
41
|
+
/**
|
|
42
|
+
* What of a Codex config.toml the proxy owns, found by content rather than
|
|
43
|
+
* by the block markers. Codex's own saves keep comments (measured on
|
|
44
|
+
* codex-cli 0.155.1), but a hand edit or another tool need not, and a
|
|
45
|
+
* marker-only writer then appended a second `[model_providers.neurolink]`,
|
|
46
|
+
* which Codex cannot parse. The proxy's provider is that table carrying the
|
|
47
|
+
* name the proxy writes, with any sub-tables under it. `foreign` is set when
|
|
48
|
+
* a `neurolink` provider exists that the proxy did not write: another name, a
|
|
49
|
+
* dotted-key or array definition, or sub-tables with no provider table, which
|
|
50
|
+
* the proxy never writes. Writing ours beside it would declare the table twice
|
|
51
|
+
* or adopt the user's keys. `remainder` keeps every byte the proxy does not
|
|
52
|
+
* own, including comments the user wrote after its table.
|
|
53
|
+
*/
|
|
54
|
+
function inspectCodexConfig(text) {
|
|
55
|
+
const sections = splitTomlSections(text);
|
|
56
|
+
const isProvider = (section) => section.path !== null &&
|
|
57
|
+
section.path.length >= 2 &&
|
|
58
|
+
section.path[0] === "model_providers" &&
|
|
59
|
+
section.path[1] === CODEX_PROVIDER_ID;
|
|
60
|
+
const tables = sections.filter((section) => isProvider(section) && !section.arrayTable && section.path?.length === 2);
|
|
61
|
+
const ours = (section) => readStringValue(section, "name") === CODEX_PROVIDER_NAME;
|
|
62
|
+
const foreign = tables.some((table) => !ours(table)) ||
|
|
63
|
+
(tables.length === 0 && sections.some(isProvider)) ||
|
|
64
|
+
sections.some((section) => isProvider(section) && section.arrayTable) ||
|
|
65
|
+
dottedTableIds(sections, "model_providers").includes(CODEX_PROVIDER_ID);
|
|
66
|
+
const owned = sections.map((section) => isProvider(section) &&
|
|
67
|
+
!section.arrayTable &&
|
|
68
|
+
(section.path?.length === 2 ? ours(section) : !foreign));
|
|
69
|
+
const managedBaseUrls = [
|
|
70
|
+
...new Set(sections.flatMap((section, index) => {
|
|
71
|
+
const url = owned[index]
|
|
72
|
+
? readStringValue(section, "base_url")
|
|
73
|
+
: undefined;
|
|
74
|
+
return url === undefined ? [] : [url];
|
|
75
|
+
})),
|
|
76
|
+
];
|
|
77
|
+
const remainder = sections
|
|
78
|
+
.flatMap((section, index) => owned[index]
|
|
79
|
+
? section.lines.slice(ownedLineCount(section, CODEX_MANAGED_COMMENT_LINES))
|
|
80
|
+
: section.lines)
|
|
81
|
+
.filter((line) => !CODEX_MANAGED_COMMENT_LINES.has(line.trim()))
|
|
82
|
+
.join("");
|
|
83
|
+
return { remainder, managedBaseUrls, foreign };
|
|
40
84
|
}
|
|
41
|
-
|
|
42
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Remove the selector the proxy injected. Only the preamble holds top-level
|
|
87
|
+
* keys: a profile's own `model_provider = "neurolink"` is the user's.
|
|
88
|
+
*/
|
|
89
|
+
function stripCodexSelector(text) {
|
|
90
|
+
return editTomlPreamble(text, (preamble) => CODEX_MANAGED_SELECTOR_RE.test(preamble)
|
|
91
|
+
? preamble.replace(CODEX_MANAGED_SELECTOR_RE, "")
|
|
92
|
+
: null);
|
|
43
93
|
}
|
|
44
94
|
/**
|
|
45
95
|
* Apply a top-level TOML key edit, confined to the document preamble.
|
|
@@ -60,8 +110,8 @@ function editTomlPreamble(text, edit) {
|
|
|
60
110
|
function buildCodexProviderBlock(baseUrl) {
|
|
61
111
|
return [
|
|
62
112
|
CODEX_BLOCK_BEGIN,
|
|
63
|
-
|
|
64
|
-
|
|
113
|
+
`[model_providers.${CODEX_PROVIDER_ID}]`,
|
|
114
|
+
`name = "${CODEX_PROVIDER_NAME}"`,
|
|
65
115
|
`base_url = "${baseUrl}/backend-api/codex"`,
|
|
66
116
|
'wire_api = "responses"',
|
|
67
117
|
"requires_openai_auth = true",
|
|
@@ -77,6 +127,37 @@ export async function setCodexProxySettings(baseUrl) {
|
|
|
77
127
|
return false;
|
|
78
128
|
}
|
|
79
129
|
const original = fs.readFileSync(getCodexConfigPath(), "utf8");
|
|
130
|
+
const inspected = inspectCodexConfig(original);
|
|
131
|
+
if (inspected.foreign) {
|
|
132
|
+
logger.warn("[proxy] Codex: config.toml already defines a neurolink provider the proxy did not write; leaving it untouched");
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
let text = stripCodexSelector(inspected.remainder);
|
|
136
|
+
// Set the selector: replace an existing top-level model_provider or insert
|
|
137
|
+
// one right after the top-level `model = ...` line (stays before any table).
|
|
138
|
+
let selectorPlaced = false;
|
|
139
|
+
text = editTomlPreamble(text, (preamble) => {
|
|
140
|
+
if (CODEX_PROVIDER_LINE_RE.test(preamble)) {
|
|
141
|
+
selectorPlaced = true;
|
|
142
|
+
return preamble.replace(CODEX_PROVIDER_LINE_RE, 'model_provider = "neurolink"');
|
|
143
|
+
}
|
|
144
|
+
if (CODEX_MODEL_LINE_RE.test(preamble)) {
|
|
145
|
+
selectorPlaced = true;
|
|
146
|
+
return preamble.replace(CODEX_MODEL_LINE_RE, (line) => `${line}\nmodel_provider = "neurolink"`);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
});
|
|
150
|
+
if (!selectorPlaced) {
|
|
151
|
+
text = `model_provider = "neurolink"\n${text}`;
|
|
152
|
+
}
|
|
153
|
+
const next = `${text.replace(/\s*$/, "\n")}\n${buildCodexProviderBlock(baseUrl)}`;
|
|
154
|
+
// Codex will not load a config that declares a table twice. Writing into
|
|
155
|
+
// one would only bury the user's own error under ours.
|
|
156
|
+
const duplicate = findDuplicateTable(next);
|
|
157
|
+
if (duplicate !== undefined) {
|
|
158
|
+
logger.warn(`[proxy] Codex: config.toml declares [${duplicate}] more than once; leaving it untouched`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
80
161
|
// Snapshot the user's original selector once (survives crashes/restarts).
|
|
81
162
|
if (!fs.existsSync(getCodexSnapshotPath())) {
|
|
82
163
|
// Same preamble boundary the edit paths use. Matching document-wide would
|
|
@@ -95,26 +176,11 @@ export async function setCodexProxySettings(baseUrl) {
|
|
|
95
176
|
fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
|
|
96
177
|
await writeFileAtomic(getCodexSnapshotPath(), JSON.stringify({ originalProviderLine }, null, 2), 0o600);
|
|
97
178
|
}
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
text = editTomlPreamble(text, (preamble) => {
|
|
103
|
-
if (CODEX_PROVIDER_LINE_RE.test(preamble)) {
|
|
104
|
-
selectorPlaced = true;
|
|
105
|
-
return preamble.replace(CODEX_PROVIDER_LINE_RE, 'model_provider = "neurolink"');
|
|
106
|
-
}
|
|
107
|
-
if (CODEX_MODEL_LINE_RE.test(preamble)) {
|
|
108
|
-
selectorPlaced = true;
|
|
109
|
-
return preamble.replace(CODEX_MODEL_LINE_RE, (line) => `${line}\nmodel_provider = "neurolink"`);
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
112
|
-
});
|
|
113
|
-
if (!selectorPlaced) {
|
|
114
|
-
text = `model_provider = "neurolink"\n${text}`;
|
|
179
|
+
// Every proxy start applies. Rewriting a file that already matches only
|
|
180
|
+
// widens the window for racing Codex's own saves.
|
|
181
|
+
if (next !== original) {
|
|
182
|
+
await writeFileAtomic(getCodexConfigPath(), next);
|
|
115
183
|
}
|
|
116
|
-
const trimmed = text.replace(/\s*$/, "\n");
|
|
117
|
-
await writeFileAtomic(getCodexConfigPath(), `${trimmed}\n${buildCodexProviderBlock(baseUrl)}`);
|
|
118
184
|
return true;
|
|
119
185
|
}
|
|
120
186
|
catch (error) {
|
|
@@ -129,19 +195,23 @@ export async function clearCodexProxySettings(expectedBaseUrl) {
|
|
|
129
195
|
return false;
|
|
130
196
|
}
|
|
131
197
|
const original = fs.readFileSync(getCodexConfigPath(), "utf8");
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
198
|
+
const inspected = inspectCodexConfig(original);
|
|
199
|
+
// Apply refuses such a config, so the selector pointing at the user's own
|
|
200
|
+
// provider is theirs as well.
|
|
201
|
+
if (inspected.foreign) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
// The managed table records its owner in `base_url`. Without this check a
|
|
205
|
+
// second proxy shutting down on another port would strip the table that
|
|
206
|
+
// the still-running proxy installed, and restore the snapshot selector on
|
|
207
|
+
// top — silently taking Codex off the live proxy. Mirrors the Claude and
|
|
208
|
+
// OpenCode clear paths.
|
|
209
|
+
if (expectedBaseUrl &&
|
|
210
|
+
inspected.managedBaseUrls.length > 0 &&
|
|
211
|
+
!inspected.managedBaseUrls.includes(`${expectedBaseUrl}/backend-api/codex`)) {
|
|
212
|
+
return false;
|
|
143
213
|
}
|
|
144
|
-
let text =
|
|
214
|
+
let text = stripCodexSelector(inspected.remainder);
|
|
145
215
|
// Restore the user's original selector line if we snapshotted one.
|
|
146
216
|
let restored = null;
|
|
147
217
|
if (fs.existsSync(getCodexSnapshotPath())) {
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* Adaptive thinking: Grok's global `default_reasoning_effort = "xhigh"`
|
|
19
19
|
* becomes Anthropic `thinking.type = "adaptive"`. Haiku 4.5 rejects that
|
|
20
20
|
* with 400. Only Claude Opus/Sonnet 4.6 and 5.x keep reasoning enabled.
|
|
21
|
+
*
|
|
22
|
+
* Ownership is decided by content, never by the block markers alone. Grok
|
|
23
|
+
* rewrites config.toml on its own saves (`/settings`, `grok mcp add`, ...)
|
|
24
|
+
* through a serializer that drops every comment, the markers included, and
|
|
25
|
+
* splits the inline `extra_headers` into a `[model.<id>.extra_headers]`
|
|
26
|
+
* sub-table (measured on Grok Build 1.0.40). A marker-only writer then kept
|
|
27
|
+
* the unmarked tables and appended a second copy of every id on the next
|
|
28
|
+
* proxy start, and TOML rejects a table declared twice, so Grok would not
|
|
29
|
+
* start. What marks an entry as ours is the placeholder key, a credential
|
|
30
|
+
* only the proxy accepts, and that survives Grok's rewrite.
|
|
21
31
|
*/
|
|
22
32
|
import type { CliGrokProxyModelSpec, CliProxyClientApplyOptions, CliProxyClientConfigurator, ModelMapping } from "../../types/index.js";
|
|
23
33
|
declare function getGrokConfigDir(): string;
|
|
@@ -26,7 +36,11 @@ declare function getGrokSnapshotPath(): string;
|
|
|
26
36
|
declare function classifyGrokProxyModel(id: string, mapping?: ModelMapping): CliGrokProxyModelSpec;
|
|
27
37
|
declare function loadRoutedModelIds(configPath?: string): Promise<string[]>;
|
|
28
38
|
declare function catalogModelIds(configPath?: string): Promise<string[]>;
|
|
29
|
-
|
|
39
|
+
/**
|
|
40
|
+
* `userModelIds` are ids the user defines themselves. Writing the proxy's
|
|
41
|
+
* entry beside theirs would declare the table twice, so theirs wins.
|
|
42
|
+
*/
|
|
43
|
+
declare function buildGrokManagedBlock(baseUrl: string, configPath?: string, userModelIds?: ReadonlySet<string>): Promise<string>;
|
|
30
44
|
export declare function setGrokProxySettings(baseUrl: string, options?: CliProxyClientApplyOptions): Promise<boolean>;
|
|
31
45
|
export declare function clearGrokProxySettings(expectedBaseUrl?: string): Promise<boolean>;
|
|
32
46
|
export declare const grokConfigurator: CliProxyClientConfigurator;
|
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* Adaptive thinking: Grok's global `default_reasoning_effort = "xhigh"`
|
|
19
19
|
* becomes Anthropic `thinking.type = "adaptive"`. Haiku 4.5 rejects that
|
|
20
20
|
* with 400. Only Claude Opus/Sonnet 4.6 and 5.x keep reasoning enabled.
|
|
21
|
+
*
|
|
22
|
+
* Ownership is decided by content, never by the block markers alone. Grok
|
|
23
|
+
* rewrites config.toml on its own saves (`/settings`, `grok mcp add`, ...)
|
|
24
|
+
* through a serializer that drops every comment, the markers included, and
|
|
25
|
+
* splits the inline `extra_headers` into a `[model.<id>.extra_headers]`
|
|
26
|
+
* sub-table (measured on Grok Build 1.0.40). A marker-only writer then kept
|
|
27
|
+
* the unmarked tables and appended a second copy of every id on the next
|
|
28
|
+
* proxy start, and TOML rejects a table declared twice, so Grok would not
|
|
29
|
+
* start. What marks an entry as ours is the placeholder key, a credential
|
|
30
|
+
* only the proxy accepts, and that survives Grok's rewrite.
|
|
21
31
|
*/
|
|
22
32
|
import { readFileSync } from "fs";
|
|
23
33
|
import { homedir } from "os";
|
|
@@ -26,9 +36,19 @@ import { logger } from "../../utils/logger.js";
|
|
|
26
36
|
import { getContextWindowSize } from "../../constants/contextWindows.js";
|
|
27
37
|
import { DEFAULT_PROXY_MODEL_IDS } from "../../constants/proxyModels.js";
|
|
28
38
|
import { defaultProxyConfigPath, parseProxyConfigString, } from "../../proxy/proxyConfig.js";
|
|
29
|
-
import { isUsableSnapshot, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
|
|
39
|
+
import { isUsableSnapshot, resolveSymlinkTarget, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
|
|
40
|
+
import { dottedTableIds, findDuplicateTable, inlineTablePairs, maskTomlStrings, maskedStringValue, ownedLineCount, parseMaskedAssignment, readStringValue, splitTomlSections, tomlCodeLines, } from "./tomlSections.js";
|
|
30
41
|
const GROK_BLOCK_BEGIN = "# >>> neurolink-proxy (managed) >>>";
|
|
31
42
|
const GROK_BLOCK_END = "# <<< neurolink-proxy (managed) <<<";
|
|
43
|
+
const GROK_BLOCK_NOTES = [
|
|
44
|
+
"# Proxy catalog. Built-in grok-4.6 / grok-4.5 stay on xAI.",
|
|
45
|
+
"# context_window is Grok's compaction limit and must be <= upstream.",
|
|
46
|
+
];
|
|
47
|
+
const GROK_MANAGED_COMMENT_LINES = new Set([
|
|
48
|
+
GROK_BLOCK_BEGIN,
|
|
49
|
+
GROK_BLOCK_END,
|
|
50
|
+
...GROK_BLOCK_NOTES,
|
|
51
|
+
]);
|
|
32
52
|
const PLACEHOLDER_KEY = "neurolink-proxy";
|
|
33
53
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
34
54
|
function getGrokConfigDir() {
|
|
@@ -43,9 +63,6 @@ function getGrokConfigPath() {
|
|
|
43
63
|
function getGrokSnapshotPath() {
|
|
44
64
|
return join(homedir(), ".neurolink", "grok-proxy-snapshot.json");
|
|
45
65
|
}
|
|
46
|
-
function escapeRegExp(value) {
|
|
47
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48
|
-
}
|
|
49
66
|
function tomlKey(id) {
|
|
50
67
|
return /^[A-Za-z0-9_-]+$/.test(id) ? id : JSON.stringify(id);
|
|
51
68
|
}
|
|
@@ -172,28 +189,148 @@ function buildGrokModelBlock(spec, baseUrl) {
|
|
|
172
189
|
}
|
|
173
190
|
return lines.join("\n");
|
|
174
191
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
/**
|
|
193
|
+
* `userModelIds` are ids the user defines themselves. Writing the proxy's
|
|
194
|
+
* entry beside theirs would declare the table twice, so theirs wins.
|
|
195
|
+
*/
|
|
196
|
+
async function buildGrokManagedBlock(baseUrl, configPath, userModelIds = new Set()) {
|
|
197
|
+
const catalog = await catalogGrokSpecs(configPath);
|
|
198
|
+
for (const spec of catalog) {
|
|
199
|
+
if (userModelIds.has(spec.id)) {
|
|
200
|
+
logger.debug(`[proxy] Grok: config.toml already defines [model.${tomlKey(spec.id)}]; keeping it and skipping the proxy's entry`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const body = catalog
|
|
204
|
+
.filter((spec) => !userModelIds.has(spec.id))
|
|
178
205
|
.map((spec) => buildGrokModelBlock(spec, baseUrl))
|
|
179
206
|
.join("\n\n");
|
|
180
207
|
return [
|
|
181
208
|
GROK_BLOCK_BEGIN,
|
|
182
|
-
|
|
183
|
-
"# context_window is Grok's compaction limit and must be <= upstream.",
|
|
209
|
+
...GROK_BLOCK_NOTES,
|
|
184
210
|
"",
|
|
185
211
|
body,
|
|
186
212
|
GROK_BLOCK_END,
|
|
187
213
|
"",
|
|
188
214
|
].join("\n");
|
|
189
215
|
}
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
216
|
+
function modelEntryId(section) {
|
|
217
|
+
return section.path !== null &&
|
|
218
|
+
section.path.length >= 2 &&
|
|
219
|
+
section.path[0] === "model"
|
|
220
|
+
? section.path[1]
|
|
221
|
+
: null;
|
|
222
|
+
}
|
|
223
|
+
/** Where a model entry carries the placeholder key, as a path within it. */
|
|
224
|
+
const PLACEHOLDER_KEY_PATHS = [
|
|
225
|
+
["api_key"],
|
|
226
|
+
["extra_headers", "x-api-key"],
|
|
227
|
+
];
|
|
228
|
+
function isPlaceholderKey(path, value) {
|
|
229
|
+
return (value === PLACEHOLDER_KEY &&
|
|
230
|
+
PLACEHOLDER_KEY_PATHS.some((keyPath) => keyPath.length === path.length &&
|
|
231
|
+
keyPath.every((part, index) => path[index] === part)));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Whether a section of a model entry sends the placeholder key: `api_key` on
|
|
235
|
+
* the model, or `x-api-key` in its `extra_headers`, spelled as a plain key, a
|
|
236
|
+
* dotted key, an inline table or the sub-table Grok's own save writes. The
|
|
237
|
+
* full key path must match, so a user's `legacy.api_key` is not the proxy's.
|
|
238
|
+
*/
|
|
239
|
+
function sectionSendsPlaceholderKey(section) {
|
|
240
|
+
const prefix = section.path?.slice(2) ?? [];
|
|
241
|
+
return tomlCodeLines(section).some(({ line, statement }) => {
|
|
242
|
+
if (!statement) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const { masked, strings } = maskTomlStrings(line);
|
|
246
|
+
const assignment = parseMaskedAssignment(masked, strings);
|
|
247
|
+
if (!assignment) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
const path = [...prefix, ...assignment.path];
|
|
251
|
+
return (isPlaceholderKey(path, maskedStringValue(assignment.value, strings)) ||
|
|
252
|
+
inlineTablePairs(assignment.value, strings).some((pair) => isPlaceholderKey([...path, ...pair.path], maskedStringValue(pair.value, strings))));
|
|
253
|
+
});
|
|
193
254
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Which parts of a Grok config.toml the proxy owns.
|
|
257
|
+
*
|
|
258
|
+
* A model entry is a `[model.<id>]` table plus its `[model.<id>.*]`
|
|
259
|
+
* sub-tables, wherever they sit: Grok's save keeps them together, hand edits
|
|
260
|
+
* need not. It is the proxy's when it sends the placeholder key and is either
|
|
261
|
+
* in the current catalog or still carries the exact name the proxy gave it,
|
|
262
|
+
* which covers entries a later catalog dropped. A user's own model behind the
|
|
263
|
+
* proxy, under their own name, is theirs. Keys and values are read as TOML,
|
|
264
|
+
* never as raw text, so a string that merely mentions the placeholder cannot
|
|
265
|
+
* make a user's table look like the proxy's. `remainder` is the text with
|
|
266
|
+
* owned entries and the managed comment lines removed and every other byte
|
|
267
|
+
* kept, so a missing, orphaned or doubled marker cannot make an apply delete
|
|
268
|
+
* the user's content or leave an old copy of the catalog behind.
|
|
269
|
+
*/
|
|
270
|
+
function inspectGrokConfig(text, catalogIds) {
|
|
271
|
+
const sections = splitTomlSections(text);
|
|
272
|
+
// Models the user defines without a [model.<id>] table of their own.
|
|
273
|
+
const definedElsewhere = new Set([
|
|
274
|
+
...dottedTableIds(sections, "model"),
|
|
275
|
+
...sections.flatMap((section) => {
|
|
276
|
+
const id = section.arrayTable ? modelEntryId(section) : null;
|
|
277
|
+
return id !== null && section.path?.length === 2 ? [id] : [];
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
const ids = sections.map((section) => section.arrayTable ? null : modelEntryId(section));
|
|
281
|
+
const isModelTable = (index) => ids[index] !== null && sections[index].path?.length === 2;
|
|
282
|
+
const tableFor = (index) => {
|
|
283
|
+
if (ids[index] === null || isModelTable(index)) {
|
|
284
|
+
return index;
|
|
285
|
+
}
|
|
286
|
+
const sameModel = (candidate) => isModelTable(candidate) && ids[candidate] === ids[index];
|
|
287
|
+
const before = sections.findLastIndex((_, candidate) => candidate < index && sameModel(candidate));
|
|
288
|
+
const after = sections.findIndex((_, candidate) => candidate > index && sameModel(candidate));
|
|
289
|
+
return before !== -1 ? before : after !== -1 ? after : index;
|
|
290
|
+
};
|
|
291
|
+
const entryOf = sections.map((_, index) => tableFor(index));
|
|
292
|
+
const ownedEntries = new Set(entryOf.filter((entry) => {
|
|
293
|
+
const id = ids[entry];
|
|
294
|
+
if (id === null) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
const sendsPlaceholder = sections.some((section, index) => entryOf[index] === entry &&
|
|
298
|
+
ids[index] !== null &&
|
|
299
|
+
sectionSendsPlaceholderKey(section));
|
|
300
|
+
if (!sendsPlaceholder) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
// A sub-table left without its table carries no name to check; it is
|
|
304
|
+
// the proxy's unless the model is defined some other way.
|
|
305
|
+
if (!isModelTable(entry)) {
|
|
306
|
+
return !definedElsewhere.has(id);
|
|
307
|
+
}
|
|
308
|
+
return (catalogIds.has(id) ||
|
|
309
|
+
readStringValue(sections[entry], "name") === displayNameFor(id));
|
|
310
|
+
}));
|
|
311
|
+
const owned = sections.map((_, index) => ids[index] !== null && ownedEntries.has(entryOf[index]));
|
|
312
|
+
const userModelIds = new Set([
|
|
313
|
+
...definedElsewhere,
|
|
314
|
+
...sections.flatMap((section, index) => {
|
|
315
|
+
const id = modelEntryId(section);
|
|
316
|
+
return id !== null && !owned[index] ? [id] : [];
|
|
317
|
+
}),
|
|
318
|
+
]);
|
|
319
|
+
const managedBaseUrls = [
|
|
320
|
+
...new Set(sections.flatMap((section, index) => {
|
|
321
|
+
const url = owned[index]
|
|
322
|
+
? readStringValue(section, "base_url")
|
|
323
|
+
: undefined;
|
|
324
|
+
return url === undefined ? [] : [url];
|
|
325
|
+
})),
|
|
326
|
+
];
|
|
327
|
+
const remainder = sections
|
|
328
|
+
.flatMap((section, index) => owned[index]
|
|
329
|
+
? section.lines.slice(ownedLineCount(section, GROK_MANAGED_COMMENT_LINES))
|
|
330
|
+
: section.lines)
|
|
331
|
+
.filter((line) => !GROK_MANAGED_COMMENT_LINES.has(line.trim()))
|
|
332
|
+
.join("");
|
|
333
|
+
return { remainder, managedBaseUrls, userModelIds };
|
|
197
334
|
}
|
|
198
335
|
async function readGrokSnapshot() {
|
|
199
336
|
const fs = await import("fs");
|
|
@@ -250,11 +387,26 @@ export async function setGrokProxySettings(baseUrl, options) {
|
|
|
250
387
|
: original.indexOf(GROK_BLOCK_END) + GROK_BLOCK_END.length)
|
|
251
388
|
: undefined
|
|
252
389
|
: undefined;
|
|
390
|
+
const inspected = original === null
|
|
391
|
+
? null
|
|
392
|
+
: inspectGrokConfig(original, new Set(await catalogModelIds(options?.configPath)));
|
|
393
|
+
const block = await buildGrokManagedBlock(baseUrl, options?.configPath, inspected?.userModelIds);
|
|
394
|
+
const remainder = inspected?.remainder ?? "";
|
|
395
|
+
const next = remainder.trim().length === 0
|
|
396
|
+
? block
|
|
397
|
+
: `${remainder.replace(/\s*$/, "\n")}\n${block}`;
|
|
398
|
+
// Grok refuses to load or save a config it cannot parse. Writing into one
|
|
399
|
+
// would only bury the user's own error under ours.
|
|
400
|
+
const duplicate = findDuplicateTable(next);
|
|
401
|
+
if (duplicate !== undefined) {
|
|
402
|
+
logger.warn(`[proxy] Grok: config.toml declares [${duplicate}] more than once; leaving it untouched`);
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
253
405
|
if (existingSnapshot === null ||
|
|
254
406
|
shouldCaptureSnapshot({
|
|
255
407
|
hasSnapshot: existingSnapshot !== null,
|
|
256
408
|
written: existingSnapshot?.writtenBaseUrl,
|
|
257
|
-
current:
|
|
409
|
+
current: inspected?.managedBaseUrls[0] ?? currentBlock,
|
|
258
410
|
})) {
|
|
259
411
|
fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
|
|
260
412
|
await writeFileAtomic(getGrokSnapshotPath(), JSON.stringify({
|
|
@@ -262,12 +414,11 @@ export async function setGrokProxySettings(baseUrl, options) {
|
|
|
262
414
|
writtenBaseUrl: baseUrl,
|
|
263
415
|
}, null, 2), 0o600);
|
|
264
416
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
: next, original === null ? 0o600 : undefined);
|
|
417
|
+
// Every proxy start applies; an update starts one. Rewriting a file that
|
|
418
|
+
// already matches only widens the window for racing Grok's own saves.
|
|
419
|
+
if (next !== original) {
|
|
420
|
+
await writeFileAtomic(getGrokConfigPath(), next, original === null ? 0o600 : undefined);
|
|
421
|
+
}
|
|
271
422
|
return true;
|
|
272
423
|
}
|
|
273
424
|
export async function clearGrokProxySettings(expectedBaseUrl) {
|
|
@@ -279,13 +430,16 @@ export async function clearGrokProxySettings(expectedBaseUrl) {
|
|
|
279
430
|
catch {
|
|
280
431
|
return false;
|
|
281
432
|
}
|
|
282
|
-
|
|
433
|
+
const inspected = inspectGrokConfig(current, new Set(await catalogModelIds()));
|
|
434
|
+
if (inspected.remainder === current) {
|
|
283
435
|
return false;
|
|
284
436
|
}
|
|
285
|
-
|
|
437
|
+
// Another proxy's block is left alone. One entry the user repointed does
|
|
438
|
+
// not make the rest foreign, whichever entry happens to come first.
|
|
439
|
+
const configuredUrls = inspected.managedBaseUrls;
|
|
286
440
|
if (expectedBaseUrl &&
|
|
287
|
-
|
|
288
|
-
|
|
441
|
+
configuredUrls.length > 0 &&
|
|
442
|
+
!configuredUrls.includes(expectedBaseUrl)) {
|
|
289
443
|
logger.debug("[proxy] Grok clear: base URL is not the one we wrote, leaving it intact");
|
|
290
444
|
return false;
|
|
291
445
|
}
|
|
@@ -294,9 +448,13 @@ export async function clearGrokProxySettings(expectedBaseUrl) {
|
|
|
294
448
|
logger.warn("[proxy] Grok clear: no usable snapshot, leaving config.toml untouched rather than stripping a block we cannot prove we own");
|
|
295
449
|
return false;
|
|
296
450
|
}
|
|
297
|
-
const remainder =
|
|
451
|
+
const remainder = inspected.remainder.replace(/\s*$/, "\n");
|
|
298
452
|
if (!snapshot.originalExisted && remainder.trim().length === 0) {
|
|
299
|
-
|
|
453
|
+
// Apply wrote through a symlink the user already had, so the link stays
|
|
454
|
+
// and the file apply created behind it goes.
|
|
455
|
+
fs.rmSync(await resolveSymlinkTarget(getGrokConfigPath()), {
|
|
456
|
+
force: true,
|
|
457
|
+
});
|
|
300
458
|
}
|
|
301
459
|
else {
|
|
302
460
|
await writeFileAtomic(getGrokConfigPath(), remainder);
|
|
@@ -63,6 +63,11 @@ export declare function isProxyOwnedValue(args: {
|
|
|
63
63
|
written: unknown;
|
|
64
64
|
current: unknown;
|
|
65
65
|
}): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The path a chain of symlinks finally names, which need not exist. A path
|
|
68
|
+
* that is not a link comes back unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveSymlinkTarget(filePath: string): Promise<string>;
|
|
66
71
|
/**
|
|
67
72
|
* Replace a file's contents without ever exposing a partial one.
|
|
68
73
|
*
|
|
@@ -112,5 +117,12 @@ export declare function isProxyOwnedValue(args: {
|
|
|
112
117
|
* is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a
|
|
113
118
|
* tidy-up and would quietly restore the exact window this function exists to
|
|
114
119
|
* close, on Windows only, where nobody here would see it.
|
|
120
|
+
*
|
|
121
|
+
* A config kept in a dotfiles repository is often a symlink into it, a setup
|
|
122
|
+
* Grok documents and its own writer honours. A rename over the link would
|
|
123
|
+
* replace the link with a regular file and leave the repository's copy stale,
|
|
124
|
+
* so the write lands on the end of the link chain instead, which need not
|
|
125
|
+
* exist yet. The temp file is that target's sibling, so the rename stays
|
|
126
|
+
* atomic.
|
|
115
127
|
*/
|
|
116
128
|
export declare function writeFileAtomic(filePath: string, contents: string, mode?: number): Promise<void>;
|
|
@@ -113,6 +113,31 @@ export function isProxyOwnedValue(args) {
|
|
|
113
113
|
}
|
|
114
114
|
return valuesMatch(args.current, args.written);
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The path a chain of symlinks finally names, which need not exist. A path
|
|
118
|
+
* that is not a link comes back unchanged.
|
|
119
|
+
*/
|
|
120
|
+
export async function resolveSymlinkTarget(filePath) {
|
|
121
|
+
const fs = await import("fs");
|
|
122
|
+
const { dirname, resolve } = await import("path");
|
|
123
|
+
let target = filePath;
|
|
124
|
+
for (let hops = 0;; hops += 1) {
|
|
125
|
+
let isLink;
|
|
126
|
+
try {
|
|
127
|
+
isLink = fs.lstatSync(target).isSymbolicLink();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
isLink = false;
|
|
131
|
+
}
|
|
132
|
+
if (!isLink) {
|
|
133
|
+
return target;
|
|
134
|
+
}
|
|
135
|
+
if (hops === 40) {
|
|
136
|
+
throw new Error(`${filePath}: too many levels of symbolic links`);
|
|
137
|
+
}
|
|
138
|
+
target = resolve(dirname(target), fs.readlinkSync(target));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
116
141
|
/** Distinguishes concurrent writers within one process. */
|
|
117
142
|
let atomicWriteCounter = 0;
|
|
118
143
|
/**
|
|
@@ -164,12 +189,20 @@ let atomicWriteCounter = 0;
|
|
|
164
189
|
* is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a
|
|
165
190
|
* tidy-up and would quietly restore the exact window this function exists to
|
|
166
191
|
* close, on Windows only, where nobody here would see it.
|
|
192
|
+
*
|
|
193
|
+
* A config kept in a dotfiles repository is often a symlink into it, a setup
|
|
194
|
+
* Grok documents and its own writer honours. A rename over the link would
|
|
195
|
+
* replace the link with a regular file and leave the repository's copy stale,
|
|
196
|
+
* so the write lands on the end of the link chain instead, which need not
|
|
197
|
+
* exist yet. The temp file is that target's sibling, so the rename stays
|
|
198
|
+
* atomic.
|
|
167
199
|
*/
|
|
168
200
|
export async function writeFileAtomic(filePath, contents, mode) {
|
|
169
201
|
const fs = await import("fs");
|
|
170
202
|
const { dirname, join, basename } = await import("path");
|
|
203
|
+
const target = await resolveSymlinkTarget(filePath);
|
|
171
204
|
atomicWriteCounter += 1;
|
|
172
|
-
const tempPath = join(dirname(
|
|
205
|
+
const tempPath = join(dirname(target), `.${basename(target)}.neurolink-${process.pid}-${atomicWriteCounter}.tmp`);
|
|
173
206
|
// Which step failed changes what the user should do about it: a failed write
|
|
174
207
|
// is usually a missing directory or a full disk and the config is untouched,
|
|
175
208
|
// while a failed rename is a locked destination and the config is intact but
|
|
@@ -180,7 +213,7 @@ export async function writeFileAtomic(filePath, contents, mode) {
|
|
|
180
213
|
let effectiveMode = mode;
|
|
181
214
|
if (effectiveMode === undefined) {
|
|
182
215
|
try {
|
|
183
|
-
effectiveMode = fs.statSync(
|
|
216
|
+
effectiveMode = fs.statSync(target).mode & 0o777;
|
|
184
217
|
}
|
|
185
218
|
catch {
|
|
186
219
|
effectiveMode = 0o600;
|
|
@@ -192,12 +225,12 @@ export async function writeFileAtomic(filePath, contents, mode) {
|
|
|
192
225
|
// caller sees an ENOENT naming a path it never asked to write. Creating
|
|
193
226
|
// the directory first makes a first-run write behave like the plain
|
|
194
227
|
// writeFileSync it replaced.
|
|
195
|
-
fs.mkdirSync(dirname(
|
|
228
|
+
fs.mkdirSync(dirname(target), { recursive: true });
|
|
196
229
|
fs.writeFileSync(tempPath, contents, { mode: effectiveMode });
|
|
197
230
|
stage = "chmod";
|
|
198
231
|
fs.chmodSync(tempPath, effectiveMode);
|
|
199
232
|
stage = "rename";
|
|
200
|
-
fs.renameSync(tempPath,
|
|
233
|
+
fs.renameSync(tempPath, target);
|
|
201
234
|
}
|
|
202
235
|
catch (error) {
|
|
203
236
|
// Never leave scratch in the user's config directory.
|