Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04d888ae69 | |||
| 190442a8cf | |||
| 37f0c4b643 | |||
| 2370a798d1 | |||
| 6f3fbe2a6a | |||
| 10a6502f73 | |||
| 75dc51bcb1 |
@@ -1 +1,3 @@
|
||||
PORT=11434
|
||||
VERBOSE=false
|
||||
API_KEY=MY0P3NA1K3Y
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Roo Modes
|
||||
.roomodes
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*.*
|
||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.fixAll.eslint"
|
||||
],
|
||||
"eslint.validate": ["javascript", "typescript"],
|
||||
"prettier.singleQuote": true,
|
||||
"cSpell.ignorePaths" : [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default tseslint.config(
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
ignores: ['**/node_modules/*', '**/*.mjs', '**/*.js', 'src/mapper.ts'],
|
||||
ignores: ['**/node_modules/*', '**/*.mjs', '**/*.js'],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
|
||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "gemini-cli-openai-api",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gemini-cli-openai-api",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "^0.1.7",
|
||||
"consola": "^3.4.2",
|
||||
"dotenv": "^17.0.0",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
@@ -18,6 +19,7 @@
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/node": "^24.0.6",
|
||||
"bumpp": "^10.2.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.30.0",
|
||||
"eslint-plugin-n": "^17.20.0",
|
||||
"jiti": "^2.4.2",
|
||||
@@ -2941,7 +2943,6 @@
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
@@ -3185,6 +3186,45 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-cli": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz",
|
||||
"integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dotenv": "^16.3.0",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"dotenv": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-cli/node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-expand": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
|
||||
"integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/dts-resolver": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.1.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"name": "gemini-cli-openai-api",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"main": "server.ts",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"bump-release": "bumpp",
|
||||
"dev": "tsx watch ./src/server.ts",
|
||||
"docker": "npm run docker:build && npm run docker:push",
|
||||
"docker:build": "npm run docker:build:version && npm run docker:tag:latest",
|
||||
"docker:build:version": "dotenv -- bash -c 'docker build -t $DOCKER_REGISTRY/$DOCKER_REGISTRY_USER/$npm_package_name:v$npm_package_version .'",
|
||||
"docker:push": "npm run docker:push:version && npm run docker:push:latest",
|
||||
"docker:push:latest": "dotenv -- bash -c 'docker push $DOCKER_REGISTRY/$DOCKER_REGISTRY_USER/$npm_package_name:latest'",
|
||||
"docker:push:version": "dotenv -- bash -c 'docker push $DOCKER_REGISTRY/$DOCKER_REGISTRY_USER/$npm_package_name:v$npm_package_version'",
|
||||
"docker:tag:latest": "dotenv -- bash -c 'docker tag $DOCKER_REGISTRY/$DOCKER_REGISTRY_USER/$npm_package_name:v$npm_package_version $DOCKER_REGISTRY/$DOCKER_REGISTRY_USER/$npm_package_name:latest'",
|
||||
"start": "node ./dist/server.js",
|
||||
"knip": "knip",
|
||||
"lint": "eslint --fix ."
|
||||
@@ -16,6 +23,7 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "^0.1.7",
|
||||
"consola": "^3.4.2",
|
||||
"dotenv": "^17.0.0",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
@@ -24,6 +32,7 @@
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/node": "^24.0.6",
|
||||
"bumpp": "^10.2.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^9.30.0",
|
||||
"eslint-plugin-n": "^17.20.0",
|
||||
"jiti": "^2.4.2",
|
||||
|
||||
38
src/auth.ts
Normal file
38
src/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @fileoverview This file contains the authentication logic for the server.
|
||||
*/
|
||||
import http from 'http';
|
||||
import { config } from './config';
|
||||
|
||||
/**
|
||||
* Checks for API key authentication.
|
||||
* @param req - The HTTP incoming message object.
|
||||
* @param res - The HTTP server response object.
|
||||
* @returns True if the request is authorized, false otherwise.
|
||||
*/
|
||||
export function isAuthorized(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): boolean {
|
||||
if (!config.API_KEY) {
|
||||
return true; // No key configured, public access.
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({ error: { message: 'Missing Authorization header' } }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (token !== config.API_KEY) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Invalid API key' } }));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,69 +1,208 @@
|
||||
// src/chatwrapper.ts
|
||||
/**
|
||||
* @fileoverview This file provides a wrapper around the Gemini API, handling
|
||||
* content generation, model management, and retry logic.
|
||||
*/
|
||||
import {
|
||||
AuthType,
|
||||
createContentGeneratorConfig,
|
||||
createContentGenerator,
|
||||
ContentGenerator,
|
||||
} from '@google/gemini-cli-core/dist/src/core/contentGenerator.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 1. Build the ContentGenerator exactly like the CLI does */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let modelName: string; // we'll fill this once
|
||||
const generatorPromise = (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);
|
||||
})();
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from '@google/gemini-cli-core/dist/src/config/models.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 2. Helpers consumed by server.ts */
|
||||
/* ------------------------------------------------------------------ */
|
||||
import { Content, GeminiResponse, Model } from './types.js';
|
||||
import consola from 'consola';
|
||||
|
||||
// ==================================================================
|
||||
// 1. ContentGenerator Management
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
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> {
|
||||
let retries = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// Only retry on 'RESOURCE_EXHAUSTED' errors.
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
model,
|
||||
contents,
|
||||
generationConfig = {},
|
||||
}: {
|
||||
contents: any[],
|
||||
model?: string,
|
||||
contents: Content[],
|
||||
generationConfig?: GenConfig,
|
||||
tools?: unknown, // accepted but ignored for now
|
||||
}) {
|
||||
const generator: any = await generatorPromise;
|
||||
return await generator.generateContent({
|
||||
}): Promise<GeminiResponse> {
|
||||
const { generator, model: modelName } = await getGenerator(model);
|
||||
const gResp = await withRetry(() => generator.generateContent({
|
||||
model: modelName,
|
||||
contents,
|
||||
config: generationConfig,
|
||||
});
|
||||
}));
|
||||
return {
|
||||
text: gResp.text ?? '',
|
||||
usageMetadata: {
|
||||
promptTokens: gResp.usageMetadata?.promptTokenCount ?? 0,
|
||||
candidatesTokens: gResp.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
totalTokens: gResp.usageMetadata?.totalTokenCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
model,
|
||||
contents,
|
||||
generationConfig = {},
|
||||
}: {
|
||||
contents: any[],
|
||||
model?: string,
|
||||
contents: Content[],
|
||||
generationConfig?: GenConfig,
|
||||
tools?: unknown,
|
||||
}) {
|
||||
const generator: any = await generatorPromise;
|
||||
const stream = await generator.generateContentStream({
|
||||
const { generator, model: modelName } = await getGenerator(model);
|
||||
const stream = await withRetry(() => generator.generateContentStream({
|
||||
model: modelName,
|
||||
contents,
|
||||
config: generationConfig,
|
||||
});
|
||||
}));
|
||||
for await (const chunk of stream) yield chunk;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 3. Additional stubs to implement later */
|
||||
/* ------------------------------------------------------------------ */
|
||||
// export function listModels() {
|
||||
// return [{ id: modelName }];
|
||||
// }
|
||||
/**
|
||||
* Lists the available models.
|
||||
*
|
||||
* @returns An array of available models.
|
||||
*/
|
||||
export function listModels(): Model[] {
|
||||
return [
|
||||
{
|
||||
id: DEFAULT_GEMINI_MODEL,
|
||||
object: 'model',
|
||||
owned_by: 'google',
|
||||
},
|
||||
{
|
||||
id: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
object: 'model',
|
||||
owned_by: 'google',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 3. Future Implementations
|
||||
// ==================================================================
|
||||
|
||||
// The embeddings endpoint is not yet implemented.
|
||||
// export async function embed(_input: unknown) {
|
||||
// throw new Error('Embeddings endpoint not implemented yet.');
|
||||
// }
|
||||
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
/**
|
||||
* @fileoverview This file manages the application's configuration,
|
||||
* loading environment variables and providing them in a structured object.
|
||||
*/
|
||||
/* eslint-disable n/no-process-env */
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* Application configuration object.
|
||||
*/
|
||||
export const config = {
|
||||
// eslint-disable-next-line n/no-process-env
|
||||
/**
|
||||
* 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),
|
||||
/**
|
||||
* 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),
|
||||
/**
|
||||
* The API key for securing the server.
|
||||
* If not set, the server will be public.
|
||||
* @type {string | undefined}
|
||||
*/
|
||||
API_KEY: process.env.API_KEY,
|
||||
};
|
||||
188
src/mapper.ts
188
src/mapper.ts
@@ -1,95 +1,152 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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 { z } from 'zod';
|
||||
import { ToolRegistry } from '@google/gemini-cli-core/dist/src/tools/tool-registry.js';
|
||||
import { z, ZodRawShape } from 'zod';
|
||||
import { ToolRegistry }
|
||||
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,
|
||||
GeminiRequestBody,
|
||||
Content,
|
||||
} from './types';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
interface Part { text?: string; inlineData?: { mimeType: string, data: string } }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
function callLocalFunction(_name: string, _args: unknown) {
|
||||
return { ok: true };
|
||||
/**
|
||||
* A placeholder for a local function call.
|
||||
*
|
||||
* @returns A promise that resolves to a successful execution result.
|
||||
*/
|
||||
async function callLocalFunction(/*_name: string, _args: unknown*/) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
llmContent: [],
|
||||
returnDisplay: 'Function executed successfully',
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Request mapper: OpenAI ➞ Gemini */
|
||||
/* ================================================================== */
|
||||
export async function mapRequest(body: any) {
|
||||
const parts: Part[] = [];
|
||||
// ==================================================================
|
||||
// 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) {
|
||||
const contents: Content[] = [];
|
||||
const systemParts: Part[] = [];
|
||||
|
||||
/* ---- convert messages & vision --------------------------------- */
|
||||
// Convert messages and handle vision content.
|
||||
for (const m of body.messages) {
|
||||
const parts: Part[] = [];
|
||||
if (Array.isArray(m.content)) {
|
||||
for (const item of m.content) {
|
||||
if (item.type === 'image_url') {
|
||||
if (item.type === 'image_url' && item.image_url) {
|
||||
parts.push({ inlineData: await fetchAndEncode(item.image_url.url) });
|
||||
} else if (item.type === 'text') {
|
||||
parts.push({ text: item.text });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (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> = {
|
||||
temperature: body.temperature,
|
||||
maxOutputTokens: body.max_tokens,
|
||||
topP: body.top_p,
|
||||
...(body.generationConfig ?? {}), // copy anything ST already merged
|
||||
...(body.generationConfig ?? {}), // Preserve existing ST-merged config.
|
||||
};
|
||||
if (body.include_reasoning === true) {
|
||||
generationConfig.enable_thoughts = true; // ← current flag
|
||||
generationConfig.thinking_budget ??= 2048; // optional limit
|
||||
// The current flag for enabling thoughts.
|
||||
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) {
|
||||
generationConfig.thinking = true;
|
||||
generationConfig.thinking_budget ??= 2048;
|
||||
}
|
||||
generationConfig.maxInputTokens ??= 1_000_000; // lift context cap
|
||||
generationConfig.maxInputTokens ??= 1_000_000; // Increase the context cap.
|
||||
|
||||
const geminiReq = {
|
||||
contents: [{ role: 'user', parts }],
|
||||
generationConfig,
|
||||
stream: body.stream,
|
||||
};
|
||||
|
||||
/* ---- Tool / function mapping ----------------------------------- */
|
||||
const tools = new ToolRegistry({} as any);
|
||||
// Map tools and functions.
|
||||
// Note: ToolRegistry expects a complex Config object that is not available
|
||||
// here. Casting to `Config` is a necessary workaround.
|
||||
const tools = new ToolRegistry({} as Config);
|
||||
|
||||
if (body.functions?.length) {
|
||||
const reg = tools as any;
|
||||
body.functions.forEach((fn: any) =>
|
||||
reg.registerTool(
|
||||
fn.name,
|
||||
{
|
||||
title: fn.name,
|
||||
for (const fn of body.functions) {
|
||||
tools.registerTool({
|
||||
name: fn.name,
|
||||
displayName: fn.name,
|
||||
description: fn.description ?? '',
|
||||
inputSchema: z.object(fn.parameters?.properties ?? {}),
|
||||
},
|
||||
(args: unknown) => callLocalFunction(fn.name, args),
|
||||
),
|
||||
);
|
||||
schema: z.object((fn.parameters?.properties as ZodRawShape) ?? {}),
|
||||
isOutputMarkdown: false,
|
||||
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: {
|
||||
contents,
|
||||
generationConfig,
|
||||
stream: body.stream,
|
||||
} as GeminiRequestBody,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Non-stream response: Gemini ➞ OpenAI */
|
||||
/* ================================================================== */
|
||||
export function mapResponse(gResp: any) {
|
||||
const usage = gResp.usageMetadata ?? {};
|
||||
// ==================================================================
|
||||
// Response Mapper: Gemini -> OpenAI (Non-Streaming)
|
||||
// ==================================================================
|
||||
/**
|
||||
* 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 ?? {
|
||||
promptTokens: 0,
|
||||
candidatesTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
return {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: 'gemini-2.5-pro-latest',
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
@@ -98,27 +155,32 @@ export function mapResponse(gResp: any) {
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: usage.promptTokens ?? 0,
|
||||
completion_tokens: usage.candidatesTokens ?? 0,
|
||||
total_tokens: usage.totalTokens ?? 0,
|
||||
prompt_tokens: usage.promptTokens,
|
||||
completion_tokens: usage.candidatesTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Stream chunk mapper: Gemini ➞ OpenAI */
|
||||
/* ================================================================== */
|
||||
// ==================================================================
|
||||
// Stream Chunk Mapper: Gemini -> OpenAI
|
||||
// ==================================================================
|
||||
|
||||
export function mapStreamChunk(chunk: any) {
|
||||
/**
|
||||
* 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) {
|
||||
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) {
|
||||
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') {
|
||||
delta.content = part.text;
|
||||
}
|
||||
return { choices: [ { delta, index: 0 } ] };
|
||||
return { choices: [{ delta, index: 0 }] };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch image: ${url}`);
|
||||
|
||||
154
src/server.ts
154
src/server.ts
@@ -1,23 +1,56 @@
|
||||
/**
|
||||
* @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 http from 'http';
|
||||
import { sendChat, sendChatStream } from './chatwrapper';
|
||||
import { mapRequest, mapResponse, mapStreamChunk } from './mapper';
|
||||
import { listModels, sendChat, sendChatStream } from './chatwrapper';
|
||||
import { mapRequest, mapResponse, mapStreamChunk } from './mapper.js';
|
||||
import { RequestBody, GeminiResponse, GeminiStreamChunk, Part } from './types';
|
||||
import { config } from './config';
|
||||
import { isAuthorized } from './auth';
|
||||
|
||||
/* ── basic config ─────────────────────────────────────────────────── */
|
||||
// ==================================================================
|
||||
// Server Configuration
|
||||
// ==================================================================
|
||||
const PORT = config.PORT;
|
||||
const VERBOSE = config.VERBOSE;
|
||||
|
||||
/* ── CORS helper ──────────────────────────────────────────────────── */
|
||||
// ==================================================================
|
||||
// Logger Setup
|
||||
// ==================================================================
|
||||
if (VERBOSE) {
|
||||
consola.level = 5;
|
||||
consola.info('Verbose logging enabled');
|
||||
}
|
||||
|
||||
consola.info('Google CLI OpenAI proxy');
|
||||
|
||||
// ==================================================================
|
||||
// HTTP Server Helpers
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* Sets CORS headers to allow cross-origin requests.
|
||||
* @param res - The HTTP server response object.
|
||||
*/
|
||||
function allowCors(res: http.ServerResponse) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
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(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): Promise<any | null> {
|
||||
): Promise<RequestBody | null> {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => (data += c));
|
||||
@@ -30,55 +63,61 @@ function readJSON(
|
||||
error: { message: 'Request body is missing for POST request' },
|
||||
}),
|
||||
);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
return resolve(null);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
resolve(JSON.parse(data) as RequestBody);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' }); // malformed JSON
|
||||
// Handle malformed JSON.
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Malformed JSON' } }));
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── server ───────────────────────────────────────────────────────── */
|
||||
// ==================================================================
|
||||
// Main Server Logic
|
||||
// ==================================================================
|
||||
http
|
||||
.createServer(async (req, res) => {
|
||||
allowCors(res);
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
||||
const pathname = url.pathname.replace(/\/$/, '') || '/';
|
||||
console.log(`[proxy] ${req.method} ${url.pathname}`);
|
||||
consola.info(`${req.method} ${url.pathname}`);
|
||||
|
||||
/* -------- pre-flight ---------- */
|
||||
// Handle pre-flight CORS requests.
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204).end();
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------- /v1/models ---------- */
|
||||
if (!isAuthorized(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route for listing available models.
|
||||
if (pathname === '/v1/models' || pathname === '/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
object: 'model',
|
||||
owned_by: 'google',
|
||||
},
|
||||
],
|
||||
data: listModels(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/* ---- /v1/chat/completions ---- */
|
||||
// Route for chat completions.
|
||||
if (
|
||||
(pathname === '/chat/completions' ||
|
||||
(pathname === '/v1/chat/completions' ) && req.method === 'POST')
|
||||
pathname === '/v1/chat/completions') &&
|
||||
req.method === 'POST'
|
||||
) {
|
||||
const body = await readJSON(req, res);
|
||||
if (!body) return;
|
||||
@@ -94,23 +133,68 @@ http
|
||||
});
|
||||
|
||||
for await (const chunk of sendChatStream({ ...geminiReq, tools })) {
|
||||
res.write(`data: ${JSON.stringify(mapStreamChunk(chunk))}\n\n`);
|
||||
// Transform the chunk to match the expected stream format.
|
||||
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');
|
||||
} else {
|
||||
const gResp = await sendChat({ ...geminiReq, tools });
|
||||
const gResp: GeminiResponse = await sendChat({ ...geminiReq, tools });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(mapResponse(gResp)));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Proxy error ➜', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: err.message } }));
|
||||
}
|
||||
return;
|
||||
res.end(JSON.stringify(mapResponse(gResp, body)));
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
consola.error('Proxy error ➜', error);
|
||||
|
||||
/* ---- anything else ---------- */
|
||||
res.writeHead(404).end();
|
||||
// Handle errors, sending them in the appropriate format for streaming
|
||||
// or non-streaming responses.
|
||||
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 {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: error.message } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.listen(PORT, () => console.log(`OpenAI proxy on :${PORT}`));
|
||||
.listen(PORT, () => {
|
||||
consola.info(`Listening on port :${PORT}`);
|
||||
});
|
||||
|
||||
140
src/types.ts
Normal file
140
src/types.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @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 {
|
||||
/** The unique identifier for the model. */
|
||||
id: string;
|
||||
/** The type of object, always 'model'. */
|
||||
object: 'model';
|
||||
/** The owner of the model, always 'google'. */
|
||||
owned_by: 'google';
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents inline data, such as an image.
|
||||
*/
|
||||
interface InlineData {
|
||||
/** The MIME type of the data (e.g., 'image/png'). */
|
||||
mimeType: string;
|
||||
/** The base64-encoded data. */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a part of a multi-part message.
|
||||
*/
|
||||
export interface Part {
|
||||
/** The text content of the part. */
|
||||
text?: string;
|
||||
/** 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 {
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a function definition for tool use.
|
||||
*/
|
||||
interface FunctionDef {
|
||||
/** The name of the function. */
|
||||
name: string;
|
||||
/** A description of the function. */
|
||||
description?: string;
|
||||
/** The parameters of the function, described as a JSON schema. */
|
||||
parameters?: {
|
||||
properties?: Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the body of an incoming OpenAI-compatible request.
|
||||
*/
|
||||
export interface RequestBody {
|
||||
/** The model to use for the request. */
|
||||
model: string;
|
||||
/** A list of messages in the conversation history. */
|
||||
messages: {
|
||||
role: string,
|
||||
content:
|
||||
| string
|
||||
| { type: string, image_url?: { url: string }, text?: string }[],
|
||||
}[];
|
||||
/** The sampling temperature. */
|
||||
temperature?: number;
|
||||
/** The maximum number of tokens to generate. */
|
||||
max_tokens?: number;
|
||||
/** The nucleus sampling probability. */
|
||||
top_p?: number;
|
||||
/** Additional generation configuration for the Gemini API. */
|
||||
generationConfig?: Record<string, unknown>;
|
||||
/** A flag to include reasoning/thoughts in the response. */
|
||||
include_reasoning?: boolean;
|
||||
/** A flag to indicate if the response should be streamed. */
|
||||
stream?: boolean;
|
||||
/** A list of functions the model can call. */
|
||||
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 {
|
||||
/** The generated text content. */
|
||||
text: string;
|
||||
/** Metadata about token usage. */
|
||||
usageMetadata?: {
|
||||
/** The number of tokens in the prompt. */
|
||||
promptTokens: number,
|
||||
/** The number of tokens in the generated candidates. */
|
||||
candidatesTokens: number,
|
||||
/** The total number of tokens used. */
|
||||
totalTokens: number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a chunk of a streaming response from the Gemini API.
|
||||
*/
|
||||
export interface GeminiStreamChunk {
|
||||
/** A list of candidate responses. */
|
||||
candidates?: {
|
||||
content?: {
|
||||
parts?: Part[],
|
||||
},
|
||||
}[];
|
||||
}
|
||||
Reference in New Issue
Block a user