@juspay/neurolink 11.17.3 → 11.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -3
- package/dist/browser/neurolink.min.js +381 -377
- package/dist/cli/commands/proxy.js +3 -0
- package/dist/providers/googleNativeGemini3/utils.d.ts +0 -49
- package/dist/providers/googleNativeGemini3/utils.js +4 -128
- package/dist/proxy/accountLedger.js +42 -1
- package/dist/proxy/clientAttribution.d.ts +30 -0
- package/dist/proxy/clientAttribution.js +64 -0
- package/dist/proxy/geminiFormat.d.ts +77 -0
- package/dist/proxy/geminiFormat.js +219 -0
- package/dist/proxy/proxyTranslationEngine.d.ts +10 -7
- package/dist/proxy/proxyTranslationEngine.js +43 -11
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +4 -3
- package/dist/server/routes/claudeProxyRoutes.js +2 -0
- package/dist/server/routes/codexProxyRoutes.js +2 -0
- package/dist/server/routes/geminiProxyRoutes.d.ts +89 -0
- package/dist/server/routes/geminiProxyRoutes.js +225 -0
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +6 -0
- package/dist/server/routes/openaiProxyRoutes.js +2 -0
- package/dist/types/proxy.d.ts +54 -1
- package/dist/types/proxyClient.d.ts +18 -0
- package/dist/types/server.d.ts +5 -3
- 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();
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which CLI is calling, from its User-Agent.
|
|
3
|
+
*
|
|
4
|
+
* The proxy already computed a client label for trace spans, but it only
|
|
5
|
+
* distinguished `claude-cli/` from `ai/` and dropped the result into an OTel
|
|
6
|
+
* attribute. Nothing persisted it, so usage could be attributed to an account
|
|
7
|
+
* but never to the tool that spent it — the question a pooled-subscription
|
|
8
|
+
* operator actually asks.
|
|
9
|
+
*
|
|
10
|
+
* Two rules keep this honest as the roster grows:
|
|
11
|
+
*
|
|
12
|
+
* - **Only prefixes observed in real traffic appear here.** A guessed mapping
|
|
13
|
+
* that never matches is indistinguishable from one that works, and silently
|
|
14
|
+
* files a client under the wrong name if the guess collides.
|
|
15
|
+
* - **The raw header is persisted alongside the derived name.** An unrecognised
|
|
16
|
+
* client is then still attributable by its own User-Agent rather than
|
|
17
|
+
* collapsing into one bucket with every other unknown.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Derive a stable client name, or "unknown" when the header is absent or
|
|
21
|
+
* unrecognised. Never throws: attribution must not be able to fail a request.
|
|
22
|
+
*/
|
|
23
|
+
export declare function detectProxyClient(userAgent: string | undefined): string;
|
|
24
|
+
/** Read the User-Agent case-insensitively and bound it for storage. */
|
|
25
|
+
export declare function readUserAgent(headers: Record<string, string> | undefined): string | undefined;
|
|
26
|
+
/** The pair every proxy engine attaches to its request log entry. */
|
|
27
|
+
export declare function buildClientAttribution(headers: Record<string, string> | undefined): {
|
|
28
|
+
clientApp: string;
|
|
29
|
+
userAgent?: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which CLI is calling, from its User-Agent.
|
|
3
|
+
*
|
|
4
|
+
* The proxy already computed a client label for trace spans, but it only
|
|
5
|
+
* distinguished `claude-cli/` from `ai/` and dropped the result into an OTel
|
|
6
|
+
* attribute. Nothing persisted it, so usage could be attributed to an account
|
|
7
|
+
* but never to the tool that spent it — the question a pooled-subscription
|
|
8
|
+
* operator actually asks.
|
|
9
|
+
*
|
|
10
|
+
* Two rules keep this honest as the roster grows:
|
|
11
|
+
*
|
|
12
|
+
* - **Only prefixes observed in real traffic appear here.** A guessed mapping
|
|
13
|
+
* that never matches is indistinguishable from one that works, and silently
|
|
14
|
+
* files a client under the wrong name if the guess collides.
|
|
15
|
+
* - **The raw header is persisted alongside the derived name.** An unrecognised
|
|
16
|
+
* client is then still attributable by its own User-Agent rather than
|
|
17
|
+
* collapsing into one bucket with every other unknown.
|
|
18
|
+
*/
|
|
19
|
+
/** Longest prefix wins, so a more specific match cannot be shadowed. */
|
|
20
|
+
const CLIENT_PREFIXES = [
|
|
21
|
+
// Verified against this machine's proxy logs.
|
|
22
|
+
["claude-cli/", "claude-code"],
|
|
23
|
+
// Verified: the AI SDK's own UA, used by NeuroLink's SDK callers.
|
|
24
|
+
["ai/", "sdk"],
|
|
25
|
+
];
|
|
26
|
+
/** Cap stored User-Agents. They are attacker-influenced and unbounded. */
|
|
27
|
+
const MAX_USER_AGENT_CHARS = 200;
|
|
28
|
+
/**
|
|
29
|
+
* Derive a stable client name, or "unknown" when the header is absent or
|
|
30
|
+
* unrecognised. Never throws: attribution must not be able to fail a request.
|
|
31
|
+
*/
|
|
32
|
+
export function detectProxyClient(userAgent) {
|
|
33
|
+
if (!userAgent) {
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
const ua = userAgent.trim();
|
|
37
|
+
let best;
|
|
38
|
+
let bestLength = 0;
|
|
39
|
+
for (const [prefix, name] of CLIENT_PREFIXES) {
|
|
40
|
+
if (ua.startsWith(prefix) && prefix.length > bestLength) {
|
|
41
|
+
best = name;
|
|
42
|
+
bestLength = prefix.length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return best ?? "unknown";
|
|
46
|
+
}
|
|
47
|
+
/** Read the User-Agent case-insensitively and bound it for storage. */
|
|
48
|
+
export function readUserAgent(headers) {
|
|
49
|
+
if (!headers) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
53
|
+
if (name.toLowerCase() === "user-agent" && typeof value === "string") {
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed ? trimmed.slice(0, MAX_USER_AGENT_CHARS) : undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/** The pair every proxy engine attaches to its request log entry. */
|
|
61
|
+
export function buildClientAttribution(headers) {
|
|
62
|
+
const userAgent = readUserAgent(headers);
|
|
63
|
+
return { clientApp: detectProxyClient(userAgent), userAgent };
|
|
64
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google `generateContent` wire format.
|
|
3
|
+
*
|
|
4
|
+
* The Gemini CLI honours `GOOGLE_GEMINI_BASE_URL`, so it can be pointed at this
|
|
5
|
+
* proxy with no vendor cooperation — verified live: with the variable set the
|
|
6
|
+
* CLI reached the proxy and failed with `ModelNotFoundError: 404`, which is the
|
|
7
|
+
* right answer from a proxy that had no route to answer it. This module is the
|
|
8
|
+
* missing half.
|
|
9
|
+
*
|
|
10
|
+
* Three differences from the Claude and OpenAI shapes drive everything here:
|
|
11
|
+
*
|
|
12
|
+
* - Roles are `user` / `model`, not `user` / `assistant`.
|
|
13
|
+
* - The system prompt is a sibling `systemInstruction`, not a turn.
|
|
14
|
+
* - Generation settings nest under `generationConfig`.
|
|
15
|
+
*
|
|
16
|
+
* Token counts also have their own names — `promptTokenCount`,
|
|
17
|
+
* `candidatesTokenCount`, `totalTokenCount` — and the CLI reads them to render
|
|
18
|
+
* its own usage line, so getting them wrong is visible to the user rather than
|
|
19
|
+
* merely wrong in a log.
|
|
20
|
+
*/
|
|
21
|
+
import type { ParsedGeminiRequest, StreamSerializerAdapter } from "../types/index.js";
|
|
22
|
+
/**
|
|
23
|
+
* Parse a `generateContent` body into the shape the translation engine takes.
|
|
24
|
+
*
|
|
25
|
+
* The final user turn becomes `prompt`; everything before it becomes
|
|
26
|
+
* `conversationMessages`, with Google's `model` role mapped to `assistant` so
|
|
27
|
+
* downstream providers see a role they understand.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseGeminiRequest(model: string, body: Record<string, unknown>, stream: boolean): ParsedGeminiRequest;
|
|
30
|
+
/** Build a complete `generateContent` response body. */
|
|
31
|
+
export declare function buildGeminiResponse(text: string, finishReason: string, usage: {
|
|
32
|
+
input: number;
|
|
33
|
+
output: number;
|
|
34
|
+
total: number;
|
|
35
|
+
}, modelVersion: string): Record<string, unknown>;
|
|
36
|
+
/** Google's error envelope, which the CLI parses to classify failures. */
|
|
37
|
+
export declare function buildGeminiErrorResponse(status: number, message: string, statusText?: string): Response;
|
|
38
|
+
/**
|
|
39
|
+
* SSE serializer for `streamGenerateContent?alt=sse`.
|
|
40
|
+
*
|
|
41
|
+
* Google streams whole `GenerateContentResponse` objects, one per `data:`
|
|
42
|
+
* frame, rather than the deltas Claude and OpenAI send. Each frame therefore
|
|
43
|
+
* carries a complete `candidates[0].content.parts[0].text` holding only that
|
|
44
|
+
* chunk's text — the CLI concatenates them — and the final frame is the one
|
|
45
|
+
* that carries `finishReason` and `usageMetadata`.
|
|
46
|
+
*/
|
|
47
|
+
export declare class GeminiStreamSerializer {
|
|
48
|
+
private readonly model;
|
|
49
|
+
constructor(model: string);
|
|
50
|
+
private frame;
|
|
51
|
+
/** Google sends no preamble frame; the first delta is the first frame. */
|
|
52
|
+
start(): string[];
|
|
53
|
+
pushDelta(text: string): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Tool calls are surfaced as text.
|
|
56
|
+
*
|
|
57
|
+
* The translation engine can emit a tool call, but the Gemini CLI drives its
|
|
58
|
+
* own tools locally and does not expect `functionCall` parts from a plain
|
|
59
|
+
* `generateContent`. Emitting one would make the CLI wait for a tool result
|
|
60
|
+
* that is never coming; rendering it as text keeps the turn terminating.
|
|
61
|
+
*/
|
|
62
|
+
pushToolUse(_id: string, name: string, input: unknown): string[];
|
|
63
|
+
finish(finishReason: string, usage: {
|
|
64
|
+
input: number;
|
|
65
|
+
output: number;
|
|
66
|
+
total: number;
|
|
67
|
+
}): string[];
|
|
68
|
+
/**
|
|
69
|
+
* Errors ride the stream as a Google error object.
|
|
70
|
+
*
|
|
71
|
+
* Once headers are sent the status code is spent, so the CLI can only learn
|
|
72
|
+
* about a mid-stream failure from the body.
|
|
73
|
+
*/
|
|
74
|
+
emitError(message: string): string[];
|
|
75
|
+
}
|
|
76
|
+
/** Adapter so the translation engine can drive this like the other two. */
|
|
77
|
+
export declare function createGeminiSerializerAdapter(model: string): StreamSerializerAdapter;
|