source repo import

This commit is contained in:
cloudflare[bot]
2026-01-12 05:55:32 +00:00
commit 30bc859948
21 changed files with 2636 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Local Netlify folder
.netlify
.wrangler
node_modules/
.vercel

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tech Shrimp(技术爬爬虾)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

186
README.md Normal file
View File

@@ -0,0 +1,186 @@
# Gemini Balance Lite
# Gemini API 代理和负载均衡无服务器轻量版(边缘函数)
### 作者:技术爬爬虾
[B站](https://space.bilibili.com/316183842)[Youtube](https://www.youtube.com/@Tech_Shrimp),抖音,公众号 全网同名。转载请注明作者。
## 项目简介
Gemini API 代理, 使用边缘函数把Gemini API免费中转到国内。还可以聚合多个Gemini API Key随机选取API Key的使用实现负载均衡使得Gemini API免费成倍增加。
## Vercel部署(推荐)
[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/tech-shrimp/gemini-balance-lite)
1. 点击部署按钮⬆️一键部署。
2. 国内使用需要配置自定义域名
<details>
<summary>配置自定义域名:</summary>
![image](/docs/images/5.png)
</details>
3. 去[AIStudio](https://aistudio.google.com)申请一个免费Gemini API Key
<br>将API Key与自定义的域名填入AI客户端即可使用如果有多个API Key用逗号分隔
<details>
<summary>以Cherry Studio为例</summary>
![image](/docs/images/2.png)
</details>
## Deno部署
1. [fork](https://github.com/tech-shrimp/gemini-balance-lite/fork)本项目
2. 登录/注册 https://dash.deno.com/
3. 创建项目 https://dash.deno.com/new_project
4. 选择此项目,填写项目名字(请仔细填写项目名字,关系到自动分配的域名)
5. Entrypoint 填写 `src/deno_index.ts` 其他字段留空
<details>
<summary>如图</summary>
![image](/docs/images/3.png)
</details>
6. 点击 <b>Deploy Project</b>
7. 部署成功后获得域名
8. 国内使用需要配置自定义域名
9. 去[AIStudio](https://aistudio.google.com)申请一个免费Gemini API Key
10. 将API Key与分配的域名填入AI客户端即可使用如果有多个API Key用逗号分隔
<details>
<summary>以Cherry Studio为例</summary>
![image](/docs/images/2.png)
</details>
## Cloudflare Worker 部署
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/tech-shrimp/gemini-balance-lite)
0. CF Worker有可能会分配香港的CDN节点导致无法使用(Gemini不允许香港IP连接)
0. 广东地区不建议使用Cloudflare Worker 部署
1. 点击部署按钮
2. 登录Cloudflare账号
3. 链接Github账户部署
4. 打开dash.cloudflare.com查看部署后的worker
6. 国内使用需要配置自定义域名
<details>
<summary>配置自定义域名:</summary>
![image](/docs/images/4.png)
</details>
## Netlify部署
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/tech-shrimp/gemini-balance-lite)
<br>点击部署按钮登录Github账户即可
<br>免费分配域名,国内可直连。
<br>但是不稳定
<details>
<summary>将分配的域名复制下来,如图:</summary>
![image](/docs/images/1.png)
</details>
去[AIStudio](https://aistudio.google.com)申请一个免费Gemini API Key
<br>将API Key与分配的域名填入AI客户端即可使用如果有多个API Key用逗号分隔
<details>
<summary>以Cherry Studio为例</summary>
![image](/docs/images/2.png)
</details>
## 打赏
#### 帮忙点点关注点点赞,谢谢啦~
B站[https://space.bilibili.com/316183842](https://space.bilibili.com/316183842)<br>
Youtube: [https://www.youtube.com/@Tech_Shrimp](https://www.youtube.com/@Tech_Shrimp)
## 本地调试
1. 安装NodeJs
2. npm install -g vercel
3. cd 项目根目录
4. vercel dev
## API 说明
### Gemini 代理
可以使用 Gemini 的原生 API 格式进行代理请求。
**Curl 示例:**
```bash
curl -X POST --location 'https://<YOUR_DEPLOYED_DOMAIN>/v1beta/models/gemini-2.5-pro:generateContent' \
--header 'Content-Type: application/json' \
--header 'x-goog-api-key: <YOUR_GEMINI_API_KEY_1>,<YOUR_GEMINI_API_KEY_2>' \
--data '{
"contents": [
{
"role": "user",
"parts": [
{
"text": "Hello"
}
]
}
]
}'
```
**Curl 示例:(流式)**
```bash
curl -X POST --location 'https://<YOUR_DEPLOYED_DOMAIN>/v1beta/models/gemini-2.5-pro:generateContent?alt=sse' \
--header 'Content-Type: application/json' \
--header 'x-goog-api-key: <YOUR_GEMINI_API_KEY_1>,<YOUR_GEMINI_API_KEY_2>' \
--data '{
"contents": [
{
"role": "user",
"parts": [
{
"text": "Hello"
}
]
}
]
}'
```
> 注意: 请将 `<YOUR_DEPLOYED_DOMAIN>` 替换为你的部署域名,并将 `<YOUR_GEMINI_API_KEY>` 替换为你的 Gemini API Ke如果有多个用逗号分隔
### API Key 校验
可以通过向 `/verify` 端点发送请求来校验你的 API Key 是否有效。可以一次性校验多个 Key用逗号隔开。
**Curl 示例:**
```bash
curl -X POST --location 'https://<YOUR_DEPLOYED_DOMAIN>/verify' \
--header 'x-goog-api-key: <YOUR_GEMINI_API_KEY_1>,<YOUR_GEMINI_API_KEY_2>'
```
### OpenAI 格式
本项目兼容 OpenAI 的 API 格式,你可以通过 `/chat``/chat/completions` 端点来发送请求。
**Curl 示例:**
```bash
curl -X POST --location 'https://<YOUR_DEPLOYED_DOMAIN>/chat/completions' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <YOUR_GEMINI_API_KEY>' \
--data '{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": "你好"
}
]
}'
```

9
api/vercel_index.js Normal file
View File

@@ -0,0 +1,9 @@
import { handleRequest } from "../src/handle_request.js";
export const config = {
runtime: 'edge' //告诉 Vercel 这是 Edge Function
};
export default async function handler(req) {
return handleRequest(req);
}

18
deno.lock generated Normal file
View File

@@ -0,0 +1,18 @@
{
"version": "4",
"remote": {
"https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb",
"https://edge.netlify.com/bootstrap/config.ts": "19dcaa5c4480175295c4dffc9be11f7c280d9391b851d30409f3e6904c34a161",
"https://edge.netlify.com/bootstrap/context.ts": "c6e9de479234f3ac500d2fc1544dadf1925e335d0d5551b1a71b7ce14423a683",
"https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05",
"https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39",
"https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f"
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:wrangler@^4.23.0"
]
}
}
}

BIN
docs/images/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/images/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
docs/images/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/images/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
docs/images/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

4
netlify.toml Normal file
View File

@@ -0,0 +1,4 @@
[[redirects]]
from = "/"
to = "/.netlify/functions/api"
status = 200

5
netlify/functions/api.js Normal file
View File

@@ -0,0 +1,5 @@
import { handleRequest } from "../../src/handle_request.js";
export default async (req, context) => {
return handleRequest(req);
};

1528
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"wrangler": "^4.23.0"
},"name": "gemini-balance-lite"
}

9
src/deno_index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { handleRequest } from "./handle_request.js";
async function denoHandleRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
console.log('Request URL:', req.url);
return handleRequest(req);
};
Deno.serve({ port: 80 },denoHandleRequest);

81
src/handle_request.js Normal file
View File

@@ -0,0 +1,81 @@
import { handleVerification } from './verify_keys.js';
import openai from './openai.mjs';
export async function handleRequest(request) {
const url = new URL(request.url);
const pathname = url.pathname;
const search = url.search;
if (pathname === '/' || pathname === '/index.html') {
return new Response('Proxy is Running! More Details: https://github.com/tech-shrimp/gemini-balance-lite', {
status: 200,
headers: { 'Content-Type': 'text/html' }
});
}
if (pathname === '/verify' && request.method === 'POST') {
return handleVerification(request);
}
// 处理OpenAI格式请求
if (url.pathname.endsWith("/chat/completions") || url.pathname.endsWith("/completions") || url.pathname.endsWith("/embeddings") || url.pathname.endsWith("/models")) {
return openai.fetch(request);
}
const targetUrl = `https://generativelanguage.googleapis.com${pathname}${search}`;
try {
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (key.trim().toLowerCase() === 'x-goog-api-key') {
const apiKeys = value.split(',').map(k => k.trim()).filter(k => k);
if (apiKeys.length > 0) {
const selectedKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
console.log(`Gemini Selected API Key: ${selectedKey}`);
headers.set('x-goog-api-key', selectedKey);
}
} else {
if (key.trim().toLowerCase()==='content-type')
{
headers.set(key, value);
}
}
}
console.log('Request Sending to Gemini')
console.log('targetUrl:'+targetUrl)
console.log(headers)
const response = await fetch(targetUrl, {
method: request.method,
headers: headers,
body: request.body
});
console.log("Call Gemini Success")
const responseHeaders = new Headers(response.headers);
console.log('Header from Gemini:')
console.log(responseHeaders)
responseHeaders.delete('transfer-encoding');
responseHeaders.delete('connection');
responseHeaders.delete('keep-alive');
responseHeaders.delete('content-encoding');
responseHeaders.set('Referrer-Policy', 'no-referrer');
return new Response(response.body, {
status: response.status,
headers: responseHeaders
});
} catch (error) {
console.error('Failed to fetch:', error);
return new Response('Internal Server Error\n' + error?.stack, {
status: 500,
headers: { 'Content-Type': 'text/plain' }
});
}
};

9
src/index.js Normal file
View File

@@ -0,0 +1,9 @@
import { handleRequest } from "./handle_request.js";
export default {
async fetch (req, env, context) {
const url = new URL(req.url);
console.log('Request URL:', req.url);
return handleRequest(req);
}
}

681
src/openai.mjs Normal file
View File

@@ -0,0 +1,681 @@
//Author: PublicAffairs
//Project: https://github.com/PublicAffairs/openai-gemini
//MIT License : https://github.com/PublicAffairs/openai-gemini/blob/main/LICENSE
import { Buffer } from "node:buffer";
export default {
async fetch (request) {
if (request.method === "OPTIONS") {
return handleOPTIONS();
}
const errHandler = (err) => {
console.error(err);
return new Response(err.message, fixCors({ status: err.status ?? 500 }));
};
try {
const auth = request.headers.get("Authorization");
let apiKey = auth?.split(" ")[1];
if (apiKey && apiKey.includes(',')) {
const apiKeys = apiKey.split(',').map(k => k.trim()).filter(k => k);
apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
console.log(`OpenAI Selected API Key: ${apiKey}`);
}
const assert = (success) => {
if (!success) {
throw new HttpError("The specified HTTP method is not allowed for the requested resource", 400);
}
};
const { pathname } = new URL(request.url);
switch (true) {
case pathname.endsWith("/chat/completions"):
assert(request.method === "POST");
return handleCompletions(await request.json(), apiKey)
.catch(errHandler);
case pathname.endsWith("/embeddings"):
assert(request.method === "POST");
return handleEmbeddings(await request.json(), apiKey)
.catch(errHandler);
case pathname.endsWith("/models"):
assert(request.method === "GET");
return handleModels(apiKey)
.catch(errHandler);
default:
throw new HttpError("404 Not Found", 404);
}
} catch (err) {
return errHandler(err);
}
}
};
class HttpError extends Error {
constructor(message, status) {
super(message);
this.name = this.constructor.name;
this.status = status;
}
}
const fixCors = ({ headers, status, statusText }) => {
headers = new Headers(headers);
headers.set("Access-Control-Allow-Origin", "*");
return { headers, status, statusText };
};
const handleOPTIONS = async () => {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}
});
};
const BASE_URL = "https://generativelanguage.googleapis.com";
const API_VERSION = "v1beta";
// https://github.com/google-gemini/generative-ai-js/blob/cf223ff4a1ee5a2d944c53cddb8976136382bee6/src/requests/request.ts#L71
const API_CLIENT = "genai-js/0.21.0"; // npm view @google/generative-ai version
const makeHeaders = (apiKey, more) => ({
"x-goog-api-client": API_CLIENT,
...(apiKey && { "x-goog-api-key": apiKey }),
...more
});
async function handleModels (apiKey) {
const response = await fetch(`${BASE_URL}/${API_VERSION}/models`, {
headers: makeHeaders(apiKey),
});
let { body } = response;
if (response.ok) {
const { models } = JSON.parse(await response.text());
body = JSON.stringify({
object: "list",
data: models.map(({ name }) => ({
id: name.replace("models/", ""),
object: "model",
created: 0,
owned_by: "",
})),
}, null, " ");
}
return new Response(body, fixCors(response));
}
const DEFAULT_EMBEDDINGS_MODEL = "text-embedding-004";
async function handleEmbeddings (req, apiKey) {
if (typeof req.model !== "string") {
throw new HttpError("model is not specified", 400);
}
let model;
if (req.model.startsWith("models/")) {
model = req.model;
} else {
if (!req.model.startsWith("gemini-")) {
req.model = DEFAULT_EMBEDDINGS_MODEL;
}
model = "models/" + req.model;
}
if (!Array.isArray(req.input)) {
req.input = [ req.input ];
}
const response = await fetch(`${BASE_URL}/${API_VERSION}/${model}:batchEmbedContents`, {
method: "POST",
headers: makeHeaders(apiKey, { "Content-Type": "application/json" }),
body: JSON.stringify({
"requests": req.input.map(text => ({
model,
content: { parts: { text } },
outputDimensionality: req.dimensions,
}))
})
});
let { body } = response;
if (response.ok) {
const { embeddings } = JSON.parse(await response.text());
body = JSON.stringify({
object: "list",
data: embeddings.map(({ values }, index) => ({
object: "embedding",
index,
embedding: values,
})),
model: req.model,
}, null, " ");
}
return new Response(body, fixCors(response));
}
const DEFAULT_MODEL = "gemini-2.5-flash";
async function handleCompletions (req, apiKey) {
let model = DEFAULT_MODEL;
switch (true) {
case typeof req.model !== "string":
break;
case req.model.startsWith("models/"):
model = req.model.substring(7);
break;
case req.model.startsWith("gemini-"):
case req.model.startsWith("gemma-"):
case req.model.startsWith("learnlm-"):
model = req.model;
}
let body = await transformRequest(req);
const extra = req.extra_body?.google
if (extra) {
if (extra.safety_settings) {
body.safetySettings = extra.safety_settings;
}
if (extra.cached_content) {
body.cachedContent = extra.cached_content;
}
if (extra.thinking_config) {
body.generationConfig.thinkingConfig = extra.thinking_config;
}
}
switch (true) {
case model.endsWith(":search"):
model = model.substring(0, model.length - 7);
// eslint-disable-next-line no-fallthrough
case req.model.endsWith("-search-preview"):
case req.tools?.some(tool => tool.function?.name === 'googleSearch'):
body.tools = body.tools || [];
body.tools.push({googleSearch: {}});
}
console.log(body.tools)
const TASK = req.stream ? "streamGenerateContent" : "generateContent";
let url = `${BASE_URL}/${API_VERSION}/models/${model}:${TASK}`;
if (req.stream) { url += "?alt=sse"; }
const response = await fetch(url, {
method: "POST",
headers: makeHeaders(apiKey, { "Content-Type": "application/json" }),
body: JSON.stringify(body),
});
body = response.body;
if (response.ok) {
let id = "chatcmpl-" + generateId(); //"chatcmpl-8pMMaqXMK68B3nyDBrapTDrhkHBQK";
const shared = {};
if (req.stream) {
body = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TransformStream({
transform: parseStream,
flush: parseStreamFlush,
buffer: "",
shared,
}))
.pipeThrough(new TransformStream({
transform: toOpenAiStream,
flush: toOpenAiStreamFlush,
streamIncludeUsage: req.stream_options?.include_usage,
model, id, last: [],
shared,
}))
.pipeThrough(new TextEncoderStream());
} else {
body = await response.text();
try {
body = JSON.parse(body);
if (!body.candidates) {
throw new Error("Invalid completion object");
}
} catch (err) {
console.error("Error parsing response:", err);
return new Response(body, fixCors(response)); // output as is
}
body = processCompletionsResponse(body, model, id);
}
}
return new Response(body, fixCors(response));
}
const adjustProps = (schemaPart) => {
if (typeof schemaPart !== "object" || schemaPart === null) {
return;
}
if (Array.isArray(schemaPart)) {
schemaPart.forEach(adjustProps);
} else {
if (schemaPart.type === "object" && schemaPart.properties && schemaPart.additionalProperties === false) {
delete schemaPart.additionalProperties;
}
Object.values(schemaPart).forEach(adjustProps);
}
};
const adjustSchema = (schema) => {
const obj = schema[schema.type];
delete obj.strict;
return adjustProps(schema);
};
const harmCategory = [
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_DANGEROUS_CONTENT",
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_CIVIC_INTEGRITY",
];
const safetySettings = harmCategory.map(category => ({
category,
threshold: "BLOCK_NONE",
}));
const fieldsMap = {
frequency_penalty: "frequencyPenalty",
max_completion_tokens: "maxOutputTokens",
max_tokens: "maxOutputTokens",
n: "candidateCount", // not for streaming
presence_penalty: "presencePenalty",
seed: "seed",
stop: "stopSequences",
temperature: "temperature",
top_k: "topK", // non-standard
top_p: "topP",
};
const thinkingBudgetMap = {
low: 1024,
medium: 8192,
high: 24576,
};
const transformConfig = (req) => {
let cfg = {};
//if (typeof req.stop === "string") { req.stop = [req.stop]; } // no need
for (let key in req) {
const matchedKey = fieldsMap[key];
if (matchedKey) {
cfg[matchedKey] = req[key];
}
}
if (req.response_format) {
switch (req.response_format.type) {
case "json_schema":
adjustSchema(req.response_format);
cfg.responseSchema = req.response_format.json_schema?.schema;
if (cfg.responseSchema && "enum" in cfg.responseSchema) {
cfg.responseMimeType = "text/x.enum";
break;
}
// eslint-disable-next-line no-fallthrough
case "json_object":
cfg.responseMimeType = "application/json";
break;
case "text":
cfg.responseMimeType = "text/plain";
break;
default:
throw new HttpError("Unsupported response_format.type", 400);
}
}
if (req.reasoning_effort) {
cfg.thinkingConfig = { thinkingBudget: thinkingBudgetMap[req.reasoning_effort] };
}
return cfg;
};
const parseImg = async (url) => {
let mimeType, data;
if (url.startsWith("http://") || url.startsWith("https://")) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText} (${url})`);
}
mimeType = response.headers.get("content-type");
data = Buffer.from(await response.arrayBuffer()).toString("base64");
} catch (err) {
throw new Error("Error fetching image: " + err.toString());
}
} else {
const match = url.match(/^data:(?<mimeType>.*?)(;base64)?,(?<data>.*)$/);
if (!match) {
throw new HttpError("Invalid image data: " + url, 400);
}
({ mimeType, data } = match.groups);
}
return {
inlineData: {
mimeType,
data,
},
};
};
const transformFnResponse = ({ content, tool_call_id }, parts) => {
if (!parts.calls) {
throw new HttpError("No function calls found in the previous message", 400);
}
let response;
try {
response = JSON.parse(content);
} catch (err) {
console.error("Error parsing function response content:", err);
throw new HttpError("Invalid function response: " + content, 400);
}
if (typeof response !== "object" || response === null || Array.isArray(response)) {
response = { result: response };
}
if (!tool_call_id) {
throw new HttpError("tool_call_id not specified", 400);
}
const { i, name } = parts.calls[tool_call_id] ?? {};
if (!name) {
throw new HttpError("Unknown tool_call_id: " + tool_call_id, 400);
}
if (parts[i]) {
throw new HttpError("Duplicated tool_call_id: " + tool_call_id, 400);
}
parts[i] = {
functionResponse: {
id: tool_call_id.startsWith("call_") ? null : tool_call_id,
name,
response,
}
};
};
const transformFnCalls = ({ tool_calls }) => {
const calls = {};
const parts = tool_calls.map(({ function: { arguments: argstr, name }, id, type }, i) => {
if (type !== "function") {
throw new HttpError(`Unsupported tool_call type: "${type}"`, 400);
}
let args;
try {
args = JSON.parse(argstr);
} catch (err) {
console.error("Error parsing function arguments:", err);
throw new HttpError("Invalid function arguments: " + argstr, 400);
}
calls[id] = {i, name};
return {
functionCall: {
id: id.startsWith("call_") ? null : id,
name,
args,
}
};
});
parts.calls = calls;
return parts;
};
const transformMsg = async ({ content }) => {
const parts = [];
if (!Array.isArray(content)) {
// system, user: string
// assistant: string or null (Required unless tool_calls is specified.)
parts.push({ text: content });
return parts;
}
// user:
// An array of content parts with a defined type.
// Supported options differ based on the model being used to generate the response.
// Can contain text, image, or audio inputs.
for (const item of content) {
switch (item.type) {
case "text":
parts.push({ text: item.text });
break;
case "image_url":
parts.push(await parseImg(item.image_url.url));
break;
case "input_audio":
parts.push({
inlineData: {
mimeType: "audio/" + item.input_audio.format,
data: item.input_audio.data,
}
});
break;
default:
throw new HttpError(`Unknown "content" item type: "${item.type}"`, 400);
}
}
if (content.every(item => item.type === "image_url")) {
parts.push({ text: "" }); // to avoid "Unable to submit request because it must have a text parameter"
}
return parts;
};
const transformMessages = async (messages) => {
if (!messages) { return; }
const contents = [];
let system_instruction;
for (const item of messages) {
switch (item.role) {
case "system":
system_instruction = { parts: await transformMsg(item) };
continue;
case "tool":
// eslint-disable-next-line no-case-declarations
let { role, parts } = contents[contents.length - 1] ?? {};
if (role !== "function") {
const calls = parts?.calls;
parts = []; parts.calls = calls;
contents.push({
role: "function", // ignored
parts
});
}
transformFnResponse(item, parts);
continue;
case "assistant":
item.role = "model";
break;
case "user":
break;
default:
throw new HttpError(`Unknown message role: "${item.role}"`, 400);
}
contents.push({
role: item.role,
parts: item.tool_calls ? transformFnCalls(item) : await transformMsg(item)
});
}
if (system_instruction) {
if (!contents[0]?.parts.some(part => part.text)) {
contents.unshift({ role: "user", parts: { text: " " } });
}
}
//console.info(JSON.stringify(contents, 2));
return { system_instruction, contents };
};
const transformTools = (req) => {
let tools, tool_config;
if (req.tools) {
const funcs = req.tools.filter(tool => tool.type === "function" && tool.function?.name !== 'googleSearch');
if (funcs.length > 0) {
funcs.forEach(adjustSchema);
tools = [{ function_declarations: funcs.map(schema => schema.function) }];
}
}
if (req.tool_choice) {
const allowed_function_names = req.tool_choice?.type === "function" ? [ req.tool_choice?.function?.name ] : undefined;
if (allowed_function_names || typeof req.tool_choice === "string") {
tool_config = {
function_calling_config: {
mode: allowed_function_names ? "ANY" : req.tool_choice.toUpperCase(),
allowed_function_names
}
};
}
}
return { tools, tool_config };
};
const transformRequest = async (req) => ({
...await transformMessages(req.messages),
safetySettings,
generationConfig: transformConfig(req),
...transformTools(req),
});
const generateId = () => {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const randomChar = () => characters[Math.floor(Math.random() * characters.length)];
return Array.from({ length: 29 }, randomChar).join("");
};
const reasonsMap = { //https://ai.google.dev/api/rest/v1/GenerateContentResponse#finishreason
//"FINISH_REASON_UNSPECIFIED": // Default value. This value is unused.
"STOP": "stop",
"MAX_TOKENS": "length",
"SAFETY": "content_filter",
"RECITATION": "content_filter",
//"OTHER": "OTHER",
};
const SEP = "\n\n|>";
const transformCandidates = (key, cand) => {
const message = { role: "assistant", content: [] };
for (const part of cand.content?.parts ?? []) {
if (part.functionCall) {
const fc = part.functionCall;
message.tool_calls = message.tool_calls ?? [];
message.tool_calls.push({
id: fc.id ?? "call_" + generateId(),
type: "function",
function: {
name: fc.name,
arguments: JSON.stringify(fc.args),
}
});
} else {
message.content.push(part.text);
}
}
message.content = message.content.join(SEP) || null;
return {
index: cand.index || 0, // 0-index is absent in new -002 models response
[key]: message,
logprobs: null,
finish_reason: message.tool_calls ? "tool_calls" : reasonsMap[cand.finishReason] || cand.finishReason,
//original_finish_reason: cand.finishReason,
};
};
const transformCandidatesMessage = transformCandidates.bind(null, "message");
const transformCandidatesDelta = transformCandidates.bind(null, "delta");
const transformUsage = (data) => ({
completion_tokens: data.candidatesTokenCount,
prompt_tokens: data.promptTokenCount,
total_tokens: data.totalTokenCount
});
const checkPromptBlock = (choices, promptFeedback, key) => {
if (choices.length) { return; }
if (promptFeedback?.blockReason) {
console.log("Prompt block reason:", promptFeedback.blockReason);
if (promptFeedback.blockReason === "SAFETY") {
promptFeedback.safetyRatings
.filter(r => r.blocked)
.forEach(r => console.log(r));
}
choices.push({
index: 0,
[key]: null,
finish_reason: "content_filter",
//original_finish_reason: data.promptFeedback.blockReason,
});
}
return true;
};
const processCompletionsResponse = (data, model, id) => {
const obj = {
id,
choices: data.candidates.map(transformCandidatesMessage),
created: Math.floor(Date.now()/1000),
model: data.modelVersion ?? model,
//system_fingerprint: "fp_69829325d0",
object: "chat.completion",
usage: data.usageMetadata && transformUsage(data.usageMetadata),
};
if (obj.choices.length === 0 ) {
checkPromptBlock(obj.choices, data.promptFeedback, "message");
}
return JSON.stringify(obj);
};
const responseLineRE = /^data: (.*)(?:\n\n|\r\r|\r\n\r\n)/;
function parseStream (chunk, controller) {
this.buffer += chunk;
do {
const match = this.buffer.match(responseLineRE);
if (!match) { break; }
controller.enqueue(match[1]);
this.buffer = this.buffer.substring(match[0].length);
} while (true); // eslint-disable-line no-constant-condition
}
function parseStreamFlush (controller) {
if (this.buffer) {
console.error("Invalid data:", this.buffer);
controller.enqueue(this.buffer);
this.shared.is_buffers_rest = true;
}
}
const delimiter = "\n\n";
const sseline = (obj) => {
obj.created = Math.floor(Date.now()/1000);
return "data: " + JSON.stringify(obj) + delimiter;
};
function toOpenAiStream (line, controller) {
let data;
try {
data = JSON.parse(line);
if (!data.candidates) {
throw new Error("Invalid completion chunk object");
}
} catch (err) {
console.error("Error parsing response:", err);
if (!this.shared.is_buffers_rest) { line =+ delimiter; }
controller.enqueue(line); // output as is
return;
}
const obj = {
id: this.id,
choices: data.candidates.map(transformCandidatesDelta),
//created: Math.floor(Date.now()/1000),
model: data.modelVersion ?? this.model,
//system_fingerprint: "fp_69829325d0",
object: "chat.completion.chunk",
usage: data.usageMetadata && this.streamIncludeUsage ? null : undefined,
};
if (checkPromptBlock(obj.choices, data.promptFeedback, "delta")) {
controller.enqueue(sseline(obj));
return;
}
console.assert(data.candidates.length === 1, "Unexpected candidates count: %d", data.candidates.length);
const cand = obj.choices[0];
cand.index = cand.index || 0; // absent in new -002 models response
const finish_reason = cand.finish_reason;
cand.finish_reason = null;
if (!this.last[cand.index]) { // first
controller.enqueue(sseline({
...obj,
choices: [{ ...cand, tool_calls: undefined, delta: { role: "assistant", content: "" } }],
}));
}
delete cand.delta.role;
if ("content" in cand.delta) { // prevent empty data (e.g. when MAX_TOKENS)
controller.enqueue(sseline(obj));
}
cand.finish_reason = finish_reason;
if (data.usageMetadata && this.streamIncludeUsage) {
obj.usage = transformUsage(data.usageMetadata);
}
cand.delta = {};
this.last[cand.index] = obj;
}
function toOpenAiStreamFlush (controller) {
if (this.last.length > 0) {
for (const obj of this.last) {
controller.enqueue(sseline(obj));
}
controller.enqueue("data: [DONE]" + delimiter);
}
}

65
src/verify_keys.js Normal file
View File

@@ -0,0 +1,65 @@
async function verifyKey(key, controller) {
const url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
const body = {
"contents": [{
"role": "user",
"parts": [{
"text": "Hello"
}]
}]
};
let result;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-goog-api-key': key,
},
body: JSON.stringify(body),
});
if (response.ok) {
await response.text(); // Consume body to release connection
result = { key: `${key.slice(0, 7)}......${key.slice(-7)}`, status: 'GOOD' };
} else {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }));
result = { key: `${key.slice(0, 7)}......${key.slice(-7)}`, status: 'BAD', error: errorData.error.message };
}
} catch (e) {
result = { key: `${key.slice(0, 7)}......${key.slice(-7)}`, status: 'ERROR', error: e.message };
}
controller.enqueue(new TextEncoder().encode('data: ' + JSON.stringify(result) + '\n\n'));
}
export async function handleVerification(request) {
try {
const authHeader = request.headers.get('x-goog-api-key');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing x-goog-api-key header.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const keys = authHeader.split(',').map(k => k.trim()).filter(Boolean);
const stream = new ReadableStream({
async start(controller) {
const verificationPromises = keys.map(key => verifyKey(key, controller));
await Promise.all(verificationPromises);
controller.close();
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
}
});
} catch (e) {
return new Response(JSON.stringify({ error: 'An unexpected error occurred: ' + e.message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}

5
vercel.json Normal file
View File

@@ -0,0 +1,5 @@
{
"routes": [
{ "src": "/(.*)", "dest": "/api/vercel_index.js" }
]
}

5
wrangler.toml Normal file
View File

@@ -0,0 +1,5 @@
name = "gemini-balance-lite"
# 主入口
main = "src/index.js"
compatibility_date = "2025-06-17"
compatibility_flags = ["nodejs_compat"]