@juspay/neurolink 11.17.3 → 11.18.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/browser/neurolink.min.js +381 -377
  3. package/dist/cli/commands/proxy.js +3 -0
  4. package/dist/cli/proxy-clients/claudeCode.js +3 -3
  5. package/dist/cli/proxy-clients/codex.js +4 -3
  6. package/dist/cli/proxy-clients/copilot.js +6 -6
  7. package/dist/cli/proxy-clients/openCode.js +3 -3
  8. package/dist/cli/proxy-clients/qwenCode.js +3 -3
  9. package/dist/cli/proxy-clients/snapshot.d.ts +51 -0
  10. package/dist/cli/proxy-clients/snapshot.js +97 -0
  11. package/dist/providers/googleNativeGemini3/utils.d.ts +0 -49
  12. package/dist/providers/googleNativeGemini3/utils.js +4 -128
  13. package/dist/proxy/accountLedger.js +42 -1
  14. package/dist/proxy/clientAttribution.d.ts +30 -0
  15. package/dist/proxy/clientAttribution.js +64 -0
  16. package/dist/proxy/geminiFormat.d.ts +77 -0
  17. package/dist/proxy/geminiFormat.js +219 -0
  18. package/dist/proxy/proxyTranslationEngine.d.ts +10 -7
  19. package/dist/proxy/proxyTranslationEngine.js +43 -11
  20. package/dist/server/index.d.ts +1 -1
  21. package/dist/server/index.js +4 -3
  22. package/dist/server/routes/claudeProxyRoutes.js +2 -0
  23. package/dist/server/routes/codexProxyRoutes.js +2 -0
  24. package/dist/server/routes/geminiProxyRoutes.d.ts +89 -0
  25. package/dist/server/routes/geminiProxyRoutes.js +225 -0
  26. package/dist/server/routes/index.d.ts +1 -0
  27. package/dist/server/routes/index.js +6 -0
  28. package/dist/server/routes/openaiProxyRoutes.js +2 -0
  29. package/dist/types/proxy.d.ts +54 -1
  30. package/dist/types/proxyClient.d.ts +18 -0
  31. package/dist/types/server.d.ts +5 -3
  32. package/package.json +1 -1
@@ -1158,6 +1158,7 @@ export async function createProxyStartApp(params) {
1158
1158
  const { createClaudeProxyRoutes } = await import("../../server/routes/claudeProxyRoutes.js");
1159
1159
  const { createOpenAIProxyRoutes } = await import("../../server/routes/openaiProxyRoutes.js");
1160
1160
  const { createCodexProxyRoutes } = await import("../../server/routes/codexProxyRoutes.js");
1161
+ const { createGeminiProxyRoutes } = await import("../../server/routes/geminiProxyRoutes.js");
1161
1162
  const { logBodyCapture, logRequest } = await import("../../proxy/requestLogger.js");
1162
1163
  const { recordFinalError } = await import("../../proxy/usageStats.js");
1163
1164
  const { Hono } = await import("hono");
@@ -1279,10 +1280,12 @@ export async function createProxyStartApp(params) {
1279
1280
  : params.accountAllowlist);
1280
1281
  const openaiRouteGroup = createOpenAIProxyRoutes(params.modelRouter, "", params.port, runtimeConfigProvider);
1281
1282
  const codexRouteGroup = createCodexProxyRoutes("");
1283
+ const geminiRouteGroup = createGeminiProxyRoutes(params.modelRouter, "", params.port, runtimeConfigProvider);
1282
1284
  const allProxyRoutes = [
1283
1285
  ...routeGroup.routes,
1284
1286
  ...openaiRouteGroup.routes,
1285
1287
  ...codexRouteGroup.routes,
1288
+ ...geminiRouteGroup.routes,
1286
1289
  ];
1287
1290
  for (const route of allProxyRoutes) {
1288
1291
  const method = route.method.toLowerCase();
@@ -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
- fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2));
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
- fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2));
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
- fs.writeFileSync(getCodexSnapshotPath(), JSON.stringify({ originalProviderLine }, null, 2), { mode: 0o600 });
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
- fs.writeFileSync(getCodexConfigPath(), `${trimmed}\n${buildCodexProviderBlock(baseUrl)}`);
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
- fs.writeFileSync(getCodexConfigPath(), text.replace(/\s*$/, "\n"));
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
- fs.writeFileSync(envPath, buildCopilotEnvScript(baseUrl, proxyKey || "neurolink-proxy"), { mode: 0o600 });
65
- // The mode option above only applies when open() creates the file. On an
66
- // overwrite it is ignored, so a pre-existing file keeps whatever
67
- // permissions it had which for a file holding a proxy key could be
68
- // world-readable. Enforce it on both paths.
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
- fs.writeFileSync(getOpenCodeConfigPath(), JSON.stringify(config, null, 2));
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
- fs.writeFileSync(getOpenCodeConfigPath(), JSON.stringify(config, null, 2));
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
- fs.writeFileSync(getQwenSettingsPath(), JSON.stringify(settings, null, 2));
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
- fs.writeFileSync(getQwenSettingsPath(), JSON.stringify(settings, null, 2));
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,101 @@ 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
+ fs.writeFileSync(tempPath, contents, { mode: effectiveMode });
173
+ stage = "chmod";
174
+ fs.chmodSync(tempPath, effectiveMode);
175
+ stage = "rename";
176
+ fs.renameSync(tempPath, filePath);
177
+ }
178
+ catch (error) {
179
+ // Never leave scratch in the user's config directory.
180
+ try {
181
+ fs.rmSync(tempPath, { force: true });
182
+ }
183
+ catch {
184
+ // best effort
185
+ }
186
+ const reason = error instanceof Error ? error.message : String(error);
187
+ // The original is attached as `cause`, not discarded: wrapping moves the
188
+ // errno off the thrown object, and `cause` is where anything that needs
189
+ // ENOENT/EPERM finds it. No caller reads it today — every call site either
190
+ // lets this propagate or swallows it — so nothing breaks, but a future one
191
+ // should not have to re-derive the syscall from a string.
192
+ throw new Error(`atomic write to ${filePath} failed at the ${stage} step: ${reason}`, { cause: error });
193
+ }
194
+ }
98
195
  //# sourceMappingURL=snapshot.js.map
@@ -28,55 +28,6 @@ export declare class DedupExecuteMap extends Map<string, Tool["execute"]> {
28
28
  private readonly resultCache;
29
29
  get(name: string): Tool["execute"] | undefined;
30
30
  }
31
- export declare function sanitizeForGoogleFunctionName(name: string): string;
32
- /**
33
- * Resolve a sanitized Gemini tool name to one that is both unique within
34
- * the current request and at most 128 characters. When the candidate
35
- * collides with an already-used name we append `_2`, `_3`, … — but
36
- * reserve room for the suffix by truncating the base first so the
37
- * resolved name never exceeds Google's `function_declarations[].name`
38
- * limit.
39
- *
40
- * @param base The already-sanitized candidate name.
41
- * @param isTaken Predicate that returns true if `name` is already used.
42
- */
43
- export declare function resolveUniqueGoogleFunctionName(base: string, isTaken: (name: string) => boolean): string;
44
- /**
45
- * Sanitize a JSON Schema for Gemini's proto-based API.
46
- *
47
- * Gemini cannot handle `anyOf`/`oneOf` union types in function declarations
48
- * because its proto format expects a single `type` field, not a list of types.
49
- * This function recursively converts unions to `string` type (the most
50
- * permissive primitive that can represent any value as text).
51
- *
52
- * Also removes `$schema`, `additionalProperties`, and `default` keys that
53
- * Gemini's proto format doesn't support.
54
- */
55
- export declare function sanitizeSchemaForGemini(schema: Record<string, unknown>): Record<string, unknown>;
56
- /**
57
- * Sanitize Vercel AI SDK tools for Gemini compatibility.
58
- *
59
- * For the Vercel AI SDK path (non-native), tool parameters are Zod schemas that
60
- * get converted to JSON Schema internally by @ai-sdk/google. This conversion
61
- * doesn't sanitize union types (anyOf/oneOf), causing Gemini proto errors.
62
- *
63
- * This function pre-converts each tool's Zod parameters to sanitized JSON Schema
64
- * and re-wraps with the Vercel AI SDK's jsonSchema() helper.
65
- */
66
- export declare function sanitizeToolsForGemini(tools: Record<string, Tool>): {
67
- tools: Record<string, Tool>;
68
- dropped: string[];
69
- /**
70
- * Reverse map: Google-safe sanitized name → original consumer-supplied
71
- * name. Lets the calling layer translate tool-call results back so the
72
- * sanitization stays transport-only (see CodeRabbit thread, PR #1006).
73
- */
74
- originalNameMap: Map<string, string>;
75
- };
76
- export declare function normalizeToolsForJsonSchemaProvider(tools: Record<string, Tool>): {
77
- tools: Record<string, Tool>;
78
- normalized: string[];
79
- };
80
31
  /**
81
32
  * Convert Vercel AI SDK tools to @google/genai FunctionDeclarations and an execute map.
82
33
  *
@@ -15,11 +15,10 @@ import { DEFAULT_CONTEXT_GUARD_RATIO, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIE
15
15
  import { needsAudioTranscode, toProviderCompatibleAudio, } from "../../adapters/audioFormatSupport.js";
16
16
  import { logger } from "../../utils/logger.js";
17
17
  import { resolveSamplingParams } from "../../models/modelRegistry.js";
18
- import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../../utils/schemaConversion.js";
18
+ import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, } from "../../utils/schemaConversion.js";
19
19
  import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
20
20
  import { resolveLiveTool } from "../../tools/toolDiscovery.js";
21
21
  import { raceWithAbort, withTimeout } from "../../utils/async/index.js";
22
- import { jsonSchema as aiJsonSchema, tool as createAISDKTool, } from "../../utils/tool.js";
23
22
  // ── Functions ──
24
23
  /** Stable, key-order-independent serialization of tool args for the dedup key. */
25
24
  function stableStringifyForDedup(value) {
@@ -83,7 +82,7 @@ export class DedupExecuteMap extends Map {
83
82
  */
84
83
  const GOOGLE_FN_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/;
85
84
  const GOOGLE_FN_NAME_MAX_LENGTH = 128;
86
- export function sanitizeForGoogleFunctionName(name) {
85
+ function sanitizeForGoogleFunctionName(name) {
87
86
  if (GOOGLE_FN_NAME_REGEX.test(name)) {
88
87
  return name;
89
88
  }
@@ -107,7 +106,7 @@ export function sanitizeForGoogleFunctionName(name) {
107
106
  * @param base The already-sanitized candidate name.
108
107
  * @param isTaken Predicate that returns true if `name` is already used.
109
108
  */
110
- export function resolveUniqueGoogleFunctionName(base, isTaken) {
109
+ function resolveUniqueGoogleFunctionName(base, isTaken) {
111
110
  if (!isTaken(base)) {
112
111
  return base;
113
112
  }
@@ -133,7 +132,7 @@ export function resolveUniqueGoogleFunctionName(base, isTaken) {
133
132
  * Also removes `$schema`, `additionalProperties`, and `default` keys that
134
133
  * Gemini's proto format doesn't support.
135
134
  */
136
- export function sanitizeSchemaForGemini(schema) {
135
+ function sanitizeSchemaForGemini(schema) {
137
136
  // If this node has anyOf/oneOf, collapse to string type
138
137
  if (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)) {
139
138
  const unionKey = schema.anyOf ? "anyOf" : "oneOf";
@@ -243,129 +242,6 @@ export function sanitizeSchemaForGemini(schema) {
243
242
  }
244
243
  return result;
245
244
  }
246
- /**
247
- * Sanitize Vercel AI SDK tools for Gemini compatibility.
248
- *
249
- * For the Vercel AI SDK path (non-native), tool parameters are Zod schemas that
250
- * get converted to JSON Schema internally by @ai-sdk/google. This conversion
251
- * doesn't sanitize union types (anyOf/oneOf), causing Gemini proto errors.
252
- *
253
- * This function pre-converts each tool's Zod parameters to sanitized JSON Schema
254
- * and re-wraps with the Vercel AI SDK's jsonSchema() helper.
255
- */
256
- export function sanitizeToolsForGemini(tools) {
257
- const sanitized = {};
258
- const dropped = [];
259
- const renamed = [];
260
- const originalNameMap = new Map();
261
- for (const [name, tool] of Object.entries(tools)) {
262
- try {
263
- // Sanitize the tool name to fit Google's function_declarations regex.
264
- // Without this, MCP-imported or user-registered tools whose names contain
265
- // characters outside [A-Za-z_][A-Za-z0-9_.:-]{0,127} cause the entire
266
- // request to 400 with "Invalid function name", surfacing as a misleading
267
- // tool-calling failure. Distinct originals that collapse onto the same
268
- // sanitized name (e.g. "my/tool" and "my-tool" → "my_tool") are
269
- // disambiguated with a numeric suffix that preserves Google's 128-char
270
- // ceiling.
271
- const candidate = sanitizeForGoogleFunctionName(name);
272
- const safeName = resolveUniqueGoogleFunctionName(candidate, (n) => n in sanitized);
273
- // Always record the mapping so downstream code can translate every
274
- // safeName back to the original — including the no-rename identity
275
- // mapping, which simplifies the lookup path.
276
- originalNameMap.set(safeName, name);
277
- if (safeName !== name) {
278
- renamed.push({ from: name, to: safeName });
279
- }
280
- // Access the legacy `parameters` field that may exist on older AI SDK tools.
281
- // AI SDK v6 uses `inputSchema`, but v3/v4 tools and third-party wrappers use `parameters`.
282
- const legacyTool = tool;
283
- const params = legacyTool.parameters;
284
- if (params &&
285
- typeof params === "object" &&
286
- "_def" in params &&
287
- typeof params.parse === "function") {
288
- const rawJsonSchema = convertZodToJsonSchema(params, "openApi3");
289
- const inlined = inlineJsonSchema(rawJsonSchema);
290
- // Gemini sanitization strips Zod-only features not supported by the Gemini API:
291
- // union types (anyOf/oneOf) are collapsed to string, default values and
292
- // additionalProperties are removed. The resulting schema is Gemini-compatible
293
- // but loses some type constraints from the original Zod schema.
294
- const sanitizedSchema = sanitizeSchemaForGemini(inlined);
295
- sanitized[safeName] = createAISDKTool({
296
- description: tool.description || `Tool: ${safeName}`,
297
- inputSchema: aiJsonSchema(sanitizedSchema),
298
- execute: tool.execute,
299
- });
300
- }
301
- else if (params &&
302
- typeof params === "object" &&
303
- "jsonSchema" in params) {
304
- // Non-Zod JSON schema (e.g., from ai SDK jsonSchema() helper) — still needs sanitization
305
- const rawSchema = params
306
- .jsonSchema;
307
- const sanitizedSchema = sanitizeSchemaForGemini(inlineJsonSchema(rawSchema));
308
- sanitized[safeName] = createAISDKTool({
309
- description: tool.description || `Tool: ${safeName}`,
310
- inputSchema: aiJsonSchema(sanitizedSchema),
311
- execute: tool.execute,
312
- });
313
- }
314
- else {
315
- sanitized[safeName] = tool;
316
- }
317
- }
318
- catch (error) {
319
- logger.warn(`[Gemini] Failed to sanitize tool "${name}", skipping: ${error instanceof Error ? error.message : String(error)}`);
320
- // Don't fall back to the original tool — an incompatible schema would fail the Gemini request
321
- dropped.push(name);
322
- }
323
- }
324
- if (renamed.length > 0) {
325
- logger.warn(`[Gemini] ${renamed.length} tool name(s) sanitized for Google's function-name regex: ${renamed
326
- .map((r) => `"${r.from}" -> "${r.to}"`)
327
- .join(", ")}`);
328
- }
329
- return { tools: sanitized, dropped, originalNameMap };
330
- }
331
- export function normalizeToolsForJsonSchemaProvider(tools) {
332
- const normalizedTools = {};
333
- const normalized = [];
334
- for (const [name, tool] of Object.entries(tools)) {
335
- const legacyTool = tool;
336
- const toolParams = legacyTool.parameters || tool.inputSchema;
337
- let rawSchema;
338
- if (isZodSchema(toolParams)) {
339
- rawSchema = convertZodToJsonSchema(toolParams, "openApi3");
340
- }
341
- else if (toolParams && typeof toolParams === "object") {
342
- rawSchema = toolParams;
343
- }
344
- else {
345
- rawSchema = { type: "object", properties: {} };
346
- }
347
- if (rawSchema.jsonSchema &&
348
- typeof rawSchema.jsonSchema === "object" &&
349
- !rawSchema.type) {
350
- rawSchema = rawSchema.jsonSchema;
351
- }
352
- const schemaBefore = JSON.stringify(rawSchema);
353
- const normalizedSchema = normalizeJsonSchemaObject(rawSchema);
354
- if (JSON.stringify(normalizedSchema) !== schemaBefore) {
355
- normalized.push(name);
356
- }
357
- const wrappedSchema = aiJsonSchema(normalizedSchema);
358
- normalizedTools[name] = {
359
- ...tool,
360
- inputSchema: wrappedSchema,
361
- ...(legacyTool.parameters ? { parameters: wrappedSchema } : {}),
362
- };
363
- }
364
- return {
365
- tools: normalizedTools,
366
- normalized,
367
- };
368
- }
369
245
  /**
370
246
  * Convert Vercel AI SDK tools to @google/genai FunctionDeclarations and an execute map.
371
247
  *
@@ -144,6 +144,7 @@ async function advanceCursor(fileName, cursor) {
144
144
  outputTokens: finiteNumber(record.outputTokens),
145
145
  cacheReadTokens: finiteNumber(record.cacheReadTokens),
146
146
  cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
147
+ clientApp: resolveClientApp(record),
147
148
  };
148
149
  // A later record for the same request enriches the earlier one — it must
149
150
  // replace it, never add to it. But token fields take the MAX rather than
@@ -214,6 +215,27 @@ function resolveSlotKey(cursor, entryKey, next) {
214
215
  }
215
216
  return slot;
216
217
  }
218
+ /**
219
+ * Which CLI a log row came from.
220
+ *
221
+ * Prefers the derived name the proxy recorded. Falls back to the raw
222
+ * User-Agent's leading token so an unclassified client is still attributable
223
+ * instead of collapsing into one bucket with everything else. Rows written
224
+ * before attribution existed carry neither and are reported as "unattributed"
225
+ * — distinct from "unknown", which means a client we saw but could not name.
226
+ */
227
+ function resolveClientApp(record) {
228
+ if (typeof record.clientApp === "string" && record.clientApp) {
229
+ return record.clientApp;
230
+ }
231
+ if (typeof record.userAgent === "string" && record.userAgent) {
232
+ const token = record.userAgent.trim().split(/[\s/]/)[0];
233
+ if (token) {
234
+ return token;
235
+ }
236
+ }
237
+ return "unattributed";
238
+ }
217
239
  /** UTC date stamp of the log file the totals cover. */
218
240
  export function currentUsageDate(now = new Date()) {
219
241
  return now.toISOString().slice(0, 10);
@@ -228,6 +250,7 @@ function emptyTotals() {
228
250
  costUsd: 0,
229
251
  unpricedRequests: 0,
230
252
  unpricedModels: [],
253
+ byClient: {},
231
254
  };
232
255
  }
233
256
  /**
@@ -267,16 +290,31 @@ export async function readAccountUsage(date = currentUsageDate()) {
267
290
  row.outputTokens += entry.outputTokens;
268
291
  row.cacheReadTokens += entry.cacheReadTokens;
269
292
  row.cacheCreationTokens += entry.cacheCreationTokens;
293
+ const client = (row.byClient[entry.clientApp] ??= {
294
+ requests: 0,
295
+ inputTokens: 0,
296
+ outputTokens: 0,
297
+ cacheReadTokens: 0,
298
+ cacheCreationTokens: 0,
299
+ costUsd: 0,
300
+ });
301
+ client.requests += 1;
302
+ client.inputTokens += entry.inputTokens;
303
+ client.outputTokens += entry.outputTokens;
304
+ client.cacheReadTokens += entry.cacheReadTokens;
305
+ client.cacheCreationTokens += entry.cacheCreationTokens;
270
306
  const provider = resolveProvider(entry);
271
307
  if (entry.model && entry.model !== "-") {
272
308
  if (hasPricing(provider, entry.model)) {
273
- row.costUsd += calculateCost(provider, entry.model, {
309
+ const cost = calculateCost(provider, entry.model, {
274
310
  input: entry.inputTokens,
275
311
  output: entry.outputTokens,
276
312
  total: entry.inputTokens + entry.outputTokens,
277
313
  cacheReadTokens: entry.cacheReadTokens,
278
314
  cacheCreationTokens: entry.cacheCreationTokens,
279
315
  });
316
+ row.costUsd += cost;
317
+ client.costUsd += cost;
280
318
  }
281
319
  else {
282
320
  row.unpricedRequests += 1;
@@ -289,6 +327,9 @@ export async function readAccountUsage(date = currentUsageDate()) {
289
327
  }
290
328
  for (const [account, row] of totals) {
291
329
  row.costUsd = Number(row.costUsd.toFixed(6));
330
+ for (const slice of Object.values(row.byClient)) {
331
+ slice.costUsd = Number(slice.costUsd.toFixed(6));
332
+ }
292
333
  row.unpricedModels = [...(unpriced.get(account) ?? [])].sort();
293
334
  }
294
335
  return totals;