@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
281 lines (280 loc) • 11.3 kB
JavaScript
/**
* Google `generateContent` wire format.
*
* The Gemini CLI honours `GOOGLE_GEMINI_BASE_URL`, so it can be pointed at this
* proxy with no vendor cooperation — verified live: with the variable set the
* CLI reached the proxy and failed with `ModelNotFoundError: 404`, which is the
* right answer from a proxy that had no route to answer it. This module is the
* missing half.
*
* Three differences from the Claude and OpenAI shapes drive everything here:
*
* - Roles are `user` / `model`, not `user` / `assistant`.
* - The system prompt is a sibling `systemInstruction`, not a turn.
* - Generation settings nest under `generationConfig`.
*
* Token counts also have their own names — `promptTokenCount`,
* `candidatesTokenCount`, `totalTokenCount` — and the CLI reads them to render
* its own usage line, so getting them wrong is visible to the user rather than
* merely wrong in a log.
*/
/** Google's role for assistant turns. */
const MODEL_ROLE = "model";
/**
* Sent as `input.text` when a request's final turn is a model turn.
*
* Google lets a client continue generation from the assistant's own last turn;
* the chat-completions wire format the engine targets has no way to say that,
* and an empty prompt is rejected before any provider is reached.
*/
const CONTINUATION_PROMPT = "Continue.";
function partsToText(parts) {
if (!Array.isArray(parts)) {
return "";
}
return parts
.map((p) => (typeof p?.text === "string" ? p.text : ""))
.filter(Boolean)
.join("");
}
function partsToImages(parts) {
if (!Array.isArray(parts)) {
return [];
}
return parts
.map((p) => p?.inlineData?.data)
.filter((d) => typeof d === "string" && d.length > 0);
}
/**
* Parse a `generateContent` body into the shape the translation engine takes.
*
* The final user turn becomes `prompt`, with Google's `model` role mapped to
* `assistant` so downstream providers see a role they understand.
*
* `conversationMessages` carries EVERY turn, the final one included. That
* looks redundant next to `prompt`, and it is the contract the shared engine
* expects: `buildTranslationOptions` does `conversationMessages.slice(0, -1)`
* to derive history, because the final turn is already being sent as `prompt`.
* `claudeFormat` and `openaiFormat` both push unconditionally for that reason.
* Excluding the last turn here — the intuitive reading of "history" — made the
* engine's slice eat one real turn instead, so every multi-turn Gemini request
* silently lost its most recent message.
*/
export function parseGeminiRequest(model, body, stream) {
const contents = Array.isArray(body.contents)
? body.contents
: [];
const generationConfig = (body.generationConfig ?? {});
const systemInstruction = body.systemInstruction;
const systemPrompt = systemInstruction
? partsToText(systemInstruction.parts)
: undefined;
const turns = contents.map((c) => ({
role: c?.role === MODEL_ROLE ? "assistant" : "user",
content: partsToText(c?.parts),
images: partsToImages(c?.parts),
}));
// The last user turn is the prompt. A request whose final turn is a model
// turn (the CLI does this when continuing) leaves an empty prompt rather
// than replaying the assistant's own words as input.
let prompt = "";
let images = [];
const conversationMessages = [];
for (let i = 0; i < turns.length; i += 1) {
const isLast = i === turns.length - 1;
if (isLast && turns[i].role === "user") {
prompt = turns[i].content;
images = turns[i].images;
}
// Unconditional — see the slice-contract note on this function.
conversationMessages.push({
role: turns[i].role,
content: turns[i].content,
});
}
// The engine's `slice(0, -1)` drops the LAST entry on the assumption that it
// is the turn already being sent as `prompt`. That holds only when the
// request ends with a user turn. The Gemini CLI also continues from a model
// turn, and there the last entry is a real assistant reply — so the slice ate
// it, which is the same lost-turn bug one case further along.
//
// A terminal placeholder restores the invariant: the slice removes this
// instead of the model turn.
//
// The prompt needs its own answer, and leaving it "" was a 500. A
// model-final request carries no user turn to send as `input.text`, and
// NeuroLink's stream() rejects an empty one outright — "Stream options must
// include either input.text, input.audio, or stt.audio" — so every continue
// from a model turn failed at the door rather than reaching a provider. The
// placeholder above fixed the history slice but never exercised this path,
// because nothing had sent the request.
//
// Google's semantics for a model-final `contents` are "keep going", and the
// chat-completions shape the engine translates into has no assistant-prefill
// to express that. An explicit continuation instruction is the closest
// faithful equivalent: the full conversation still arrives as history, and
// the model is told to continue it rather than being handed an empty turn.
if (turns.length > 0 && turns[turns.length - 1].role !== "user") {
conversationMessages.push({ role: "user", content: CONTINUATION_PROMPT });
prompt = CONTINUATION_PROMPT;
}
const numeric = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
const stops = generationConfig.stopSequences;
return {
model,
maxTokens: numeric(generationConfig.maxOutputTokens),
temperature: numeric(generationConfig.temperature),
topP: numeric(generationConfig.topP),
systemPrompt: systemPrompt || undefined,
stream,
prompt,
images,
conversationMessages,
tools: {},
stopSequences: Array.isArray(stops)
? stops.filter((x) => typeof x === "string")
: undefined,
};
}
/** Google's finishReason vocabulary. */
function toGeminiFinishReason(reason) {
switch (reason) {
case "length":
case "max_tokens":
return "MAX_TOKENS";
case "content_filter":
return "SAFETY";
default:
return "STOP";
}
}
function usageMetadata(usage) {
return {
promptTokenCount: usage.input,
candidatesTokenCount: usage.output,
totalTokenCount: usage.total || usage.input + usage.output,
};
}
/** Build a complete `generateContent` response body. */
/**
* Render a tool call as text.
*
* The proxy does not forward tool calls in Google's `functionCall` part shape:
* the CLI drives tools locally, so a `functionCall` it never asked for would be
* an unresolvable pending call. Text is what it can act on. Both the streaming
* serializer and the non-streaming builder go through here so the two paths
* cannot drift.
*/
export function renderGeminiToolUse(name, input) {
return `\n[tool: ${name} ${JSON.stringify(input)}]\n`;
}
export function buildGeminiResponse(text, finishReason, usage, modelVersion, toolCalls) {
// A translated result can legitimately carry tool calls and no text — the
// engine's hasTranslatedOutput() accepts that. Rendering only `text` there
// handed the client parts[0].text === "" with finishReason STOP, which reads
// as "the model answered nothing" rather than "the model wants a tool".
const rendered = (toolCalls ?? [])
.map((call) => renderGeminiToolUse(call.toolName, call.args))
.join("");
const body = `${text}${rendered}`;
return {
candidates: [
{
content: { role: MODEL_ROLE, parts: [{ text: body }] },
finishReason: toGeminiFinishReason(finishReason),
index: 0,
},
],
usageMetadata: usageMetadata(usage),
modelVersion,
};
}
/** Google's error envelope, which the CLI parses to classify failures. */
export function buildGeminiErrorResponse(status, message, statusText = "INVALID_ARGUMENT") {
return new Response(JSON.stringify({ error: { code: status, message, status: statusText } }), { status, headers: { "content-type": "application/json" } });
}
/**
* SSE serializer for `streamGenerateContent?alt=sse`.
*
* Google streams whole `GenerateContentResponse` objects, one per `data:`
* frame, rather than the deltas Claude and OpenAI send. Each frame therefore
* carries a complete `candidates[0].content.parts[0].text` holding only that
* chunk's text — the CLI concatenates them — and the final frame is the one
* that carries `finishReason` and `usageMetadata`.
*/
export class GeminiStreamSerializer {
model;
constructor(model) {
this.model = model;
}
frame(payload) {
return `data: ${JSON.stringify(payload)}\n\n`;
}
/** Google sends no preamble frame; the first delta is the first frame. */
start() {
return [];
}
pushDelta(text) {
if (!text) {
return [];
}
return [
this.frame({
candidates: [
{ content: { role: MODEL_ROLE, parts: [{ text }] }, index: 0 },
],
modelVersion: this.model,
}),
];
}
/**
* Tool calls are surfaced as text.
*
* The translation engine can emit a tool call, but the Gemini CLI drives its
* own tools locally and does not expect `functionCall` parts from a plain
* `generateContent`. Emitting one would make the CLI wait for a tool result
* that is never coming; rendering it as text keeps the turn terminating.
*/
pushToolUse(_id, name, input) {
return this.pushDelta(renderGeminiToolUse(name, input));
}
finish(finishReason, usage) {
return [
this.frame({
candidates: [
{
content: { role: MODEL_ROLE, parts: [{ text: "" }] },
finishReason: toGeminiFinishReason(finishReason),
index: 0,
},
],
usageMetadata: usageMetadata(usage),
modelVersion: this.model,
}),
];
}
/**
* Errors ride the stream as a Google error object.
*
* Once headers are sent the status code is spent, so the CLI can only learn
* about a mid-stream failure from the body.
*/
emitError(message) {
return [
this.frame({
error: { code: 500, message, status: "INTERNAL" },
}),
];
}
}
/** Adapter so the translation engine can drive this like the other two. */
export function createGeminiSerializerAdapter(model) {
const s = new GeminiStreamSerializer(model);
return {
start: () => s.start(),
pushDelta: (text) => s.pushDelta(text),
pushToolUse: (id, name, input) => s.pushToolUse(id, name, input),
finish: (finishReason, usage) => s.finish(finishReason, usage),
emitError: (message) => s.emitError(message),
};
}