Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/api/type-aliases/ParsedGeminiRequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@

Defined in: [types/proxy.ts:3236](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3236)

A Gemini `generateContent` request, reduced to what translation needs.

Google's shape differs from both others in three ways that matter here:
roles are `user`/`model` rather than `user`/`assistant`, the system prompt
lives in a sibling `systemInstruction` rather than in the turn list, and
generation settings are nested under `generationConfig` instead of sitting
at the top level.

## Properties

### model
Expand Down
6 changes: 3 additions & 3 deletions docs/api/type-aliases/ProxyGeminiContent.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

> **ProxyGeminiContent** = `object`

Defined in: [types/proxy.ts:3234](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3234)
Defined in: [types/proxy.ts:3225](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3225)

One turn in a Gemini `contents[]` array.

Expand All @@ -18,12 +18,12 @@ One turn in a Gemini `contents[]` array.

> `optional` **role?**: `string`

Defined in: [types/proxy.ts:3234](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3234)
Defined in: [types/proxy.ts:3225](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3225)

---

### parts?

> `optional` **parts?**: [`ProxyGeminiPart`](ProxyGeminiPart.md)[]

Defined in: [types/proxy.ts:3234](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3234)
Defined in: [types/proxy.ts:3225](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3225)
6 changes: 3 additions & 3 deletions docs/api/type-aliases/ProxyGeminiPart.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

> **ProxyGeminiPart** = `object`

Defined in: [types/proxy.ts:3231](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3231)
Defined in: [types/proxy.ts:3222](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3222)

One part of a Gemini `contents[].parts[]` entry.

Expand All @@ -18,15 +18,15 @@ One part of a Gemini `contents[].parts[]` entry.

> `optional` **text?**: `string`

Defined in: [types/proxy.ts:3231](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3231)
Defined in: [types/proxy.ts:3222](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3222)

---

### inlineData?

> `optional` **inlineData?**: `object`

Defined in: [types/proxy.ts:3231](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3231)
Defined in: [types/proxy.ts:3222](https://github.com/juspay/neurolink/blob/release/src/lib/types/proxy.ts#L3222)

#### data?

Expand Down
21 changes: 19 additions & 2 deletions src/cli/commands/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,15 @@
logger.always(
` ${chalk.blue("POST")} /v1/chat/completions — OpenAI-compatible proxy`,
);
Comment thread
murdore marked this conversation as resolved.
// The banner listed two of the four inbound doors, so the Codex and Gemini
Comment thread
murdore marked this conversation as resolved.
// CLIs looked unsupported to anyone reading start-up output rather than the
// docs. Every door the proxy actually answers on belongs here.
logger.always(
` ${chalk.blue("POST")} /backend-api/codex/… — Codex proxy (Responses format)`,
);
logger.always(
` ${chalk.blue("POST")} /v1beta/models/… — Gemini proxy (generateContent)`,
);
logger.always(` ${chalk.green("GET")} /health — Health check`);
logger.always(
` ${chalk.green("GET")} /status — Detailed status`,
Expand Down Expand Up @@ -1491,13 +1500,21 @@
throw error;
}
};
// Cover both the Anthropic (/v1/*) and Codex (/backend-api/*) inbound paths so
// drain/reject, lifecycle logging, and concurrency accounting apply to both.
// Cover every inbound door so drain/reject, lifecycle logging, and
// concurrency accounting apply to all of them.
//
Comment thread
murdore marked this conversation as resolved.
// `/v1beta/*` is listed separately on purpose: Hono matches wildcards a path
// segment at a time, so `/v1/*` does NOT cover `/v1beta/models/...` — the
// segment is `v1beta`, not `v1`. When the Gemini door landed it inherited
// neither tracker, which meant its requests were absent from the request
// log, from per-CLI attribution, and from the in-flight count the graceful
// drain waits on. An update could therefore cut a live Gemini stream.
app.use("/v1/*", trackingHandler);
app.use("/v1beta/*", trackingHandler);
app.use("/backend-api/*", trackingHandler);
}

export async function createProxyStartApp(params: {

Check warning on line 1517 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async function 'createProxyStartApp' has too many lines (843). Maximum allowed is 300

Check warning on line 1517 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async function 'createProxyStartApp' has too many lines (843). Maximum allowed is 300

Check warning on line 1517 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / 🛡️ Code Quality & Security Gate

Async function 'createProxyStartApp' has too many lines (843). Maximum allowed is 300
neurolink: ProxyNeurolinkRuntime["neurolink"];
modelRouter: ModelRouterInterface | undefined;
strategy: ProxyStartStrategy;
Expand Down Expand Up @@ -1912,7 +1929,7 @@
);
});

app.get("/status", async (c) => {

Check warning on line 1932 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async arrow function has too many lines (425). Maximum allowed is 300

Check warning on line 1932 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async arrow function has too many lines (425). Maximum allowed is 300

Check warning on line 1932 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / 🛡️ Code Quality & Security Gate

Async arrow function has too many lines (425). Maximum allowed is 300
const runtimeConfig = params.runtimeConfigStore?.getSnapshot();
const runtimeConfigStatus = params.runtimeConfigStore?.getStatus();
const activeStrategy = runtimeConfig
Expand Down Expand Up @@ -2758,7 +2775,7 @@
return shutdown;
}

async function startProxyRuntime(params: {

Check warning on line 2778 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async function 'startProxyRuntime' has too many lines (305). Maximum allowed is 300

Check warning on line 2778 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async function 'startProxyRuntime' has too many lines (305). Maximum allowed is 300

Check warning on line 2778 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / 🛡️ Code Quality & Security Gate

Async function 'startProxyRuntime' has too many lines (305). Maximum allowed is 300
argv: ProxyStartArgs;
spinner: ProxySpinner;
app: ProxyStartApp["app"];
Expand Down Expand Up @@ -3615,7 +3632,7 @@
"Show proxy status as JSON",
) as Argv<ProxyStatusArgs>;
},
handler: async (argv) => {

Check warning on line 3635 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async method 'handler' has too many lines (348). Maximum allowed is 300

Check warning on line 3635 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async method 'handler' has too many lines (348). Maximum allowed is 300

Check warning on line 3635 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / 🛡️ Code Quality & Security Gate

Async method 'handler' has too many lines (348). Maximum allowed is 300
try {
const state = loadProxyState();
const supervisorState = loadProxySupervisorState();
Expand Down Expand Up @@ -4083,7 +4100,7 @@
default: true,
}) as Argv<ProxyGuardArgs>;
},
handler: async (argv) => {

Check warning on line 4103 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async method 'handler' has too many lines (699). Maximum allowed is 300

Check warning on line 4103 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async method 'handler' has too many lines (699). Maximum allowed is 300

Check warning on line 4103 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / 🛡️ Code Quality & Security Gate

Async method 'handler' has too many lines (699). Maximum allowed is 300
const host = argv.host ?? "127.0.0.1";
const port = argv.port ?? 55669;
const parentPid = Number(argv.parentPid);
Expand Down Expand Up @@ -4169,7 +4186,7 @@
};
let updateInProgress = false;
let updateRestartInProgress = false;
const runUpdateCheck = async () => {

Check warning on line 4189 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async arrow function has too many lines (473). Maximum allowed is 300

Check warning on line 4189 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / test

Async arrow function has too many lines (473). Maximum allowed is 300

Check warning on line 4189 in src/cli/commands/proxy.ts

View workflow job for this annotation

GitHub Actions / 🛡️ Code Quality & Security Gate

Async arrow function has too many lines (473). Maximum allowed is 300
if (guardStopping || updateInProgress) {
return;
}
Expand Down
6 changes: 6 additions & 0 deletions src/cli/proxy-clients/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ export async function writeFileAtomic(
}
}
try {
// The temp file is a sibling of the destination, so a missing parent fails
Comment thread
murdore marked this conversation as resolved.
// the write rather than the rename — the config is untouched, but the
// caller sees an ENOENT naming a path it never asked to write. Creating
// the directory first makes a first-run write behave like the plain
// writeFileSync it replaced.
Comment thread
murdore marked this conversation as resolved.
Comment thread
murdore marked this conversation as resolved.
fs.mkdirSync(dirname(filePath), { recursive: true });
fs.writeFileSync(tempPath, contents, { mode: effectiveMode });
stage = "chmod";
fs.chmodSync(tempPath, effectiveMode);
Expand Down
73 changes: 60 additions & 13 deletions src/lib/proxy/geminiFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,17 @@ function partsToImages(parts: ProxyGeminiPart[] | undefined): string[] {
/**
* Parse a `generateContent` body into the shape the translation engine takes.
*
* The final user turn becomes `prompt`; everything before it becomes
* `conversationMessages`, with Google's `model` role mapped to `assistant` so
* downstream providers see a role they understand.
* 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: string,
Expand Down Expand Up @@ -81,9 +89,9 @@ export function parseGeminiRequest(
images: partsToImages(c?.parts),
}));

// The last user turn is the prompt; anything before it is history. 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.
// 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: string[] = [];
const conversationMessages: Array<{ role: string; content: string }> = [];
Expand All @@ -92,12 +100,26 @@ export function parseGeminiRequest(
if (isLast && turns[i].role === "user") {
prompt = turns[i].content;
images = turns[i].images;
} else {
conversationMessages.push({
role: turns[i].role,
content: turns[i].content,
});
}
// Unconditional — see the slice-contract note on this function.
conversationMessages.push({
role: turns[i].role,
content: turns[i].content,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
murdore marked this conversation as resolved.
Comment thread
murdore marked this conversation as resolved.
Comment thread
murdore marked this conversation as resolved.
}

// 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. It is never sent anywhere — `prompt` is
// independently "" in exactly this case, so the placeholder only exists to be
// consumed by the slice.
if (turns.length > 0 && turns[turns.length - 1].role !== "user") {
conversationMessages.push({ role: "user", content: "" });
}

const numeric = (v: unknown): number | undefined =>
Expand Down Expand Up @@ -148,16 +170,41 @@ function usageMetadata(usage: {
}
Comment thread
murdore marked this conversation as resolved.

/** 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: string, input: unknown): string {
return `\n[tool: ${name} ${JSON.stringify(input)}]\n`;
}

export function buildGeminiResponse(
text: string,
finishReason: string,
usage: { input: number; output: number; total: number },
modelVersion: string,
toolCalls?: ReadonlyArray<{
toolName: string;
args: Record<string, unknown>;
}>,
): Record<string, unknown> {
// 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 }] },
content: { role: MODEL_ROLE, parts: [{ text: body }] },
finishReason: toGeminiFinishReason(finishReason),
index: 0,
},
Expand Down Expand Up @@ -227,7 +274,7 @@ export class GeminiStreamSerializer {
* that is never coming; rendering it as text keeps the turn terminating.
*/
pushToolUse(_id: string, name: string, input: unknown): string[] {
return this.pushDelta(`\n[tool: ${name} ${JSON.stringify(input)}]\n`);
return this.pushDelta(renderGeminiToolUse(name, input));
}

finish(
Expand Down
1 change: 1 addition & 0 deletions src/lib/proxy/proxyTranslationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@ export async function handleTranslatedJsonRequest(args: {
internal.finishReason ?? defaultFinishReason(format),
resolvedUsage,
internal.model ?? requestModel,
internal.toolCalls,
);
}
return serializeOpenAIResponse(internal, requestModel);
Expand Down
7 changes: 6 additions & 1 deletion src/lib/server/routes/geminiProxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,12 @@ export function createGeminiProxyRoutes(
// --- Dispatch via shared translation engine ---
try {
if (stream) {
return handleTranslatedStreamRequest({
// Awaited, not returned bare: `handleTranslatedStreamRequest` is
Comment thread
murdore marked this conversation as resolved.
// 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,
Expand Down
12 changes: 6 additions & 6 deletions src/lib/types/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3218,6 +3218,12 @@ export type OpenAIErrorResponse = {
};

/** Parsed OpenAI request — intermediate form for NeuroLink pipeline. */
/** One part of a Gemini `contents[].parts[]` entry. */
export type ProxyGeminiPart = { text?: string; inlineData?: { data?: string } };

/** One turn in a Gemini `contents[]` array. */
export type ProxyGeminiContent = { role?: string; parts?: ProxyGeminiPart[] };

/**
* A Gemini `generateContent` request, reduced to what translation needs.
*
Expand All @@ -3227,12 +3233,6 @@ export type OpenAIErrorResponse = {
* generation settings are nested under `generationConfig` instead of sitting
* at the top level.
*/
/** One part of a Gemini `contents[].parts[]` entry. */
export type ProxyGeminiPart = { text?: string; inlineData?: { data?: string } };

/** One turn in a Gemini `contents[]` array. */
export type ProxyGeminiContent = { role?: string; parts?: ProxyGeminiPart[] };

export type ParsedGeminiRequest = {
model: string;
maxTokens?: number;
Expand Down
Loading
Loading