@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
231 lines (230 loc) • 12.6 kB
JavaScript
/**
* Gemini-Compatible Proxy Routes
*
* Exposes the Google `generateContent` / `streamGenerateContent` endpoints the
* Gemini CLI calls when pointed at this proxy via `GOOGLE_GEMINI_BASE_URL`.
* ALL requests are routed through ctx.neurolink.stream() via the shared proxy
* translation engine — no direct HTTP calls to any upstream provider.
*
* This is a thin wrapper that parses the Google wire format and delegates to
* the same translation engine openaiProxyRoutes.ts and claudeProxyRoutes.ts
* use, mirroring that file's validate → log → delegate shape.
*
* An optional ModelRouter can remap incoming model names to different
* provider/model pairs, exactly as it does for the OpenAI-compatible door.
*/
import { buildGeminiErrorResponse, parseGeminiRequest, } from "../../proxy/geminiFormat.js";
import { ProxyTracer } from "../../proxy/proxyTracer.js";
import { handleTranslatedJsonRequest, handleTranslatedStreamRequest, } from "../../proxy/proxyTranslationEngine.js";
import { buildProxyTranslationPlan } from "../../proxy/routingPolicy.js";
import { sanitizeForLog } from "../../utils/logSanitize.js";
import { logger } from "../../utils/logger.js";
// Default loopback port — kept only for signature parity with
// createOpenAIProxyRoutes. Gemini has no Anthropic-loopback bridge (nothing
// in the Google wire format needs the OAuth-passthrough detour), so the value
// is threaded through but never read.
const DEFAULT_LOOPBACK_PORT = 55669;
const GENERATE = "generateContent";
const STREAM_GENERATE = "streamGenerateContent";
// ---------------------------------------------------------------------------
// Path parsing
// ---------------------------------------------------------------------------
/**
* Google bakes the action into the final path segment as `<model>:<action>`
* (`models/gemini-2.5-pro:generateContent`). Hono's router has no way to
* express a literal `:` after a named param — verified directly against the
* `hono` version this repo pins: registering `:model\:generateContent`
* doesn't escape the colon, it becomes *part of the param name*, and the
* resulting param still greedily swallows the whole trailing segment
* (`streamGenerateContent` included) because nothing after the leading `:`
* stops the capture except the next `/`. A `:model{regex}` constraint
* followed by literal text fails outright — the route never matches (404 on
* every request). A bare `:model` segment is therefore the only shape that
* actually matches; it captures `<model>:<action>` whole, and this function
* splits it back apart in the handler instead of in the route table.
*/
/**
* Pull "<model>:<action>" out of the request path.
*
* Read from `ctx.path`, not from a route param. Hono does capture the whole
* `gemini-2.5-pro:generateContent` segment — the colon is not a route
* separator, so one route covers both actions — but the proxy's mount loop
* builds its ServerContext from `path`, `headers`, `query`, `body`, `method`
* and `requestId` only. The captured param never reaches the handler, and
* reading `ctx.params` here would be `undefined` on every request.
*/
function splitModelAction(requestPath) {
// Take the last path segment, ignoring any trailing slash or query.
const withoutQuery = requestPath.split("?")[0];
const rawSegment = withoutQuery.replace(/\/+$/, "").split("/").pop() ?? "";
const separatorIndex = rawSegment.lastIndexOf(":");
if (separatorIndex === -1) {
return { modelId: rawSegment, action: undefined };
}
return {
modelId: rawSegment.slice(0, separatorIndex),
action: rawSegment.slice(separatorIndex + 1),
};
}
// ---------------------------------------------------------------------------
// Adapt ParsedGeminiRequest to the shape buildProxyTranslationPlan expects
// ---------------------------------------------------------------------------
/**
* buildProxyTranslationPlan's classifier expects ParsedClaudeRequest, whose
* `maxTokens` is required. Gemini's is optional (Google omits
* `generationConfig.maxOutputTokens` on plenty of real requests), so the gap
* is bridged the same way the OpenAI door bridges it: fill in a safe default
* and let the (structurally near-identical) rest pass through untouched.
*/
function adaptGeminiForTranslationPlan(parsed) {
return {
model: parsed.model,
maxTokens: parsed.maxTokens ?? 4096,
temperature: parsed.temperature,
topP: parsed.topP,
systemPrompt: parsed.systemPrompt,
stream: parsed.stream,
prompt: parsed.prompt,
images: parsed.images,
conversationMessages: parsed.conversationMessages,
tools: parsed.tools,
stopSequences: parsed.stopSequences,
};
}
// ---------------------------------------------------------------------------
// Route factory
// ---------------------------------------------------------------------------
/**
* Create Gemini-compatible proxy routes.
*
* Every request flows through ctx.neurolink.stream() — no direct HTTP calls
* to any upstream provider.
*
* @param modelRouter - Optional model router for remapping model names.
* @param basePath - Base path prefix (default: "").
* @param loopbackPort - Unused by this door; kept for parameter-shape parity
* with createOpenAIProxyRoutes (see DEFAULT_LOOPBACK_PORT).
* @returns RouteGroup with Gemini-compatible endpoints.
*/
export function createGeminiProxyRoutes(modelRouter, basePath = "", _loopbackPort = DEFAULT_LOOPBACK_PORT, runtimeConfigProvider) {
return {
prefix: `${basePath}/v1beta`,
routes: [
// =================================================================
// POST /v1beta/models/:model — covers both `:generateContent` and
// `:streamGenerateContent`; the action lives inside the captured
// segment, not in a second route (see splitModelAction above).
// =================================================================
{
method: "POST",
path: `${basePath}/v1beta/models/:model`,
description: "Gemini-compatible generateContent / streamGenerateContent (translation mode)",
handler: async (ctx) => {
const requestModelRouter = runtimeConfigProvider
? runtimeConfigProvider().modelRouter
: modelRouter;
const requestStartTime = Date.now();
// --- Split "<model>:<action>" out of the captured path segment ---
const { modelId, action } = splitModelAction(ctx.path);
if (!modelId || (action !== GENERATE && action !== STREAM_GENERATE)) {
// Mirrors Google's own 404 shape so the CLI's error handling
// (which parses `error.status`) sees a response it recognizes
// instead of a bare proxy 404.
return buildGeminiErrorResponse(404, `Unsupported Gemini endpoint: ${ctx.path}`, "NOT_FOUND");
}
const body = ctx.body;
// --- Validation ---
if (!body ||
!Array.isArray(body.contents) ||
body.contents.length === 0) {
return buildGeminiErrorResponse(400, "Request must include a non-empty 'contents' array");
}
const stream = action === STREAM_GENERATE;
// --- Resolve target provider/model ---
const route = requestModelRouter
? requestModelRouter.resolve(modelId)
: { provider: null, model: modelId };
const targetProvider = route.provider ?? undefined;
const targetModel = route.model ?? modelId;
logger.debug(`[proxy:gemini] ${modelId} → ${targetProvider ?? "auto"}/${targetModel}`);
// --- Parse request ---
const parsed = parseGeminiRequest(targetModel, body, stream);
// --- Build translation plan ---
const adapted = adaptGeminiForTranslationPlan(parsed);
const plan = buildProxyTranslationPlan({
provider: targetProvider ?? "auto",
model: targetModel,
}, requestModelRouter?.getFallbackChain() ?? [], modelId,
// The classifier only reads fields present across all three formats.
adapted, requestModelRouter?.isAutoFallbackEnabled?.() ?? false);
const attempts = plan.attempts;
// --- Optional tracing ---
let tracer;
try {
tracer = ProxyTracer.startRequest({
requestId: ctx.requestId,
method: ctx.method,
path: ctx.path,
model: modelId,
stream,
toolCount: Object.keys(parsed.tools).length,
clientApp: "gemini-compat",
userAgent: ctx.headers["user-agent"] ?? "",
// Same reasoning as the OpenAI door: without an explicit
// provider the tracer defaults to "anthropic" and prices a
// Gemini model at Claude rates (or $0, off the anthropic
// table's _default-less lookup).
provider: targetProvider ?? "google-ai",
}, ctx.headers);
tracer.setMode("full");
}
catch {
// Tracing is best-effort; continue without it.
}
// --- Dispatch via shared translation engine ---
try {
if (stream) {
// Awaited, not returned bare: `handleTranslatedStreamRequest` is
// async, so a rejection raised before the Response exists would
// escape this try/catch and land in `app.onError`, which answers
// in Anthropic's error shape. A Gemini client parsing that finds
// no `error.message` and reports an empty failure.
return await handleTranslatedStreamRequest({
ctx,
format: "gemini",
requestModel: modelId,
parsed,
attempts,
tracer,
requestStartTime,
});
}
return await handleTranslatedJsonRequest({
ctx,
format: "gemini",
requestModel: modelId,
parsed,
attempts,
tracer,
requestStartTime,
});
}
catch (err) {
// Internal exception text (.message + any stack-trace remnants)
// is kept ONLY in server-side logs + tracer. The client receives
// a fixed generic message so internal paths/frames don't leak
// back through the response body. (CodeQL: information exposure
// through a stack trace.)
const rawMessage = err instanceof Error ? err.message : String(err);
const internalDetail = sanitizeForLog(rawMessage);
logger.always(`[proxy:gemini] request failed: ${internalDetail}`);
tracer?.setError("generation_error", internalDetail);
tracer?.end(500, Date.now() - requestStartTime);
return buildGeminiErrorResponse(500, "Internal proxy error", "INTERNAL");
}
},
},
],
};
}
export const __testHooks = { splitModelAction, adaptGeminiForTranslationPlan };