source repo import
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Local Netlify folder
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
node_modules/
|
||||||
|
.vercel
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
186
README.md
Normal 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部署(推荐)
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https://github.com/tech-shrimp/gemini-balance-lite)
|
||||||
|
|
||||||
|
|
||||||
|
1. 点击部署按钮⬆️一键部署。
|
||||||
|
2. 国内使用需要配置自定义域名
|
||||||
|
<details>
|
||||||
|
<summary>配置自定义域名:</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
3. 去[AIStudio](https://aistudio.google.com)申请一个免费Gemini API Key
|
||||||
|
<br>将API Key与自定义的域名填入AI客户端即可使用,如果有多个API Key用逗号分隔
|
||||||
|
<details>
|
||||||
|
<summary>以Cherry Studio为例:</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</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>
|
||||||
|
|
||||||
|

|
||||||
|
</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>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
## Cloudflare Worker 部署
|
||||||
|
[](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>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
## Netlify部署
|
||||||
|
[](https://app.netlify.com/start/deploy?repository=https://github.com/tech-shrimp/gemini-balance-lite)
|
||||||
|
<br>点击部署按钮,登录Github账户即可
|
||||||
|
<br>免费分配域名,国内可直连。
|
||||||
|
<br>但是不稳定
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>将分配的域名复制下来,如图:</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
去[AIStudio](https://aistudio.google.com)申请一个免费Gemini API Key
|
||||||
|
<br>将API Key与分配的域名填入AI客户端即可使用,如果有多个API Key用逗号分隔
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>以Cherry Studio为例:</summary>
|
||||||
|
|
||||||
|

|
||||||
|
</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
9
api/vercel_index.js
Normal 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
18
deno.lock
generated
Normal 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
BIN
docs/images/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/images/2.png
Normal file
BIN
docs/images/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/images/3.png
Normal file
BIN
docs/images/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/images/4.png
Normal file
BIN
docs/images/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/images/5.png
Normal file
BIN
docs/images/5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
4
netlify.toml
Normal file
4
netlify.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[[redirects]]
|
||||||
|
from = "/"
|
||||||
|
to = "/.netlify/functions/api"
|
||||||
|
status = 200
|
||||||
5
netlify/functions/api.js
Normal file
5
netlify/functions/api.js
Normal 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
1528
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.23.0"
|
||||||
|
},"name": "gemini-balance-lite"
|
||||||
|
}
|
||||||
9
src/deno_index.ts
Normal file
9
src/deno_index.ts
Normal 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
81
src/handle_request.js
Normal 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
9
src/index.js
Normal 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
681
src/openai.mjs
Normal 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
65
src/verify_keys.js
Normal 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
5
vercel.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"routes": [
|
||||||
|
{ "src": "/(.*)", "dest": "/api/vercel_index.js" }
|
||||||
|
]
|
||||||
|
}
|
||||||
5
wrangler.toml
Normal file
5
wrangler.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name = "gemini-balance-lite"
|
||||||
|
# 主入口
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2025-06-17"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
Reference in New Issue
Block a user