Added model and role translation. Rewrite of code's comments.
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,4 +21,7 @@ profile/
|
|||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Roo Modes
|
||||||
|
.roomodes
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
// src/chatwrapper.ts
|
/**
|
||||||
|
* @fileoverview This file provides a wrapper around the Gemini API, handling
|
||||||
|
* content generation, model management, and retry logic.
|
||||||
|
*/
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
createContentGeneratorConfig,
|
createContentGeneratorConfig,
|
||||||
@@ -14,35 +17,86 @@ import {
|
|||||||
import { Content, GeminiResponse, Model } from './types.js';
|
import { Content, GeminiResponse, Model } from './types.js';
|
||||||
import consola from 'consola';
|
import consola from 'consola';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
// ==================================================================
|
||||||
/* 1. Build the ContentGenerator exactly like the CLI does */
|
// 1. ContentGenerator Management
|
||||||
/* ------------------------------------------------------------------ */
|
// ==================================================================
|
||||||
let modelName: string; // we'll fill this once
|
|
||||||
const generatorPromise: Promise<ContentGenerator> = (async () => {
|
|
||||||
// Pass undefined for model so the helper falls back to DEFAULT_GEMINI_MODEL
|
|
||||||
const cfg = await createContentGeneratorConfig(
|
|
||||||
undefined, // let helper pick default (Gemini-2.5-Pro)
|
|
||||||
AuthType.LOGIN_WITH_GOOGLE_PERSONAL, // same mode the CLI defaults to
|
|
||||||
);
|
|
||||||
modelName = cfg.model; // remember the actual model string
|
|
||||||
return await createContentGenerator(cfg);
|
|
||||||
})();
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/**
|
||||||
/* 2. Helpers consumed by server.ts */
|
* A cache for ContentGenerator instances to avoid re-creating them.
|
||||||
/* ------------------------------------------------------------------ */
|
* The key is the model name, or 'default' for the default model.
|
||||||
|
*/
|
||||||
|
const generatorCache = new Map<
|
||||||
|
string,
|
||||||
|
Promise<{
|
||||||
|
generator: ContentGenerator,
|
||||||
|
model: string,
|
||||||
|
}>
|
||||||
|
>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a ContentGenerator, creating and caching it if necessary.
|
||||||
|
* If an unsupported model is requested, it falls back to the default model.
|
||||||
|
*
|
||||||
|
* @param model - The name of the model to use.
|
||||||
|
* @returns A promise that resolves to an object containing
|
||||||
|
* the generator and the effective model name.
|
||||||
|
*/
|
||||||
|
function getGenerator(
|
||||||
|
model?: string,
|
||||||
|
): Promise<{
|
||||||
|
generator: ContentGenerator,
|
||||||
|
model: string,
|
||||||
|
}> {
|
||||||
|
// Fallback to default if the specified model is not supported.
|
||||||
|
const modelToUse =
|
||||||
|
model === DEFAULT_GEMINI_MODEL || model === DEFAULT_GEMINI_FLASH_MODEL
|
||||||
|
? model
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Use the effective model name for the cache key.
|
||||||
|
const key = modelToUse ?? 'default';
|
||||||
|
|
||||||
|
if (generatorCache.has(key)) {
|
||||||
|
return generatorCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and cache a new generator.
|
||||||
|
const generatorPromise = (async () => {
|
||||||
|
const cfg = await createContentGeneratorConfig(
|
||||||
|
modelToUse,
|
||||||
|
AuthType.LOGIN_WITH_GOOGLE_PERSONAL,
|
||||||
|
);
|
||||||
|
const generator = await createContentGenerator(cfg);
|
||||||
|
return { generator, model: cfg.model };
|
||||||
|
})();
|
||||||
|
|
||||||
|
generatorCache.set(key, generatorPromise);
|
||||||
|
return generatorPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// 2. API Helpers
|
||||||
|
// ==================================================================
|
||||||
type GenConfig = Record<string, unknown>;
|
type GenConfig = Record<string, unknown>;
|
||||||
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A higher-order function that adds retry logic with exponential backoff
|
||||||
|
* to an operation that may fail due to rate limiting.
|
||||||
|
*
|
||||||
|
* @param operation - The async operation to perform.
|
||||||
|
* @returns The result of the operation.
|
||||||
|
* @throws Throws an error if the operation fails after all retries.
|
||||||
|
*/
|
||||||
async function withRetry<T>(operation: () => Promise<T>): Promise<T> {
|
async function withRetry<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check if it's an Error object with a message property
|
// Only retry on 'RESOURCE_EXHAUSTED' errors.
|
||||||
if (!(error instanceof Error) ||
|
if (!(error instanceof Error) ||
|
||||||
!error.message.includes('RESOURCE_EXHAUSTED') ||
|
!error.message.includes('RESOURCE_EXHAUSTED') ||
|
||||||
retries >= MAX_RETRIES) {
|
retries >= MAX_RETRIES) {
|
||||||
@@ -60,15 +114,26 @@ async function withRetry<T>(operation: () => Promise<T>): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a chat request to the Gemini API.
|
||||||
|
*
|
||||||
|
* @param params - The request parameters.
|
||||||
|
* @param params.model - The model to use.
|
||||||
|
* @param params.contents - The chat history.
|
||||||
|
* @param params.generationConfig - Configuration for the generation.
|
||||||
|
* @returns The Gemini API response.
|
||||||
|
*/
|
||||||
export async function sendChat({
|
export async function sendChat({
|
||||||
|
model,
|
||||||
contents,
|
contents,
|
||||||
generationConfig = {},
|
generationConfig = {},
|
||||||
}: {
|
}: {
|
||||||
|
model?: string,
|
||||||
contents: Content[],
|
contents: Content[],
|
||||||
generationConfig?: GenConfig,
|
generationConfig?: GenConfig,
|
||||||
tools?: unknown, // accepted but ignored for now
|
tools?: unknown, // accepted but ignored for now
|
||||||
}): Promise<GeminiResponse> {
|
}): Promise<GeminiResponse> {
|
||||||
const generator = await generatorPromise;
|
const { generator, model: modelName } = await getGenerator(model);
|
||||||
const gResp = await withRetry(() => generator.generateContent({
|
const gResp = await withRetry(() => generator.generateContent({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
contents,
|
contents,
|
||||||
@@ -84,15 +149,26 @@ export async function sendChat({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a streaming chat request to the Gemini API.
|
||||||
|
*
|
||||||
|
* @param params - The request parameters.
|
||||||
|
* @param params.model - The model to use.
|
||||||
|
* @param params.contents - The chat history.
|
||||||
|
* @param params.generationConfig - Configuration for the generation.
|
||||||
|
* @yields Chunks of the Gemini API response.
|
||||||
|
*/
|
||||||
export async function* sendChatStream({
|
export async function* sendChatStream({
|
||||||
|
model,
|
||||||
contents,
|
contents,
|
||||||
generationConfig = {},
|
generationConfig = {},
|
||||||
}: {
|
}: {
|
||||||
|
model?: string,
|
||||||
contents: Content[],
|
contents: Content[],
|
||||||
generationConfig?: GenConfig,
|
generationConfig?: GenConfig,
|
||||||
tools?: unknown,
|
tools?: unknown,
|
||||||
}) {
|
}) {
|
||||||
const generator = await generatorPromise;
|
const { generator, model: modelName } = await getGenerator(model);
|
||||||
const stream = await withRetry(() => generator.generateContentStream({
|
const stream = await withRetry(() => generator.generateContentStream({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
contents,
|
contents,
|
||||||
@@ -101,6 +177,11 @@ export async function* sendChatStream({
|
|||||||
for await (const chunk of stream) yield chunk;
|
for await (const chunk of stream) yield chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the available models.
|
||||||
|
*
|
||||||
|
* @returns An array of available models.
|
||||||
|
*/
|
||||||
export function listModels(): Model[] {
|
export function listModels(): Model[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -116,9 +197,11 @@ export function listModels(): Model[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
// ==================================================================
|
||||||
/* 3. Additional stubs to implement later */
|
// 3. Future Implementations
|
||||||
/* ------------------------------------------------------------------ */
|
// ==================================================================
|
||||||
|
|
||||||
|
// The embeddings endpoint is not yet implemented.
|
||||||
// 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.');
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview This file manages the application's configuration,
|
||||||
|
* loading environment variables and providing them in a structured object.
|
||||||
|
*/
|
||||||
/* eslint-disable n/no-process-env */
|
/* eslint-disable n/no-process-env */
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application configuration object.
|
||||||
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
|
/**
|
||||||
|
* The port number for the server to listen on.
|
||||||
|
* Defaults to 11434 if not specified in the environment.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
PORT: Number(process.env.PORT ?? 11434),
|
PORT: Number(process.env.PORT ?? 11434),
|
||||||
|
/**
|
||||||
|
* A flag to enable or disable verbose logging.
|
||||||
|
* Defaults to true if not specified in the environment.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
VERBOSE: Boolean(process.env.VERBOSE ?? true),
|
VERBOSE: Boolean(process.env.VERBOSE ?? true),
|
||||||
};
|
};
|
||||||
122
src/mapper.ts
122
src/mapper.ts
@@ -1,15 +1,28 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
/**
|
||||||
/* mapper.ts – OpenAI ⇆ Gemini (with reasoning/1 M context) */
|
* @fileoverview This file contains the logic for mapping requests and
|
||||||
/* ------------------------------------------------------------------ */
|
* responses between the OpenAI and Gemini API formats. It handles message
|
||||||
|
* conversion, vision support, and tool mapping.
|
||||||
|
*/
|
||||||
import { fetchAndEncode } from './remoteimage';
|
import { fetchAndEncode } from './remoteimage';
|
||||||
import { z, ZodRawShape } from 'zod';
|
import { z, ZodRawShape } from 'zod';
|
||||||
import { ToolRegistry }
|
import { ToolRegistry }
|
||||||
from '@google/gemini-cli-core/dist/src/tools/tool-registry.js';
|
from '@google/gemini-cli-core/dist/src/tools/tool-registry.js';
|
||||||
import { Config } from '@google/gemini-cli-core/dist/src/config/config.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 { Tool } from '@google/gemini-cli-core/dist/src/tools/tools.js';
|
||||||
import { Part, RequestBody, GeminiResponse, GeminiStreamChunk } from './types';
|
import {
|
||||||
|
Part,
|
||||||
|
RequestBody,
|
||||||
|
GeminiResponse,
|
||||||
|
GeminiStreamChunk,
|
||||||
|
GeminiRequestBody,
|
||||||
|
Content,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- */
|
/**
|
||||||
|
* A placeholder for a local function call.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves to a successful execution result.
|
||||||
|
*/
|
||||||
async function callLocalFunction(/*_name: string, _args: unknown*/) {
|
async function callLocalFunction(/*_name: string, _args: unknown*/) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -18,14 +31,22 @@ async function callLocalFunction(/*_name: string, _args: unknown*/) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================== */
|
// ==================================================================
|
||||||
/* Request mapper: OpenAI ➞ Gemini */
|
// Request Mapper: OpenAI -> Gemini
|
||||||
/* ================================================================== */
|
// ==================================================================
|
||||||
|
/**
|
||||||
|
* Maps an OpenAI-compatible request body to a Gemini-compatible format.
|
||||||
|
*
|
||||||
|
* @param body - The incoming OpenAI request body.
|
||||||
|
* @returns An object containing the mapped Gemini request and tools.
|
||||||
|
*/
|
||||||
export async function mapRequest(body: RequestBody) {
|
export async function mapRequest(body: RequestBody) {
|
||||||
const parts: Part[] = [];
|
const contents: Content[] = [];
|
||||||
|
const systemParts: Part[] = [];
|
||||||
|
|
||||||
/* ---- convert messages & vision --------------------------------- */
|
// Convert messages and handle vision content.
|
||||||
for (const m of body.messages) {
|
for (const m of body.messages) {
|
||||||
|
const parts: Part[] = [];
|
||||||
if (Array.isArray(m.content)) {
|
if (Array.isArray(m.content)) {
|
||||||
for (const item of m.content) {
|
for (const item of m.content) {
|
||||||
if (item.type === 'image_url' && item.image_url) {
|
if (item.type === 'image_url' && item.image_url) {
|
||||||
@@ -34,39 +55,47 @@ export async function mapRequest(body: RequestBody) {
|
|||||||
parts.push({ text: item.text });
|
parts.push({ text: item.text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (m.content) {
|
||||||
parts.push({ text: m.content });
|
parts.push({ text: m.content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m.role === 'system') {
|
||||||
|
systemParts.push(...parts);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.role === 'user') {
|
||||||
|
contents.push({ role: 'user', parts: [...systemParts, ...parts] });
|
||||||
|
systemParts.length = 0;
|
||||||
|
} else if (m.role === 'assistant') {
|
||||||
|
contents.push({ role: 'model', parts });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- base generationConfig ------------------------------------- */
|
// Map generation configuration parameters.
|
||||||
const generationConfig: Record<string, unknown> = {
|
const generationConfig: Record<string, unknown> = {
|
||||||
temperature: body.temperature,
|
temperature: body.temperature,
|
||||||
maxOutputTokens: body.max_tokens,
|
maxOutputTokens: body.max_tokens,
|
||||||
topP: body.top_p,
|
topP: body.top_p,
|
||||||
...(body.generationConfig ?? {}), // copy anything ST already merged
|
...(body.generationConfig ?? {}), // Preserve existing ST-merged config.
|
||||||
};
|
};
|
||||||
if (body.include_reasoning === true) {
|
if (body.include_reasoning === true) {
|
||||||
generationConfig.enable_thoughts = true; // ← current flag
|
// The current flag for enabling thoughts.
|
||||||
generationConfig.thinking_budget ??= 2048; // optional limit
|
generationConfig.enable_thoughts = true;
|
||||||
|
// Optional limit for thinking budget.
|
||||||
|
generationConfig.thinking_budget ??= 2048;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- auto-enable reasoning & 1 M context ----------------------- */
|
// Auto-enable reasoning and a 1 million token context window.
|
||||||
if (body.include_reasoning === true && generationConfig.thinking !== true) {
|
if (body.include_reasoning === true && generationConfig.thinking !== true) {
|
||||||
generationConfig.thinking = true;
|
generationConfig.thinking = true;
|
||||||
generationConfig.thinking_budget ??= 2048;
|
generationConfig.thinking_budget ??= 2048;
|
||||||
}
|
}
|
||||||
generationConfig.maxInputTokens ??= 1_000_000; // lift context cap
|
generationConfig.maxInputTokens ??= 1_000_000; // Increase the context cap.
|
||||||
|
|
||||||
const geminiReq = {
|
// Map tools and functions.
|
||||||
contents: [{ role: 'user', parts }],
|
// Note: ToolRegistry expects a complex Config object that is not available
|
||||||
generationConfig,
|
// here. Casting to `Config` is a necessary workaround.
|
||||||
stream: body.stream,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- Tool / function mapping ----------------------------------- */
|
|
||||||
// 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);
|
const tools = new ToolRegistry({} as Config);
|
||||||
|
|
||||||
if (body.functions?.length) {
|
if (body.functions?.length) {
|
||||||
@@ -87,13 +116,27 @@ export async function mapRequest(body: RequestBody) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { geminiReq, tools };
|
return {
|
||||||
|
geminiReq: {
|
||||||
|
contents,
|
||||||
|
generationConfig,
|
||||||
|
stream: body.stream,
|
||||||
|
} as GeminiRequestBody,
|
||||||
|
tools,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================== */
|
// ==================================================================
|
||||||
/* Non-stream response: Gemini ➞ OpenAI */
|
// Response Mapper: Gemini -> OpenAI (Non-Streaming)
|
||||||
/* ================================================================== */
|
// ==================================================================
|
||||||
export function mapResponse(gResp: GeminiResponse) {
|
/**
|
||||||
|
* Maps a Gemini API response to the OpenAI format for non-streaming responses.
|
||||||
|
*
|
||||||
|
* @param gResp - The response from the Gemini API.
|
||||||
|
* @param body - The original OpenAI request body.
|
||||||
|
* @returns An OpenAI-compatible chat completion object.
|
||||||
|
*/
|
||||||
|
export function mapResponse(gResp: GeminiResponse, body: RequestBody) {
|
||||||
const usage = gResp.usageMetadata ?? {
|
const usage = gResp.usageMetadata ?? {
|
||||||
promptTokens: 0,
|
promptTokens: 0,
|
||||||
candidatesTokens: 0,
|
candidatesTokens: 0,
|
||||||
@@ -103,7 +146,7 @@ export function mapResponse(gResp: GeminiResponse) {
|
|||||||
id: `chatcmpl-${Date.now()}`,
|
id: `chatcmpl-${Date.now()}`,
|
||||||
object: 'chat.completion',
|
object: 'chat.completion',
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
model: 'gemini-2.5-pro-latest',
|
model: body.model,
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
@@ -119,16 +162,23 @@ export function mapResponse(gResp: GeminiResponse) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================== */
|
// ==================================================================
|
||||||
/* Stream chunk mapper: Gemini ➞ OpenAI */
|
// Stream Chunk Mapper: Gemini -> OpenAI
|
||||||
/* ================================================================== */
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a Gemini stream chunk to the OpenAI format.
|
||||||
|
*
|
||||||
|
* @param chunk - A chunk from the Gemini API stream.
|
||||||
|
* @returns An OpenAI-compatible stream chunk.
|
||||||
|
*/
|
||||||
export function mapStreamChunk(chunk: GeminiStreamChunk) {
|
export function mapStreamChunk(chunk: GeminiStreamChunk) {
|
||||||
const part = chunk?.candidates?.[0]?.content?.parts?.[0] ?? {};
|
const part = chunk?.candidates?.[0]?.content?.parts?.[0] ?? {};
|
||||||
const delta: { role: 'assistant', content?: string } = { 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
|
// Wrap thought content in <think> tags for rendering.
|
||||||
|
delta.content = `<think>${part.text ?? ''}`;
|
||||||
} else if (typeof part.text === 'string') {
|
} else if (typeof part.text === 'string') {
|
||||||
delta.content = part.text;
|
delta.content = part.text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview This file provides a utility function for fetching a remote
|
||||||
|
* image and encoding it in base64.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches an image from a URL and returns
|
||||||
|
* its MIME type and base64-encoded data.
|
||||||
|
*
|
||||||
|
* @param url - The URL of the image to fetch.
|
||||||
|
* @returns A promise that resolves to an object containing the MIME type and
|
||||||
|
* base64-encoded image data.
|
||||||
|
* @throws Throws an error if the image fetch fails.
|
||||||
|
*/
|
||||||
export async function fetchAndEncode(url: string) {
|
export async function fetchAndEncode(url: string) {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) throw new Error(`Failed to fetch image: ${url}`);
|
if (!res.ok) throw new Error(`Failed to fetch image: ${url}`);
|
||||||
|
|||||||
105
src/server.ts
105
src/server.ts
@@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview This file sets up and runs the HTTP server that acts as a
|
||||||
|
* proxy between an OpenAI-compatible client and the Gemini API.
|
||||||
|
*/
|
||||||
import consola from 'consola';
|
import consola from 'consola';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { listModels, sendChat, sendChatStream } from './chatwrapper';
|
import { listModels, sendChat, sendChatStream } from './chatwrapper';
|
||||||
@@ -5,11 +9,15 @@ import { mapRequest, mapResponse, mapStreamChunk } from './mapper.js';
|
|||||||
import { RequestBody, GeminiResponse, GeminiStreamChunk, Part } from './types';
|
import { RequestBody, GeminiResponse, GeminiStreamChunk, Part } from './types';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
|
|
||||||
/* ── basic config ─────────────────────────────────────────────────── */
|
// ==================================================================
|
||||||
|
// Server Configuration
|
||||||
|
// ==================================================================
|
||||||
const PORT = config.PORT;
|
const PORT = config.PORT;
|
||||||
const VERBOSE = config.VERBOSE;
|
const VERBOSE = config.VERBOSE;
|
||||||
|
|
||||||
/* ── Consola setup ────────────────────────────────────────────────── */
|
// ==================================================================
|
||||||
|
// Logger Setup
|
||||||
|
// ==================================================================
|
||||||
if (VERBOSE) {
|
if (VERBOSE) {
|
||||||
consola.level = 5;
|
consola.level = 5;
|
||||||
consola.info('Verbose logging enabled');
|
consola.info('Verbose logging enabled');
|
||||||
@@ -17,14 +25,27 @@ if (VERBOSE) {
|
|||||||
|
|
||||||
consola.info('Google CLI OpenAI proxy');
|
consola.info('Google CLI OpenAI proxy');
|
||||||
|
|
||||||
/* ── CORS helper ──────────────────────────────────────────────────── */
|
// ==================================================================
|
||||||
|
// HTTP Server Helpers
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets CORS headers to allow cross-origin requests.
|
||||||
|
* @param res - The HTTP server response object.
|
||||||
|
*/
|
||||||
function allowCors(res: http.ServerResponse) {
|
function allowCors(res: http.ServerResponse) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── JSON body helper ─────────────────────────────────────────────── */
|
/**
|
||||||
|
* Reads and parses a JSON request body.
|
||||||
|
* @param req - The HTTP incoming message object.
|
||||||
|
* @param res - The HTTP server response object.
|
||||||
|
* @returns A promise that resolves to the parsed request body
|
||||||
|
* or null if invalid.
|
||||||
|
*/
|
||||||
function readJSON(
|
function readJSON(
|
||||||
req: http.IncomingMessage,
|
req: http.IncomingMessage,
|
||||||
res: http.ServerResponse,
|
res: http.ServerResponse,
|
||||||
@@ -50,7 +71,7 @@ function readJSON(
|
|||||||
try {
|
try {
|
||||||
resolve(JSON.parse(data) as RequestBody);
|
resolve(JSON.parse(data) as RequestBody);
|
||||||
} catch {
|
} catch {
|
||||||
// malformed JSON
|
// Handle malformed JSON.
|
||||||
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);
|
||||||
@@ -60,7 +81,9 @@ function readJSON(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── server ───────────────────────────────────────────────────────── */
|
// ==================================================================
|
||||||
|
// Main Server Logic
|
||||||
|
// ==================================================================
|
||||||
http
|
http
|
||||||
.createServer(async (req, res) => {
|
.createServer(async (req, res) => {
|
||||||
allowCors(res);
|
allowCors(res);
|
||||||
@@ -68,13 +91,13 @@ http
|
|||||||
const pathname = url.pathname.replace(/\/$/, '') || '/';
|
const pathname = url.pathname.replace(/\/$/, '') || '/';
|
||||||
consola.info(`${req.method} ${url.pathname}`);
|
consola.info(`${req.method} ${url.pathname}`);
|
||||||
|
|
||||||
/* -------- pre-flight ---------- */
|
// Handle pre-flight CORS requests.
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.writeHead(204).end();
|
res.writeHead(204).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------- /v1/models ---------- */
|
// Route for listing available models.
|
||||||
if (pathname === '/v1/models' || pathname === '/models') {
|
if (pathname === '/v1/models' || pathname === '/models') {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(
|
res.end(
|
||||||
@@ -85,7 +108,7 @@ http
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- /v1/chat/completions ---- */
|
// Route for chat completions.
|
||||||
if (
|
if (
|
||||||
(pathname === '/chat/completions' ||
|
(pathname === '/chat/completions' ||
|
||||||
pathname === '/v1/chat/completions') &&
|
pathname === '/v1/chat/completions') &&
|
||||||
@@ -105,32 +128,34 @@ http
|
|||||||
});
|
});
|
||||||
|
|
||||||
for await (const chunk of sendChatStream({ ...geminiReq, tools })) {
|
for await (const chunk of sendChatStream({ ...geminiReq, tools })) {
|
||||||
// Transform the chunk to match our expected type
|
// Transform the chunk to match the expected stream format.
|
||||||
const transformedParts =
|
const transformedParts =
|
||||||
chunk.candidates?.[0]?.content?.parts?.map(part => {
|
chunk.candidates?.[0]?.content?.parts?.map((part) => {
|
||||||
const transformedPart: Part = {
|
const transformedPart: Part = {
|
||||||
text: part.text,
|
text: part.text,
|
||||||
thought: part.text?.startsWith?.('<think>') ?? false,
|
thought: part.text?.startsWith?.('<think>') ?? false,
|
||||||
};
|
|
||||||
|
|
||||||
if (part.inlineData?.data) {
|
|
||||||
transformedPart.inlineData = {
|
|
||||||
mimeType: part.inlineData.mimeType ?? 'text/plain',
|
|
||||||
data: part.inlineData.data,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
if (part.inlineData?.data) {
|
||||||
return transformedPart;
|
transformedPart.inlineData = {
|
||||||
}) ?? [];
|
mimeType: part.inlineData.mimeType ?? 'text/plain',
|
||||||
|
data: part.inlineData.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedPart;
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
const streamChunk: GeminiStreamChunk = {
|
const streamChunk: GeminiStreamChunk = {
|
||||||
candidates: [{
|
candidates: [
|
||||||
content: {
|
{
|
||||||
parts: transformedParts,
|
content: {
|
||||||
|
parts: transformedParts,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify(mapStreamChunk(streamChunk))}\n\n`,
|
`data: ${JSON.stringify(mapStreamChunk(streamChunk))}\n\n`,
|
||||||
);
|
);
|
||||||
@@ -139,24 +164,26 @@ http
|
|||||||
} else {
|
} else {
|
||||||
const gResp: GeminiResponse = await sendChat({ ...geminiReq, tools });
|
const gResp: GeminiResponse = await sendChat({ ...geminiReq, tools });
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(mapResponse(gResp)));
|
res.end(JSON.stringify(mapResponse(gResp, body)));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
consola.error('Proxy error ➜', error);
|
consola.error('Proxy error ➜', error);
|
||||||
|
|
||||||
// For streaming responses, send error in stream format
|
// Handle errors, sending them in the appropriate format for streaming
|
||||||
|
// or non-streaming responses.
|
||||||
if (body.stream && res.headersSent) {
|
if (body.stream && res.headersSent) {
|
||||||
res.write(`data: ${JSON.stringify({
|
res.write(
|
||||||
error: {
|
`data: ${JSON.stringify({
|
||||||
message: error.message,
|
error: {
|
||||||
type: 'error',
|
message: error.message,
|
||||||
},
|
type: 'error',
|
||||||
})}\n\n`);
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
res.end('data: [DONE]\n\n');
|
res.end('data: [DONE]\n\n');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// For non-streaming responses or if headers haven't been sent yet
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: { message: error.message } }));
|
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||||
}
|
}
|
||||||
|
|||||||
94
src/types.ts
94
src/types.ts
@@ -1,61 +1,137 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
/**
|
||||||
/* types.ts - Type definitions for the application */
|
* @fileoverview This file contains type definitions for the data structures
|
||||||
/* ------------------------------------------------------------------ */
|
* used throughout the application, including request and response bodies for
|
||||||
|
* both the OpenAI and Gemini APIs.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Represents a model available in the API.
|
||||||
|
*/
|
||||||
export interface Model {
|
export interface Model {
|
||||||
|
/** The unique identifier for the model. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** The type of object, always 'model'. */
|
||||||
object: 'model';
|
object: 'model';
|
||||||
|
/** The owner of the model, always 'google'. */
|
||||||
owned_by: 'google';
|
owned_by: 'google';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents inline data, such as an image.
|
||||||
|
*/
|
||||||
interface InlineData {
|
interface InlineData {
|
||||||
mimeType: string;
|
/** The MIME type of the data (e.g., 'image/png'). */
|
||||||
data: string;
|
mimeType: string;
|
||||||
|
/** The base64-encoded data. */
|
||||||
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a part of a multi-part message.
|
||||||
|
*/
|
||||||
export interface Part {
|
export interface Part {
|
||||||
text?: string;
|
/** The text content of the part. */
|
||||||
inlineData?: InlineData;
|
text?: string;
|
||||||
thought?: boolean;
|
/** The inline data content of the part. */
|
||||||
|
inlineData?: InlineData;
|
||||||
|
/** A flag indicating if this part represents a thought process. */
|
||||||
|
thought?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a piece of content in a conversation.
|
||||||
|
*/
|
||||||
export interface Content {
|
export interface Content {
|
||||||
role: string;
|
/**
|
||||||
|
* The producer of the content. Must be either 'user' or 'model'.
|
||||||
|
*
|
||||||
|
* Useful to set for multi-turn conversations, otherwise can be empty.
|
||||||
|
* If role is not specified, SDK will determine the role.
|
||||||
|
*/
|
||||||
|
role?: 'user' | 'model';
|
||||||
|
/** An array of parts that make up the content. */
|
||||||
parts: Part[];
|
parts: Part[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a function definition for tool use.
|
||||||
|
*/
|
||||||
interface FunctionDef {
|
interface FunctionDef {
|
||||||
|
/** The name of the function. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** A description of the function. */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** The parameters of the function, described as a JSON schema. */
|
||||||
parameters?: {
|
parameters?: {
|
||||||
properties?: Record<string, unknown>,
|
properties?: Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the body of an incoming OpenAI-compatible request.
|
||||||
|
*/
|
||||||
export interface RequestBody {
|
export interface RequestBody {
|
||||||
|
/** The model to use for the request. */
|
||||||
|
model: string;
|
||||||
|
/** A list of messages in the conversation history. */
|
||||||
messages: {
|
messages: {
|
||||||
|
role: string,
|
||||||
content:
|
content:
|
||||||
| string
|
| string
|
||||||
| { type: string, image_url?: { url: string }, text?: string }[],
|
| { type: string, image_url?: { url: string }, text?: string }[],
|
||||||
}[];
|
}[];
|
||||||
|
/** The sampling temperature. */
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
/** The maximum number of tokens to generate. */
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
/** The nucleus sampling probability. */
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
|
/** Additional generation configuration for the Gemini API. */
|
||||||
generationConfig?: Record<string, unknown>;
|
generationConfig?: Record<string, unknown>;
|
||||||
|
/** A flag to include reasoning/thoughts in the response. */
|
||||||
include_reasoning?: boolean;
|
include_reasoning?: boolean;
|
||||||
|
/** A flag to indicate if the response should be streamed. */
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
|
/** A list of functions the model can call. */
|
||||||
functions?: FunctionDef[];
|
functions?: FunctionDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the request body for the Gemini API.
|
||||||
|
*/
|
||||||
|
export interface GeminiRequestBody {
|
||||||
|
/** The model to use. */
|
||||||
|
model?: string;
|
||||||
|
/** The content of the conversation. */
|
||||||
|
contents: Content[];
|
||||||
|
/** Configuration for the generation process. */
|
||||||
|
generationConfig: Record<string, unknown>;
|
||||||
|
/** Whether to stream the response. */
|
||||||
|
stream?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a non-streaming response from the Gemini API.
|
||||||
|
*/
|
||||||
export interface GeminiResponse {
|
export interface GeminiResponse {
|
||||||
|
/** The generated text content. */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Metadata about token usage. */
|
||||||
usageMetadata?: {
|
usageMetadata?: {
|
||||||
|
/** The number of tokens in the prompt. */
|
||||||
promptTokens: number,
|
promptTokens: number,
|
||||||
|
/** The number of tokens in the generated candidates. */
|
||||||
candidatesTokens: number,
|
candidatesTokens: number,
|
||||||
|
/** The total number of tokens used. */
|
||||||
totalTokens: number,
|
totalTokens: number,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a chunk of a streaming response from the Gemini API.
|
||||||
|
*/
|
||||||
export interface GeminiStreamChunk {
|
export interface GeminiStreamChunk {
|
||||||
|
/** A list of candidate responses. */
|
||||||
candidates?: {
|
candidates?: {
|
||||||
content?: {
|
content?: {
|
||||||
parts?: Part[],
|
parts?: Part[],
|
||||||
|
|||||||
Reference in New Issue
Block a user