@juspay/neurolink 11.18.0 → 11.18.2
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 +3 -4
- package/dist/browser/neurolink.min.js +240 -240
- package/dist/cli/commands/proxy.js +15 -2
- package/dist/cli/proxy-clients/claudeCode.js +3 -3
- package/dist/cli/proxy-clients/codex.js +4 -3
- package/dist/cli/proxy-clients/copilot.js +6 -6
- package/dist/cli/proxy-clients/openCode.js +3 -3
- package/dist/cli/proxy-clients/qwenCode.js +3 -3
- package/dist/cli/proxy-clients/snapshot.d.ts +51 -0
- package/dist/cli/proxy-clients/snapshot.js +103 -0
- package/dist/proxy/geminiFormat.d.ts +25 -4
- package/dist/proxy/geminiFormat.js +55 -15
- package/dist/proxy/proxyTranslationEngine.js +1 -1
- package/dist/server/routes/geminiProxyRoutes.js +6 -1
- package/dist/types/proxy.d.ts +9 -9
- package/package.json +1 -1
|
@@ -895,6 +895,11 @@ function printProxyBanner(url, strategy) {
|
|
|
895
895
|
logger.always(chalk.bold("Endpoints:"));
|
|
896
896
|
logger.always(` ${chalk.blue("POST")} /v1/messages — Claude proxy (Anthropic format)`);
|
|
897
897
|
logger.always(` ${chalk.blue("POST")} /v1/chat/completions — OpenAI-compatible proxy`);
|
|
898
|
+
// The banner listed two of the four inbound doors, so the Codex and Gemini
|
|
899
|
+
// CLIs looked unsupported to anyone reading start-up output rather than the
|
|
900
|
+
// docs. Every door the proxy actually answers on belongs here.
|
|
901
|
+
logger.always(` ${chalk.blue("POST")} /backend-api/codex/… — Codex proxy (Responses format)`);
|
|
902
|
+
logger.always(` ${chalk.blue("POST")} /v1beta/models/… — Gemini proxy (generateContent)`);
|
|
898
903
|
logger.always(` ${chalk.green("GET")} /health — Health check`);
|
|
899
904
|
logger.always(` ${chalk.green("GET")} /status — Detailed status`);
|
|
900
905
|
logger.always("");
|
|
@@ -1149,9 +1154,17 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1149
1154
|
throw error;
|
|
1150
1155
|
}
|
|
1151
1156
|
};
|
|
1152
|
-
// Cover
|
|
1153
|
-
//
|
|
1157
|
+
// Cover every inbound door so drain/reject, lifecycle logging, and
|
|
1158
|
+
// concurrency accounting apply to all of them.
|
|
1159
|
+
//
|
|
1160
|
+
// `/v1beta/*` is listed separately on purpose: Hono matches wildcards a path
|
|
1161
|
+
// segment at a time, so `/v1/*` does NOT cover `/v1beta/models/...` — the
|
|
1162
|
+
// segment is `v1beta`, not `v1`. When the Gemini door landed it inherited
|
|
1163
|
+
// neither tracker, which meant its requests were absent from the request
|
|
1164
|
+
// log, from per-CLI attribution, and from the in-flight count the graceful
|
|
1165
|
+
// drain waits on. An update could therefore cut a live Gemini stream.
|
|
1154
1166
|
app.use("/v1/*", trackingHandler);
|
|
1167
|
+
app.use("/v1beta/*", trackingHandler);
|
|
1155
1168
|
app.use("/backend-api/*", trackingHandler);
|
|
1156
1169
|
}
|
|
1157
1170
|
export async function createProxyStartApp(params) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { homedir } from "os";
|
|
9
9
|
import { join } from "path";
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
|
-
import { isProxyOwnedValue, shouldCaptureSnapshot } from "./snapshot.js";
|
|
11
|
+
import { isProxyOwnedValue, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
|
|
12
12
|
/**
|
|
13
13
|
* Resolved per call rather than at module load so `detect()` and `apply()`
|
|
14
14
|
* agree when HOME changes — under test, and on the `--dev` isolation path.
|
|
@@ -63,7 +63,7 @@ export async function setClaudeProxySettings(baseUrl) {
|
|
|
63
63
|
ANTHROPIC_BASE_URL: baseUrl,
|
|
64
64
|
ENABLE_TOOL_SEARCH: "true",
|
|
65
65
|
};
|
|
66
|
-
|
|
66
|
+
await writeFileAtomic(getClaudeSettingsPath(), JSON.stringify(settings, null, 2));
|
|
67
67
|
}
|
|
68
68
|
export async function clearClaudeProxySettings(expectedBaseUrl) {
|
|
69
69
|
const fs = await import("fs");
|
|
@@ -128,7 +128,7 @@ export async function clearClaudeProxySettings(expectedBaseUrl) {
|
|
|
128
128
|
else {
|
|
129
129
|
settings.env = env;
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
await writeFileAtomic(getClaudeSettingsPath(), JSON.stringify(settings, null, 2));
|
|
132
132
|
return hadBaseUrl || hadToolSearch;
|
|
133
133
|
}
|
|
134
134
|
export const claudeCodeConfigurator = {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { homedir } from "os";
|
|
8
8
|
import { join } from "path";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
10
|
+
import { writeFileAtomic } from "./snapshot.js";
|
|
10
11
|
//
|
|
11
12
|
// Points the Codex CLI at the proxy by managing `~/.codex/config.toml`:
|
|
12
13
|
// - appends a marker-delimited `[model_providers.neurolink]` table
|
|
@@ -92,7 +93,7 @@ export async function setCodexProxySettings(baseUrl) {
|
|
|
92
93
|
? providerMatch[0]
|
|
93
94
|
: null;
|
|
94
95
|
fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
|
|
95
|
-
|
|
96
|
+
await writeFileAtomic(getCodexSnapshotPath(), JSON.stringify({ originalProviderLine }, null, 2), 0o600);
|
|
96
97
|
}
|
|
97
98
|
let text = stripCodexManagedConfig(original);
|
|
98
99
|
// Set the selector: replace an existing top-level model_provider or insert
|
|
@@ -113,7 +114,7 @@ export async function setCodexProxySettings(baseUrl) {
|
|
|
113
114
|
text = `model_provider = "neurolink"\n${text}`;
|
|
114
115
|
}
|
|
115
116
|
const trimmed = text.replace(/\s*$/, "\n");
|
|
116
|
-
|
|
117
|
+
await writeFileAtomic(getCodexConfigPath(), `${trimmed}\n${buildCodexProviderBlock(baseUrl)}`);
|
|
117
118
|
return true;
|
|
118
119
|
}
|
|
119
120
|
catch (error) {
|
|
@@ -182,7 +183,7 @@ export async function clearCodexProxySettings(expectedBaseUrl) {
|
|
|
182
183
|
}
|
|
183
184
|
return false;
|
|
184
185
|
}
|
|
185
|
-
|
|
186
|
+
await writeFileAtomic(getCodexConfigPath(), text.replace(/\s*$/, "\n"));
|
|
186
187
|
try {
|
|
187
188
|
fs.rmSync(getCodexSnapshotPath(), { force: true });
|
|
188
189
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { homedir } from "os";
|
|
25
25
|
import { join } from "path";
|
|
26
26
|
import { logger } from "../../utils/logger.js";
|
|
27
|
+
import { writeFileAtomic } from "./snapshot.js";
|
|
27
28
|
/**
|
|
28
29
|
* Resolved per call rather than at module load so `detect()` and `apply()`
|
|
29
30
|
* agree when HOME changes — under test, and on the `--dev` isolation path.
|
|
@@ -61,12 +62,11 @@ export async function setCopilotProxySettings(baseUrl, proxyKey) {
|
|
|
61
62
|
try {
|
|
62
63
|
const envPath = getCopilotEnvPath();
|
|
63
64
|
fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
fs.chmodSync(envPath, 0o600);
|
|
65
|
+
// 0600 is applied to the temp file before the rename, so the script never
|
|
66
|
+
// exists at the destination with wider permissions — not even briefly.
|
|
67
|
+
// writeFileSync's own mode option would not do this: it applies only when
|
|
68
|
+
// open() creates the file, so an overwrite kept whatever mode was there.
|
|
69
|
+
await writeFileAtomic(envPath, buildCopilotEnvScript(baseUrl, proxyKey || "neurolink-proxy"), 0o600);
|
|
70
70
|
return true;
|
|
71
71
|
}
|
|
72
72
|
catch (error) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { homedir } from "os";
|
|
8
8
|
import { join } from "path";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
10
|
-
import { cloneForSnapshot, isProxyOwnedValue, shouldCaptureSnapshot, } from "./snapshot.js";
|
|
10
|
+
import { cloneForSnapshot, isProxyOwnedValue, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
|
|
11
11
|
function getOpenCodeConfigDir() {
|
|
12
12
|
// OpenCode resolves this with the unmodified `xdg-basedir` package —
|
|
13
13
|
// `XDG_CONFIG_HOME || ~/.config` — on every platform, macOS included. There
|
|
@@ -82,7 +82,7 @@ export async function setOpenCodeProxySettings(baseUrl, proxyKey) {
|
|
|
82
82
|
provider.neurolink = block;
|
|
83
83
|
config[OPENCODE_WRITTEN_KEY] = cloneForSnapshot(block);
|
|
84
84
|
config.provider = provider;
|
|
85
|
-
|
|
85
|
+
await writeFileAtomic(getOpenCodeConfigPath(), JSON.stringify(config, null, 2));
|
|
86
86
|
return true;
|
|
87
87
|
}
|
|
88
88
|
export async function clearOpenCodeProxySettings(expectedBaseUrl) {
|
|
@@ -146,7 +146,7 @@ export async function clearOpenCodeProxySettings(expectedBaseUrl) {
|
|
|
146
146
|
return false;
|
|
147
147
|
}
|
|
148
148
|
config.provider = provider;
|
|
149
|
-
|
|
149
|
+
await writeFileAtomic(getOpenCodeConfigPath(), JSON.stringify(config, null, 2));
|
|
150
150
|
return hadNeurolink;
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { homedir } from "os";
|
|
21
21
|
import { join } from "path";
|
|
22
22
|
import { logger } from "../../utils/logger.js";
|
|
23
|
-
import { cloneForSnapshot, isProxyOwnedValue, shouldCaptureSnapshot, } from "./snapshot.js";
|
|
23
|
+
import { cloneForSnapshot, isProxyOwnedValue, shouldCaptureSnapshot, writeFileAtomic, } from "./snapshot.js";
|
|
24
24
|
/**
|
|
25
25
|
* Resolved per call rather than at module load so `detect()` and `apply()`
|
|
26
26
|
* agree when HOME changes — under test, and on the `--dev` isolation path.
|
|
@@ -87,7 +87,7 @@ export async function setQwenProxySettings(baseUrl, proxyKey) {
|
|
|
87
87
|
security.auth = auth;
|
|
88
88
|
settings.security = security;
|
|
89
89
|
settings[QWEN_WRITTEN_KEY] = cloneForSnapshot(auth);
|
|
90
|
-
|
|
90
|
+
await writeFileAtomic(getQwenSettingsPath(), JSON.stringify(settings, null, 2));
|
|
91
91
|
return true;
|
|
92
92
|
}
|
|
93
93
|
export async function clearQwenProxySettings(expectedBaseUrl) {
|
|
@@ -133,7 +133,7 @@ export async function clearQwenProxySettings(expectedBaseUrl) {
|
|
|
133
133
|
delete settings[QWEN_ORIGINAL_KEY];
|
|
134
134
|
delete settings[QWEN_WRITTEN_KEY];
|
|
135
135
|
settings.security = security;
|
|
136
|
-
|
|
136
|
+
await writeFileAtomic(getQwenSettingsPath(), JSON.stringify(settings, null, 2));
|
|
137
137
|
return true;
|
|
138
138
|
}
|
|
139
139
|
export const qwenCodeConfigurator = {
|
|
@@ -50,3 +50,54 @@ export declare function isProxyOwnedValue(args: {
|
|
|
50
50
|
written: unknown;
|
|
51
51
|
current: unknown;
|
|
52
52
|
}): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Replace a file's contents without ever exposing a partial one.
|
|
55
|
+
*
|
|
56
|
+
* `writeFileSync` opens with `O_TRUNC`, so from the truncate until the last
|
|
57
|
+
* byte lands the user's config is short — and a real config spans several
|
|
58
|
+
* syscalls, not an instant. Anything reading concurrently, the CLI the config
|
|
59
|
+
* belongs to included, can load a truncated file; a crash in that window leaves
|
|
60
|
+
* it truncated permanently. Both Qwen and OpenCode keep live API keys there.
|
|
61
|
+
*
|
|
62
|
+
* Writing to a sibling temp file and renaming closes it: `rename(2)` within a
|
|
63
|
+
* directory is atomic, so a reader sees either the whole old file or the whole
|
|
64
|
+
* new one. The temp file must be a sibling — a rename across filesystems is a
|
|
65
|
+
* copy, which reintroduces exactly the window this removes.
|
|
66
|
+
*
|
|
67
|
+
* PERMISSIONS ARE THE SUBTLE PART, and getting them wrong here leaks API keys.
|
|
68
|
+
*
|
|
69
|
+
* Writing through a temp file changes who decides the destination's mode. A
|
|
70
|
+
* plain `writeFileSync` over an existing file leaves that file's mode alone, so
|
|
71
|
+
* a config the user had locked to 0600 stayed 0600. A rename replaces the inode,
|
|
72
|
+
* so the destination inherits the TEMP file's mode instead — and a temp file
|
|
73
|
+
* created without an explicit mode lands at 0666 minus umask, i.e. 0644 on a
|
|
74
|
+
* default system. Left unhandled, making the write atomic would have quietly
|
|
75
|
+
* widened every credential file it touched from 0600 to 0644.
|
|
76
|
+
*
|
|
77
|
+
* So when the caller does not specify a mode, the destination's current mode is
|
|
78
|
+
* carried over, which reproduces `writeFileSync`'s behaviour exactly; a file
|
|
79
|
+
* that does not exist yet starts at 0600 rather than whatever umask allows.
|
|
80
|
+
*
|
|
81
|
+
* The mode is applied at CREATE time, not after. `writeFileSync` followed by
|
|
82
|
+
* `chmodSync` would put the credential bytes on disk at 0644 first and tighten
|
|
83
|
+
* them a moment later — a window a local reader can win. The trailing `chmod`
|
|
84
|
+
* remains only to pin the exact mode, since umask masks the create mode; by
|
|
85
|
+
* then the file has never been readable more widely than its final mode.
|
|
86
|
+
*
|
|
87
|
+
* On Windows the guarantee holds but the failure mode differs, which is worth
|
|
88
|
+
* stating because the obvious worry there is the wrong one. Node's `renameSync`
|
|
89
|
+
* is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`; replacing a file on the
|
|
90
|
+
* same volume is an atomic directory-entry update, so a concurrent reader still
|
|
91
|
+
* sees the whole old file or the whole new one and never a torn one. What
|
|
92
|
+
* Windows adds is that the rename can *fail* — `EPERM`/`EBUSY` when a reader
|
|
93
|
+
* holds the destination open — where POSIX would succeed. That path is safe:
|
|
94
|
+
* the catch below removes the temp file and rethrows, leaving the previous
|
|
95
|
+
* config intact for the caller to report on.
|
|
96
|
+
*
|
|
97
|
+
* The sibling rule above is what makes that true. `MOVEFILE_COPY_ALLOWED` is
|
|
98
|
+
* also set, so a cross-volume rename silently degrades to copy-then-delete and
|
|
99
|
+
* is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a
|
|
100
|
+
* tidy-up and would quietly restore the exact window this function exists to
|
|
101
|
+
* close, on Windows only, where nobody here would see it.
|
|
102
|
+
*/
|
|
103
|
+
export declare function writeFileAtomic(filePath: string, contents: string, mode?: number): Promise<void>;
|
|
@@ -95,4 +95,107 @@ export function isProxyOwnedValue(args) {
|
|
|
95
95
|
}
|
|
96
96
|
return valuesMatch(args.current, args.written);
|
|
97
97
|
}
|
|
98
|
+
/** Distinguishes concurrent writers within one process. */
|
|
99
|
+
let atomicWriteCounter = 0;
|
|
100
|
+
/**
|
|
101
|
+
* Replace a file's contents without ever exposing a partial one.
|
|
102
|
+
*
|
|
103
|
+
* `writeFileSync` opens with `O_TRUNC`, so from the truncate until the last
|
|
104
|
+
* byte lands the user's config is short — and a real config spans several
|
|
105
|
+
* syscalls, not an instant. Anything reading concurrently, the CLI the config
|
|
106
|
+
* belongs to included, can load a truncated file; a crash in that window leaves
|
|
107
|
+
* it truncated permanently. Both Qwen and OpenCode keep live API keys there.
|
|
108
|
+
*
|
|
109
|
+
* Writing to a sibling temp file and renaming closes it: `rename(2)` within a
|
|
110
|
+
* directory is atomic, so a reader sees either the whole old file or the whole
|
|
111
|
+
* new one. The temp file must be a sibling — a rename across filesystems is a
|
|
112
|
+
* copy, which reintroduces exactly the window this removes.
|
|
113
|
+
*
|
|
114
|
+
* PERMISSIONS ARE THE SUBTLE PART, and getting them wrong here leaks API keys.
|
|
115
|
+
*
|
|
116
|
+
* Writing through a temp file changes who decides the destination's mode. A
|
|
117
|
+
* plain `writeFileSync` over an existing file leaves that file's mode alone, so
|
|
118
|
+
* a config the user had locked to 0600 stayed 0600. A rename replaces the inode,
|
|
119
|
+
* so the destination inherits the TEMP file's mode instead — and a temp file
|
|
120
|
+
* created without an explicit mode lands at 0666 minus umask, i.e. 0644 on a
|
|
121
|
+
* default system. Left unhandled, making the write atomic would have quietly
|
|
122
|
+
* widened every credential file it touched from 0600 to 0644.
|
|
123
|
+
*
|
|
124
|
+
* So when the caller does not specify a mode, the destination's current mode is
|
|
125
|
+
* carried over, which reproduces `writeFileSync`'s behaviour exactly; a file
|
|
126
|
+
* that does not exist yet starts at 0600 rather than whatever umask allows.
|
|
127
|
+
*
|
|
128
|
+
* The mode is applied at CREATE time, not after. `writeFileSync` followed by
|
|
129
|
+
* `chmodSync` would put the credential bytes on disk at 0644 first and tighten
|
|
130
|
+
* them a moment later — a window a local reader can win. The trailing `chmod`
|
|
131
|
+
* remains only to pin the exact mode, since umask masks the create mode; by
|
|
132
|
+
* then the file has never been readable more widely than its final mode.
|
|
133
|
+
*
|
|
134
|
+
* On Windows the guarantee holds but the failure mode differs, which is worth
|
|
135
|
+
* stating because the obvious worry there is the wrong one. Node's `renameSync`
|
|
136
|
+
* is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`; replacing a file on the
|
|
137
|
+
* same volume is an atomic directory-entry update, so a concurrent reader still
|
|
138
|
+
* sees the whole old file or the whole new one and never a torn one. What
|
|
139
|
+
* Windows adds is that the rename can *fail* — `EPERM`/`EBUSY` when a reader
|
|
140
|
+
* holds the destination open — where POSIX would succeed. That path is safe:
|
|
141
|
+
* the catch below removes the temp file and rethrows, leaving the previous
|
|
142
|
+
* config intact for the caller to report on.
|
|
143
|
+
*
|
|
144
|
+
* The sibling rule above is what makes that true. `MOVEFILE_COPY_ALLOWED` is
|
|
145
|
+
* also set, so a cross-volume rename silently degrades to copy-then-delete and
|
|
146
|
+
* is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a
|
|
147
|
+
* tidy-up and would quietly restore the exact window this function exists to
|
|
148
|
+
* close, on Windows only, where nobody here would see it.
|
|
149
|
+
*/
|
|
150
|
+
export async function writeFileAtomic(filePath, contents, mode) {
|
|
151
|
+
const fs = await import("fs");
|
|
152
|
+
const { dirname, join, basename } = await import("path");
|
|
153
|
+
atomicWriteCounter += 1;
|
|
154
|
+
const tempPath = join(dirname(filePath), `.${basename(filePath)}.neurolink-${process.pid}-${atomicWriteCounter}.tmp`);
|
|
155
|
+
// Which step failed changes what the user should do about it: a failed write
|
|
156
|
+
// is usually a missing directory or a full disk and the config is untouched,
|
|
157
|
+
// while a failed rename is a locked destination and the config is intact but
|
|
158
|
+
// stale. The bare errno is the same shape for both, so the stage is recorded
|
|
159
|
+
// as it advances and named in the rethrow.
|
|
160
|
+
let stage = "write";
|
|
161
|
+
// Resolved before the first byte is written — see the permissions note above.
|
|
162
|
+
let effectiveMode = mode;
|
|
163
|
+
if (effectiveMode === undefined) {
|
|
164
|
+
try {
|
|
165
|
+
effectiveMode = fs.statSync(filePath).mode & 0o777;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
effectiveMode = 0o600;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
// The temp file is a sibling of the destination, so a missing parent fails
|
|
173
|
+
// the write rather than the rename — the config is untouched, but the
|
|
174
|
+
// caller sees an ENOENT naming a path it never asked to write. Creating
|
|
175
|
+
// the directory first makes a first-run write behave like the plain
|
|
176
|
+
// writeFileSync it replaced.
|
|
177
|
+
fs.mkdirSync(dirname(filePath), { recursive: true });
|
|
178
|
+
fs.writeFileSync(tempPath, contents, { mode: effectiveMode });
|
|
179
|
+
stage = "chmod";
|
|
180
|
+
fs.chmodSync(tempPath, effectiveMode);
|
|
181
|
+
stage = "rename";
|
|
182
|
+
fs.renameSync(tempPath, filePath);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// Never leave scratch in the user's config directory.
|
|
186
|
+
try {
|
|
187
|
+
fs.rmSync(tempPath, { force: true });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// best effort
|
|
191
|
+
}
|
|
192
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
193
|
+
// The original is attached as `cause`, not discarded: wrapping moves the
|
|
194
|
+
// errno off the thrown object, and `cause` is where anything that needs
|
|
195
|
+
// ENOENT/EPERM finds it. No caller reads it today — every call site either
|
|
196
|
+
// lets this propagate or swallows it — so nothing breaks, but a future one
|
|
197
|
+
// should not have to re-derive the syscall from a string.
|
|
198
|
+
throw new Error(`atomic write to ${filePath} failed at the ${stage} step: ${reason}`, { cause: error });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
98
201
|
//# sourceMappingURL=snapshot.js.map
|
|
@@ -22,17 +22,38 @@ import type { ParsedGeminiRequest, StreamSerializerAdapter } from "../types/inde
|
|
|
22
22
|
/**
|
|
23
23
|
* Parse a `generateContent` body into the shape the translation engine takes.
|
|
24
24
|
*
|
|
25
|
-
* The final user turn becomes `prompt
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
25
|
+
* The final user turn becomes `prompt`, with Google's `model` role mapped to
|
|
26
|
+
* `assistant` so downstream providers see a role they understand.
|
|
27
|
+
*
|
|
28
|
+
* `conversationMessages` carries EVERY turn, the final one included. That
|
|
29
|
+
* looks redundant next to `prompt`, and it is the contract the shared engine
|
|
30
|
+
* expects: `buildTranslationOptions` does `conversationMessages.slice(0, -1)`
|
|
31
|
+
* to derive history, because the final turn is already being sent as `prompt`.
|
|
32
|
+
* `claudeFormat` and `openaiFormat` both push unconditionally for that reason.
|
|
33
|
+
* Excluding the last turn here — the intuitive reading of "history" — made the
|
|
34
|
+
* engine's slice eat one real turn instead, so every multi-turn Gemini request
|
|
35
|
+
* silently lost its most recent message.
|
|
28
36
|
*/
|
|
29
37
|
export declare function parseGeminiRequest(model: string, body: Record<string, unknown>, stream: boolean): ParsedGeminiRequest;
|
|
30
38
|
/** Build a complete `generateContent` response body. */
|
|
39
|
+
/**
|
|
40
|
+
* Render a tool call as text.
|
|
41
|
+
*
|
|
42
|
+
* The proxy does not forward tool calls in Google's `functionCall` part shape:
|
|
43
|
+
* the CLI drives tools locally, so a `functionCall` it never asked for would be
|
|
44
|
+
* an unresolvable pending call. Text is what it can act on. Both the streaming
|
|
45
|
+
* serializer and the non-streaming builder go through here so the two paths
|
|
46
|
+
* cannot drift.
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderGeminiToolUse(name: string, input: unknown): string;
|
|
31
49
|
export declare function buildGeminiResponse(text: string, finishReason: string, usage: {
|
|
32
50
|
input: number;
|
|
33
51
|
output: number;
|
|
34
52
|
total: number;
|
|
35
|
-
}, modelVersion: string
|
|
53
|
+
}, modelVersion: string, toolCalls?: ReadonlyArray<{
|
|
54
|
+
toolName: string;
|
|
55
|
+
args: Record<string, unknown>;
|
|
56
|
+
}>): Record<string, unknown>;
|
|
36
57
|
/** Google's error envelope, which the CLI parses to classify failures. */
|
|
37
58
|
export declare function buildGeminiErrorResponse(status: number, message: string, statusText?: string): Response;
|
|
38
59
|
/**
|
|
@@ -40,9 +40,17 @@ function partsToImages(parts) {
|
|
|
40
40
|
/**
|
|
41
41
|
* Parse a `generateContent` body into the shape the translation engine takes.
|
|
42
42
|
*
|
|
43
|
-
* The final user turn becomes `prompt
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
43
|
+
* The final user turn becomes `prompt`, with Google's `model` role mapped to
|
|
44
|
+
* `assistant` so downstream providers see a role they understand.
|
|
45
|
+
*
|
|
46
|
+
* `conversationMessages` carries EVERY turn, the final one included. That
|
|
47
|
+
* looks redundant next to `prompt`, and it is the contract the shared engine
|
|
48
|
+
* expects: `buildTranslationOptions` does `conversationMessages.slice(0, -1)`
|
|
49
|
+
* to derive history, because the final turn is already being sent as `prompt`.
|
|
50
|
+
* `claudeFormat` and `openaiFormat` both push unconditionally for that reason.
|
|
51
|
+
* Excluding the last turn here — the intuitive reading of "history" — made the
|
|
52
|
+
* engine's slice eat one real turn instead, so every multi-turn Gemini request
|
|
53
|
+
* silently lost its most recent message.
|
|
46
54
|
*/
|
|
47
55
|
export function parseGeminiRequest(model, body, stream) {
|
|
48
56
|
const contents = Array.isArray(body.contents)
|
|
@@ -58,9 +66,9 @@ export function parseGeminiRequest(model, body, stream) {
|
|
|
58
66
|
content: partsToText(c?.parts),
|
|
59
67
|
images: partsToImages(c?.parts),
|
|
60
68
|
}));
|
|
61
|
-
// The last user turn is the prompt
|
|
62
|
-
//
|
|
63
|
-
//
|
|
69
|
+
// The last user turn is the prompt. A request whose final turn is a model
|
|
70
|
+
// turn (the CLI does this when continuing) leaves an empty prompt rather
|
|
71
|
+
// than replaying the assistant's own words as input.
|
|
64
72
|
let prompt = "";
|
|
65
73
|
let images = [];
|
|
66
74
|
const conversationMessages = [];
|
|
@@ -70,12 +78,24 @@ export function parseGeminiRequest(model, body, stream) {
|
|
|
70
78
|
prompt = turns[i].content;
|
|
71
79
|
images = turns[i].images;
|
|
72
80
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
// Unconditional — see the slice-contract note on this function.
|
|
82
|
+
conversationMessages.push({
|
|
83
|
+
role: turns[i].role,
|
|
84
|
+
content: turns[i].content,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// The engine's `slice(0, -1)` drops the LAST entry on the assumption that it
|
|
88
|
+
// is the turn already being sent as `prompt`. That holds only when the
|
|
89
|
+
// request ends with a user turn. The Gemini CLI also continues from a model
|
|
90
|
+
// turn, and there the last entry is a real assistant reply — so the slice ate
|
|
91
|
+
// it, which is the same lost-turn bug one case further along.
|
|
92
|
+
//
|
|
93
|
+
// A terminal placeholder restores the invariant: the slice removes this
|
|
94
|
+
// instead of the model turn. It is never sent anywhere — `prompt` is
|
|
95
|
+
// independently "" in exactly this case, so the placeholder only exists to be
|
|
96
|
+
// consumed by the slice.
|
|
97
|
+
if (turns.length > 0 && turns[turns.length - 1].role !== "user") {
|
|
98
|
+
conversationMessages.push({ role: "user", content: "" });
|
|
79
99
|
}
|
|
80
100
|
const numeric = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
81
101
|
const stops = generationConfig.stopSequences;
|
|
@@ -115,11 +135,31 @@ function usageMetadata(usage) {
|
|
|
115
135
|
};
|
|
116
136
|
}
|
|
117
137
|
/** Build a complete `generateContent` response body. */
|
|
118
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Render a tool call as text.
|
|
140
|
+
*
|
|
141
|
+
* The proxy does not forward tool calls in Google's `functionCall` part shape:
|
|
142
|
+
* the CLI drives tools locally, so a `functionCall` it never asked for would be
|
|
143
|
+
* an unresolvable pending call. Text is what it can act on. Both the streaming
|
|
144
|
+
* serializer and the non-streaming builder go through here so the two paths
|
|
145
|
+
* cannot drift.
|
|
146
|
+
*/
|
|
147
|
+
export function renderGeminiToolUse(name, input) {
|
|
148
|
+
return `\n[tool: ${name} ${JSON.stringify(input)}]\n`;
|
|
149
|
+
}
|
|
150
|
+
export function buildGeminiResponse(text, finishReason, usage, modelVersion, toolCalls) {
|
|
151
|
+
// A translated result can legitimately carry tool calls and no text — the
|
|
152
|
+
// engine's hasTranslatedOutput() accepts that. Rendering only `text` there
|
|
153
|
+
// handed the client parts[0].text === "" with finishReason STOP, which reads
|
|
154
|
+
// as "the model answered nothing" rather than "the model wants a tool".
|
|
155
|
+
const rendered = (toolCalls ?? [])
|
|
156
|
+
.map((call) => renderGeminiToolUse(call.toolName, call.args))
|
|
157
|
+
.join("");
|
|
158
|
+
const body = `${text}${rendered}`;
|
|
119
159
|
return {
|
|
120
160
|
candidates: [
|
|
121
161
|
{
|
|
122
|
-
content: { role: MODEL_ROLE, parts: [{ text }] },
|
|
162
|
+
content: { role: MODEL_ROLE, parts: [{ text: body }] },
|
|
123
163
|
finishReason: toGeminiFinishReason(finishReason),
|
|
124
164
|
index: 0,
|
|
125
165
|
},
|
|
@@ -175,7 +215,7 @@ export class GeminiStreamSerializer {
|
|
|
175
215
|
* that is never coming; rendering it as text keeps the turn terminating.
|
|
176
216
|
*/
|
|
177
217
|
pushToolUse(_id, name, input) {
|
|
178
|
-
return this.pushDelta(
|
|
218
|
+
return this.pushDelta(renderGeminiToolUse(name, input));
|
|
179
219
|
}
|
|
180
220
|
finish(finishReason, usage) {
|
|
181
221
|
return [
|
|
@@ -598,7 +598,7 @@ export async function handleTranslatedJsonRequest(args) {
|
|
|
598
598
|
return serializeClaudeResponse(internal, requestModel);
|
|
599
599
|
}
|
|
600
600
|
if (format === "gemini") {
|
|
601
|
-
return buildGeminiResponse(internal.content, internal.finishReason ?? defaultFinishReason(format), resolvedUsage, internal.model ?? requestModel);
|
|
601
|
+
return buildGeminiResponse(internal.content, internal.finishReason ?? defaultFinishReason(format), resolvedUsage, internal.model ?? requestModel, internal.toolCalls);
|
|
602
602
|
}
|
|
603
603
|
return serializeOpenAIResponse(internal, requestModel);
|
|
604
604
|
}
|
|
@@ -184,7 +184,12 @@ export function createGeminiProxyRoutes(modelRouter, basePath = "", _loopbackPor
|
|
|
184
184
|
// --- Dispatch via shared translation engine ---
|
|
185
185
|
try {
|
|
186
186
|
if (stream) {
|
|
187
|
-
|
|
187
|
+
// Awaited, not returned bare: `handleTranslatedStreamRequest` is
|
|
188
|
+
// async, so a rejection raised before the Response exists would
|
|
189
|
+
// escape this try/catch and land in `app.onError`, which answers
|
|
190
|
+
// in Anthropic's error shape. A Gemini client parsing that finds
|
|
191
|
+
// no `error.message` and reports an empty failure.
|
|
192
|
+
return await handleTranslatedStreamRequest({
|
|
188
193
|
ctx,
|
|
189
194
|
format: "gemini",
|
|
190
195
|
requestModel: modelId,
|
package/dist/types/proxy.d.ts
CHANGED
|
@@ -2773,15 +2773,6 @@ export type OpenAIErrorResponse = {
|
|
|
2773
2773
|
};
|
|
2774
2774
|
};
|
|
2775
2775
|
/** Parsed OpenAI request — intermediate form for NeuroLink pipeline. */
|
|
2776
|
-
/**
|
|
2777
|
-
* A Gemini `generateContent` request, reduced to what translation needs.
|
|
2778
|
-
*
|
|
2779
|
-
* Google's shape differs from both others in three ways that matter here:
|
|
2780
|
-
* roles are `user`/`model` rather than `user`/`assistant`, the system prompt
|
|
2781
|
-
* lives in a sibling `systemInstruction` rather than in the turn list, and
|
|
2782
|
-
* generation settings are nested under `generationConfig` instead of sitting
|
|
2783
|
-
* at the top level.
|
|
2784
|
-
*/
|
|
2785
2776
|
/** One part of a Gemini `contents[].parts[]` entry. */
|
|
2786
2777
|
export type ProxyGeminiPart = {
|
|
2787
2778
|
text?: string;
|
|
@@ -2794,6 +2785,15 @@ export type ProxyGeminiContent = {
|
|
|
2794
2785
|
role?: string;
|
|
2795
2786
|
parts?: ProxyGeminiPart[];
|
|
2796
2787
|
};
|
|
2788
|
+
/**
|
|
2789
|
+
* A Gemini `generateContent` request, reduced to what translation needs.
|
|
2790
|
+
*
|
|
2791
|
+
* Google's shape differs from both others in three ways that matter here:
|
|
2792
|
+
* roles are `user`/`model` rather than `user`/`assistant`, the system prompt
|
|
2793
|
+
* lives in a sibling `systemInstruction` rather than in the turn list, and
|
|
2794
|
+
* generation settings are nested under `generationConfig` instead of sitting
|
|
2795
|
+
* at the top level.
|
|
2796
|
+
*/
|
|
2797
2797
|
export type ParsedGeminiRequest = {
|
|
2798
2798
|
model: string;
|
|
2799
2799
|
maxTokens?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "11.18.
|
|
3
|
+
"version": "11.18.2",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|