Linting fixes. Fixed crash when hitting rate limit

This commit is contained in:
2025-06-28 15:47:33 -05:00
parent 75dc51bcb1
commit 10a6502f73
5 changed files with 162 additions and 50 deletions

View File

@@ -9,7 +9,7 @@ export default tseslint.config(
...tseslint.configs.strictTypeChecked, ...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked, ...tseslint.configs.stylisticTypeChecked,
{ {
ignores: ['**/node_modules/*', '**/*.mjs', '**/*.js', 'src/mapper.ts'], ignores: ['**/node_modules/*', '**/*.mjs', '**/*.js'],
}, },
{ {
languageOptions: { languageOptions: {

View File

@@ -12,6 +12,7 @@ import {
} from '@google/gemini-cli-core/dist/src/config/models.js'; } from '@google/gemini-cli-core/dist/src/config/models.js';
import { Content, GeminiResponse, Model } from './types.js'; import { Content, GeminiResponse, Model } from './types.js';
import consola from 'consola';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 1. Build the ContentGenerator exactly like the CLI does */ /* 1. Build the ContentGenerator exactly like the CLI does */
@@ -32,6 +33,33 @@ const generatorPromise: Promise<ContentGenerator> = (async () => {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
type GenConfig = Record<string, unknown>; type GenConfig = Record<string, unknown>;
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000; // 1 second
async function withRetry<T>(operation: () => Promise<T>): Promise<T> {
let retries = 0;
while (true) {
try {
return await operation();
} catch (error) {
// Check if it's an Error object with a message property
if (!(error instanceof Error) ||
!error.message.includes('RESOURCE_EXHAUSTED') ||
retries >= MAX_RETRIES) {
throw error;
}
retries++;
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retries - 1);
consola.error(
`Rate limit hit, retrying in ${delay}ms ` +
`(attempt ${retries}/${MAX_RETRIES})`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
export async function sendChat({ export async function sendChat({
contents, contents,
generationConfig = {}, generationConfig = {},
@@ -41,11 +69,11 @@ export async function sendChat({
tools?: unknown, // accepted but ignored for now tools?: unknown, // accepted but ignored for now
}): Promise<GeminiResponse> { }): Promise<GeminiResponse> {
const generator = await generatorPromise; const generator = await generatorPromise;
const gResp = await generator.generateContent({ const gResp = await withRetry(() => generator.generateContent({
model: modelName, model: modelName,
contents, contents,
config: generationConfig, config: generationConfig,
}); }));
return { return {
text: gResp.text ?? '', text: gResp.text ?? '',
usageMetadata: { usageMetadata: {
@@ -65,11 +93,11 @@ export async function* sendChatStream({
tools?: unknown, tools?: unknown,
}) { }) {
const generator = await generatorPromise; const generator = await generatorPromise;
const stream = await generator.generateContentStream({ const stream = await withRetry(() => generator.generateContentStream({
model: modelName, model: modelName,
contents, contents,
config: generationConfig, config: generationConfig,
}); }));
for await (const chunk of stream) yield chunk; for await (const chunk of stream) yield chunk;
} }
@@ -94,3 +122,4 @@ export function listModels(): Model[] {
// export async function embed(_input: unknown) { // export async function embed(_input: unknown) {
// throw new Error('Embeddings endpoint not implemented yet.'); // throw new Error('Embeddings endpoint not implemented yet.');
// } // }

View File

@@ -2,16 +2,25 @@
/* mapper.ts OpenAI ⇆ Gemini (with reasoning/1 M context) */ /* mapper.ts OpenAI ⇆ Gemini (with reasoning/1 M context) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
import { fetchAndEncode } from './remoteimage'; import { fetchAndEncode } from './remoteimage';
import { z } from 'zod'; import { z, ZodRawShape } from 'zod';
import { ToolRegistry } from '@google/gemini-cli-core/dist/src/tools/tool-registry.js'; import { ToolRegistry }
import { RequestBody } from './types'; from '@google/gemini-cli-core/dist/src/tools/tool-registry.js';
import { Config } from '@google/gemini-cli-core/dist/src/config/config.js';
import { Tool } from '@google/gemini-cli-core/dist/src/tools/tools.js';
import {
Part,
RequestBody,
GeminiResponse,
GeminiStreamChunk,
} from './types';
/* ------------------------------------------------------------------ */ /* ----------------------------------------------------------------- */
interface Part { text?: string; inlineData?: { mimeType: string, data: string } } async function callLocalFunction(/*_name: string, _args: unknown*/) {
return Promise.resolve({
/* ------------------------------------------------------------------ */ ok: true,
function callLocalFunction(_name: string, _args: unknown) { llmContent: [],
return { ok: true }; returnDisplay: 'Function executed successfully',
});
} }
/* ================================================================== */ /* ================================================================== */
@@ -61,21 +70,29 @@ export async function mapRequest(body: RequestBody) {
}; };
/* ---- Tool / function mapping ----------------------------------- */ /* ---- Tool / function mapping ----------------------------------- */
const tools = new ToolRegistry({} as any); // Note: ToolRegistry expects a complex Config object that we don't have
// access to. Casting to `Config` is a workaround.
const tools = new ToolRegistry({} as Config);
if (body.functions?.length) { if (body.functions?.length) {
const reg = tools as any; for (const fn of body.functions) {
body.functions.forEach((fn: any) => tools.registerTool({
reg.registerTool( name: fn.name,
fn.name, displayName: fn.name,
{ description: fn.description ?? '',
title: fn.name, schema: z.object(
description: fn.description ?? '', (fn.parameters?.properties as ZodRawShape) ?? {},
inputSchema: z.object(fn.parameters?.properties ?? {}), ),
}, isOutputMarkdown: false,
(args: unknown) => callLocalFunction(fn.name, args), canUpdateOutput: false,
), validateToolParams: () => null,
); getDescription: (params: unknown) =>
`Executing ${fn.name} with parameters: ` +
JSON.stringify(params),
shouldConfirmExecute: () => Promise.resolve(false),
execute: () => callLocalFunction(),
} as Tool);
}
} }
return { geminiReq, tools }; return { geminiReq, tools };
@@ -84,8 +101,12 @@ export async function mapRequest(body: RequestBody) {
/* ================================================================== */ /* ================================================================== */
/* Non-stream response: Gemini ➞ OpenAI */ /* Non-stream response: Gemini ➞ OpenAI */
/* ================================================================== */ /* ================================================================== */
export function mapResponse(gResp: any) { export function mapResponse(gResp: GeminiResponse) {
const usage = gResp.usageMetadata ?? {}; const usage = gResp.usageMetadata ?? {
promptTokens: 0,
candidatesTokens: 0,
totalTokens: 0,
};
return { return {
id: `chatcmpl-${Date.now()}`, id: `chatcmpl-${Date.now()}`,
object: 'chat.completion', object: 'chat.completion',
@@ -99,9 +120,9 @@ export function mapResponse(gResp: any) {
}, },
], ],
usage: { usage: {
prompt_tokens: usage.promptTokens ?? 0, prompt_tokens: usage.promptTokens,
completion_tokens: usage.candidatesTokens ?? 0, completion_tokens: usage.candidatesTokens,
total_tokens: usage.totalTokens ?? 0, total_tokens: usage.totalTokens,
}, },
}; };
} }
@@ -110,9 +131,9 @@ export function mapResponse(gResp: any) {
/* Stream chunk mapper: Gemini ➞ OpenAI */ /* Stream chunk mapper: Gemini ➞ OpenAI */
/* ================================================================== */ /* ================================================================== */
export function mapStreamChunk(chunk: any) { export function mapStreamChunk(chunk: GeminiStreamChunk) {
const part = chunk?.candidates?.[0]?.content?.parts?.[0] ?? {}; const part = chunk?.candidates?.[0]?.content?.parts?.[0] ?? {};
const delta: any = { role: 'assistant' }; const delta: { role: 'assistant', content?: string } = { role: 'assistant' };
if (part.thought === true) { if (part.thought === true) {
delta.content = `<think>${part.text ?? ''}`; // ST renders grey bubble delta.content = `<think>${part.text ?? ''}`; // ST renders grey bubble

View File

@@ -2,7 +2,7 @@ import consola from 'consola';
import http from 'http'; import http from 'http';
import { listModels, sendChat, sendChatStream } from './chatwrapper'; import { listModels, sendChat, sendChatStream } from './chatwrapper';
import { mapRequest, mapResponse, mapStreamChunk } from './mapper.js'; import { mapRequest, mapResponse, mapStreamChunk } from './mapper.js';
import { RequestBody, GeminiResponse } from './types'; import { RequestBody, GeminiResponse, GeminiStreamChunk, Part } from './types';
import { config } from './config'; import { config } from './config';
/* ── basic config ─────────────────────────────────────────────────── */ /* ── basic config ─────────────────────────────────────────────────── */
@@ -41,8 +41,11 @@ function readJSON(
error: { message: 'Request body is missing for POST request' }, error: { message: 'Request body is missing for POST request' },
}), }),
); );
resolve(null);
return;
} }
return resolve(null); resolve(null);
return;
} }
try { try {
resolve(JSON.parse(data) as RequestBody); resolve(JSON.parse(data) as RequestBody);
@@ -51,6 +54,7 @@ function readJSON(
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Malformed JSON' } })); res.end(JSON.stringify({ error: { message: 'Malformed JSON' } }));
resolve(null); resolve(null);
return;
} }
}); });
}); });
@@ -101,7 +105,35 @@ http
}); });
for await (const chunk of sendChatStream({ ...geminiReq, tools })) { for await (const chunk of sendChatStream({ ...geminiReq, tools })) {
res.write(`data: ${JSON.stringify(mapStreamChunk(chunk))}\n\n`); // Transform the chunk to match our expected type
const transformedParts =
chunk.candidates?.[0]?.content?.parts?.map(part => {
const transformedPart: Part = {
text: part.text,
thought: part.text?.startsWith?.('<think>') ?? false,
};
if (part.inlineData?.data) {
transformedPart.inlineData = {
mimeType: part.inlineData.mimeType ?? 'text/plain',
data: part.inlineData.data,
};
}
return transformedPart;
}) ?? [];
const streamChunk: GeminiStreamChunk = {
candidates: [{
content: {
parts: transformedParts,
},
}],
};
res.write(
`data: ${JSON.stringify(mapStreamChunk(streamChunk))}\n\n`,
);
} }
res.end('data: [DONE]\n\n'); res.end('data: [DONE]\n\n');
} else { } else {
@@ -112,10 +144,23 @@ http
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
consola.error('Proxy error ➜', error); consola.error('Proxy error ➜', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: error.message } })); // For streaming responses, send error in stream format
if (body.stream && res.headersSent) {
res.write(`data: ${JSON.stringify({
error: {
message: error.message,
type: 'error',
},
})}\n\n`);
res.end('data: [DONE]\n\n');
return;
} else {
// For non-streaming responses or if headers haven't been sent yet
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: error.message } }));
}
} }
return;
} }
/* ---- anything else ---------- */ /* ---- anything else ---------- */

View File

@@ -6,9 +6,16 @@ export interface Model {
object: 'model'; object: 'model';
owned_by: 'google'; owned_by: 'google';
} }
interface InlineData {
mimeType: string;
data: string;
}
export interface Part { export interface Part {
text?: string; text?: string;
inlineData?: { mimeType: string, data: string }; inlineData?: InlineData;
thought?: boolean;
} }
export interface Content { export interface Content {
@@ -16,6 +23,14 @@ export interface Content {
parts: Part[]; parts: Part[];
} }
interface FunctionDef {
name: string;
description?: string;
parameters?: {
properties?: Record<string, unknown>,
};
}
export interface RequestBody { export interface RequestBody {
messages: { messages: {
content: content:
@@ -28,13 +43,7 @@ export interface RequestBody {
generationConfig?: Record<string, unknown>; generationConfig?: Record<string, unknown>;
include_reasoning?: boolean; include_reasoning?: boolean;
stream?: boolean; stream?: boolean;
functions?: { functions?: FunctionDef[];
name: string,
description?: string,
parameters?: {
properties?: Record<string, unknown>,
},
}[];
} }
export interface GeminiResponse { export interface GeminiResponse {
@@ -44,4 +53,12 @@ export interface GeminiResponse {
candidatesTokens: number, candidatesTokens: number,
totalTokens: number, totalTokens: number,
}; };
} }
export interface GeminiStreamChunk {
candidates?: {
content?: {
parts?: Part[],
},
}[];
}