├── public
└── empty.html
├── .gitignore
├── vercel.json
├── netlify.toml
├── netlify
├── functions
│ └── handler.mjs
└── edge-functions
│ └── handler.mjs
├── wrangler.toml
├── deno.mjs
├── .editorconfig
├── bun.mjs
├── node.mjs
├── package.json
├── .github
└── workflows
│ └── cf-deploy.yml
├── api
└── handler.mjs
├── LICENSE
├── readme.MD
└── src
└── worker.mjs
/public/empty.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .netlify
3 | .vercel
4 | .wrangler
5 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | { "source": "/(.*)", "destination": "api/handler" }
4 | ]
5 | }
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | publish = "public/"
3 |
4 | [functions]
5 | node_bundler = "esbuild"
--------------------------------------------------------------------------------
/netlify/functions/handler.mjs:
--------------------------------------------------------------------------------
1 | export const config = { path: "/*" };
2 |
3 | import worker from "../../src/worker.mjs";
4 |
5 | export default worker.fetch;
6 |
--------------------------------------------------------------------------------
/netlify/edge-functions/handler.mjs:
--------------------------------------------------------------------------------
1 | export const config = { path: "/edge/*" };
2 |
3 | import worker from "../../src/worker.mjs";
4 |
5 | export default worker.fetch;
6 |
--------------------------------------------------------------------------------
/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "gemini"
2 | main = "src/worker.mjs"
3 | compatibility_date = "2024-09-23"
4 | compatibility_flags = [ "nodejs_compat" ]
5 |
6 | [observability.logs]
7 | enabled = true
8 |
--------------------------------------------------------------------------------
/deno.mjs:
--------------------------------------------------------------------------------
1 | //deprecated:
2 | //import {serve} from "https://deno.land/std/http/mod.ts"
3 |
4 | import worker from "./src/worker.mjs";
5 |
6 | const port = +(Deno.env.get("PORT") ?? 8080);
7 |
8 | Deno.serve({port}, worker.fetch);
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.yml]
13 | indent_style = space
14 |
--------------------------------------------------------------------------------
/bun.mjs:
--------------------------------------------------------------------------------
1 | import worker from "./src/worker.mjs";
2 |
3 | worker.port = +(process.env.PORT || 8080);
4 | const environment = process.env.NODE_ENV ?? "development"
5 | worker.development = process.env.NODE_ENV==="development"
6 |
7 | const server = Bun.serve(worker);
8 | console.log(`[${environment}] Listening on ${server.url.origin}`);
9 |
--------------------------------------------------------------------------------
/node.mjs:
--------------------------------------------------------------------------------
1 | import { createServerAdapter } from '@whatwg-node/server'
2 | import { createServer } from 'node:http'
3 | import worker from "./src/worker.mjs";
4 |
5 | const port = +(process.env.PORT || 8080);
6 |
7 | const serverAdapter = createServerAdapter(worker.fetch)
8 | const server = createServer(serverAdapter)
9 | server.listen(port, () => {
10 | console.log('Listening on:', server.address());
11 | })
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@whatwg-node/server": "0.9"
4 | },
5 | "devDependencies": {
6 | "nodemon": "3"
7 | },
8 | "scripts": {
9 | "start": "node node.mjs",
10 | "dev": "nodemon --watch node.mjs --watch src/*.mjs node.mjs",
11 | "start:deno": "deno --allow-env --allow-net deno.mjs",
12 | "dev:deno": "deno --watch --allow-env --allow-net deno.mjs",
13 | "start:bun": "bun bun.mjs",
14 | "dev:bun": "bun --watch bun.mjs"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/cf-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Worker
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | repository_dispatch:
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 | name: Deploy
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Build & Deploy Worker
15 | uses: cloudflare/wrangler-action@v3
16 | with:
17 | apiToken: ${{ secrets.CF_API_TOKEN }}
18 | accountId: ${{ secrets.CF_ACCOUNT_ID }}
19 |
--------------------------------------------------------------------------------
/api/handler.mjs:
--------------------------------------------------------------------------------
1 | import worker from "../src/worker.mjs";
2 |
3 | export default worker.fetch;
4 |
5 | export const config = {
6 | runtime: "edge",
7 | // Available languages and regions for Google AI Studio and Gemini API
8 | // https://ai.google.dev/gemini-api/docs/available-regions#available_regions
9 | // Vercel Edge Network Regions
10 | // https://vercel.com/docs/edge-network/regions#region-list
11 | regions: [
12 | "arn1",
13 | "bom1",
14 | "cdg1",
15 | "cle1",
16 | "cpt1",
17 | "dub1",
18 | "fra1",
19 | "gru1",
20 | //"hkg1",
21 | "hnd1",
22 | "iad1",
23 | "icn1",
24 | "kix1",
25 | "pdx1",
26 | "sfo1",
27 | "sin1",
28 | "syd1",
29 | ],
30 | };
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 PublicAffairs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/readme.MD:
--------------------------------------------------------------------------------
1 | ## Why
2 |
3 | The Gemini API is [free](https://ai.google.dev/pricing "limits applied!"),
4 | but there are many tools that work exclusively with the OpenAI API.
5 |
6 | This project provides a personal OpenAI-compatible endpoint for free.
7 |
8 |
9 | ## Serverless?
10 |
11 | Although it runs in the cloud, it does not require server maintenance.
12 | It can be easily deployed to various providers for free
13 | (with generous limits suitable for personal use).
14 |
15 | > [!TIP]
16 | > Running the proxy endpoint locally is also an option,
17 | > though it's more appropriate for development use.
18 |
19 |
20 | ## How to start
21 |
22 | You will need a personal Google [API key](https://makersuite.google.com/app/apikey).
23 |
24 | > [!IMPORTANT]
25 | > Even if you are located outside of the [supported regions](https://ai.google.dev/gemini-api/docs/available-regions#available_regions),
26 | > it is still possible to acquire one using a VPN.
27 |
28 | Deploy the project to one of the providers, using the instructions below.
29 | You will need to set up an account there.
30 |
31 | If you opt for “button-deploy”, you'll be guided through the process of forking the repository first,
32 | which is necessary for continuous integration (CI).
33 |
34 |
35 | ### Deploy with Vercel
36 |
37 | [](https://vercel.com/new/clone?repository-url=https://github.com/PublicAffairs/openai-gemini&repository-name=my-openai-gemini)
38 | - Alternatively can be deployed with [cli](https://vercel.com/docs/cli):
39 | `vercel deploy`
40 | - Serve locally: `vercel dev`
41 | - Vercel _Functions_ [limitations](https://vercel.com/docs/functions/limitations) (with _Edge_ runtime)
42 |
43 |
44 | ### Deploy to Netlify
45 |
46 | [](https://app.netlify.com/start/deploy?repository=https://github.com/PublicAffairs/openai-gemini&integrationName=integrationName&integrationSlug=integrationSlug&integrationDescription=integrationDescription)
47 | - Alternatively can be deployed with [cli](https://docs.netlify.com/cli/get-started/):
48 | `netlify deploy`
49 | - Serve locally: `netlify dev`
50 | - Two different api bases provided:
51 | - `/v1` (e.g. `/v1/chat/completions` endpoint)
52 | _Functions_ [limits](https://docs.netlify.com/functions/get-started/?fn-language=js#synchronous-function-2)
53 | - `/edge/v1`
54 | _Edge functions_ [limits](https://docs.netlify.com/edge-functions/limits/)
55 |
56 |
57 | ### Deploy to Cloudflare
58 |
59 | [](https://deploy.workers.cloudflare.com/?url=https://github.com/PublicAffairs/openai-gemini)
60 | - Alternatively can be deployed manually pasting content of [`src/worker.mjs`](src/worker.mjs)
61 | to https://workers.cloudflare.com/playground (see there `Deploy` button).
62 | - Alternatively can be deployed with [cli](https://developers.cloudflare.com/workers/wrangler/):
63 | `wrangler deploy`
64 | - Serve locally: `wrangler dev`
65 | - _Worker_ [limits](https://developers.cloudflare.com/workers/platform/limits/#worker-limits)
66 |
67 |
68 | ### Deploy to Deno
69 |
70 | See details [here](https://github.com/PublicAffairs/openai-gemini/discussions/19).
71 |
72 |
73 | ### Serve locally - with Node, Deno, Bun
74 |
75 | Only for Node: `npm install`.
76 |
77 | Then `npm run start` / `npm run start:deno` / `npm run start:bun`.
78 |
79 |
80 | #### Dev mode (watch source changes)
81 |
82 | Only for Node: `npm install --include=dev`
83 |
84 | Then: `npm run dev` / `npm run dev:deno` / `npm run dev:bun`.
85 |
86 |
87 | ## How to use
88 | If you open your newly-deployed site in a browser, you will only see a `404 Not Found` message. This is expected, as the API is not designed for direct browser access.
89 | To utilize it, you should enter your API address and your Gemini API key into the corresponding fields in your software settings.
90 |
91 | > [!NOTE]
92 | > Not all software tools allow overriding the OpenAI endpoint, but many do
93 | > (however these settings can sometimes be deeply hidden).
94 |
95 | Typically, you should specify the API base in this format:
96 | `https://my-super-proxy.vercel.app/v1`
97 |
98 | The relevant field may be labeled as "_OpenAI proxy_".
99 | You might need to look under "_Advanced settings_" or similar sections.
100 | Alternatively, it could be in some config file (check the relevant documentation for details).
101 |
102 | For some command-line tools, you may need to set an environment variable, _e.g._:
103 | ```sh
104 | OPENAI_BASE_URL="https://my-super-proxy.vercel.app/v1"
105 | ```
106 | _..or_:
107 | ```sh
108 | OPENAI_API_BASE="https://my-super-proxy.vercel.app/v1"
109 | ```
110 |
111 |
112 | ## Models
113 |
114 | Requests use the specified [model] if its name starts with "gemini-", "learnlm-",
115 | or "models/". Otherwise, these defaults apply:
116 |
117 | - `chat/completions`: `gemini-1.5-pro-latest`
118 | - `embeddings`: `text-embedding-004`
119 |
120 | [model]: https://ai.google.dev/gemini-api/docs/models/gemini
121 |
122 |
123 | ## Media
124 |
125 | [Vision] and [audio] input supported as per OpenAI [specs].
126 | Implemented via [`inlineData`](https://ai.google.dev/api/caching#Part).
127 |
128 | [vision]: https://platform.openai.com/docs/guides/vision
129 | [audio]: https://platform.openai.com/docs/guides/audio?audio-generation-quickstart-example=audio-in
130 | [specs]: https://platform.openai.com/docs/api-reference/chat/create
131 |
132 | ---
133 |
134 | ## Supported API endpoints and applicable parameters
135 |
136 | - [x] `chat/completions`
137 |
138 | Currently, most of the parameters that are applicable to both APIs have been implemented,
139 | with the exception of function calls.
140 |
141 |
142 | - [x] `messages`
143 | - [x] `content`
144 | - [x] `role`
145 | - [x] "system" (=>`system_instruction`)
146 | - [x] "user"
147 | - [x] "assistant"
148 | - [ ] "tool" (v1beta)
149 | - [ ] `name`
150 | - [ ] `tool_calls`
151 | - [x] `model`
152 | - [x] `frequency_penalty`
153 | - [ ] `logit_bias`
154 | - [ ] `logprobs`
155 | - [ ] `top_logprobs`
156 | - [x] `max_tokens`, `max_completion_tokens`
157 | - [x] `n` (`candidateCount` <8, not for streaming)
158 | - [x] `presence_penalty`
159 | - [x] `response_format`
160 | - [x] "json_object"
161 | - [x] "json_schema" (a select subset of an OpenAPI 3.0 schema object)
162 | - [x] "text"
163 | - [ ] `seed`
164 | - [x] `stop`: string|array (`stopSequences` [1,5])
165 | - [x] `stream`
166 | - [x] `stream_options`
167 | - [x] `include_usage`
168 | - [x] `temperature` (0.0..2.0 for OpenAI, but Gemini supports up to infinity)
169 | - [x] `top_p`
170 | - [ ] `tools` (v1beta)
171 | - [ ] `tool_choice` (v1beta)
172 | - [ ] `parallel_tool_calls`
173 |
174 |
175 | - [ ] `completions`
176 | - [x] `embeddings`
177 | - [x] `models`
178 |
--------------------------------------------------------------------------------
/src/worker.mjs:
--------------------------------------------------------------------------------
1 | import { Buffer } from "node:buffer";
2 |
3 | export default {
4 | async fetch (request, env) {
5 | if (request.method === "OPTIONS") {
6 | return handleOPTIONS();
7 | }
8 | const errHandler = (err) => {
9 | console.error(err);
10 | return new Response(err.message, fixCors({ status: err.status ?? 500 }));
11 | };
12 | try {
13 | const auth = request.headers.get("Authorization");
14 | const apiKey = auth?.split(" ")[1];
15 | const assert = (success) => {
16 | if (!success) {
17 | throw new HttpError("The specified HTTP method is not allowed for the requested resource", 400);
18 | }
19 | };
20 | const { pathname } = new URL(request.url);
21 |
22 | // 克隆请求以便我们可以多次读取body
23 | const requestClone = request.clone();
24 |
25 | // 生成请求ID
26 | const requestId = crypto.randomUUID();
27 |
28 | // 记录请求
29 | if (env.REQUEST_LOG) {
30 | recordRequest(requestClone, requestId, env.REQUEST_LOG).catch(console.error);
31 | }
32 |
33 | switch (true) {
34 | case pathname.endsWith("/chat/completions"):
35 | assert(request.method === "POST");
36 | return handleCompletions(await request.json(), apiKey)
37 | .catch(errHandler);
38 | case pathname.endsWith("/embeddings"):
39 | assert(request.method === "POST");
40 | return handleEmbeddings(await request.json(), apiKey)
41 | .catch(errHandler);
42 | case pathname.endsWith("/models"):
43 | assert(request.method === "GET");
44 | return handleModels(apiKey)
45 | .catch(errHandler);
46 | default:
47 | throw new HttpError("404 Not Found", 404);
48 | }
49 | } catch (err) {
50 | return errHandler(err);
51 | }
52 | }
53 | };
54 |
55 | class HttpError extends Error {
56 | constructor(message, status) {
57 | super(message);
58 | this.name = this.constructor.name;
59 | this.status = status;
60 | }
61 | }
62 |
63 | const fixCors = ({ headers, status, statusText }) => {
64 | headers = new Headers(headers);
65 | headers.set("Access-Control-Allow-Origin", "*");
66 | return { headers, status, statusText };
67 | };
68 |
69 | const handleOPTIONS = async () => {
70 | return new Response(null, {
71 | headers: {
72 | "Access-Control-Allow-Origin": "*",
73 | "Access-Control-Allow-Methods": "*",
74 | "Access-Control-Allow-Headers": "*",
75 | }
76 | });
77 | };
78 |
79 | const BASE_URL = "https://generativelanguage.googleapis.com";
80 | const API_VERSION = "v1beta";
81 |
82 | const API_CLIENT = "genai-js/0.21.0"; // npm view @google/generative-ai version
83 | const makeHeaders = (apiKey, more) => ({
84 | "x-goog-api-client": API_CLIENT,
85 | ...(apiKey && { "x-goog-api-key": apiKey }),
86 | ...more
87 | });
88 |
89 | async function handleModels (apiKey) {
90 | const response = await fetch(`${BASE_URL}/${API_VERSION}/models`, {
91 | headers: makeHeaders(apiKey),
92 | });
93 | let { body } = response;
94 | if (response.ok) {
95 | const { models } = JSON.parse(await response.text());
96 | body = JSON.stringify({
97 | object: "list",
98 | data: models.map(({ name }) => ({
99 | id: name.replace("models/", ""),
100 | object: "model",
101 | created: 0,
102 | owned_by: "",
103 | })),
104 | }, null, " ");
105 | }
106 | return new Response(body, fixCors(response));
107 | }
108 |
109 | const DEFAULT_EMBEDDINGS_MODEL = "text-embedding-004";
110 | async function handleEmbeddings (req, apiKey) {
111 | if (typeof req.model !== "string") {
112 | throw new HttpError("model is not specified", 400);
113 | }
114 | if (!Array.isArray(req.input)) {
115 | req.input = [ req.input ];
116 | }
117 | let model;
118 | if (req.model.startsWith("models/")) {
119 | model = req.model;
120 | } else {
121 | req.model = DEFAULT_EMBEDDINGS_MODEL;
122 | model = "models/" + req.model;
123 | }
124 | const response = await fetch(`${BASE_URL}/${API_VERSION}/${model}:batchEmbedContents`, {
125 | method: "POST",
126 | headers: makeHeaders(apiKey, { "Content-Type": "application/json" }),
127 | body: JSON.stringify({
128 | "requests": req.input.map(text => ({
129 | model,
130 | content: { parts: { text } },
131 | outputDimensionality: req.dimensions,
132 | }))
133 | })
134 | });
135 | let { body } = response;
136 | if (response.ok) {
137 | const { embeddings } = JSON.parse(await response.text());
138 | body = JSON.stringify({
139 | object: "list",
140 | data: embeddings.map(({ values }, index) => ({
141 | object: "embedding",
142 | index,
143 | embedding: values,
144 | })),
145 | model: req.model,
146 | }, null, " ");
147 | }
148 | return new Response(body, fixCors(response));
149 | }
150 |
151 | const DEFAULT_MODEL = "gemini-1.5-pro-latest";
152 | async function handleCompletions (req, apiKey) {
153 | let model = DEFAULT_MODEL;
154 | switch(true) {
155 | case typeof req.model !== "string":
156 | break;
157 | case req.model.startsWith("models/"):
158 | model = req.model.substring(7);
159 | break;
160 | case req.model.startsWith("gemini-"):
161 | case req.model.startsWith("learnlm-"):
162 | model = req.model;
163 | }
164 | const TASK = req.stream ? "streamGenerateContent" : "generateContent";
165 | let url = `${BASE_URL}/${API_VERSION}/models/${model}:${TASK}`;
166 | if (req.stream) { url += "?alt=sse"; }
167 | const response = await fetch(url, {
168 | method: "POST",
169 | headers: makeHeaders(apiKey, { "Content-Type": "application/json" }),
170 | body: JSON.stringify(await transformRequest(req)), // try
171 | });
172 |
173 | let body = response.body;
174 | if (response.ok) {
175 | let id = generateChatcmplId(); //"chatcmpl-8pMMaqXMK68B3nyDBrapTDrhkHBQK";
176 | if (req.stream) {
177 | body = response.body
178 | .pipeThrough(new TextDecoderStream())
179 | .pipeThrough(new TransformStream({
180 | transform: parseStream,
181 | flush: parseStreamFlush,
182 | buffer: "",
183 | }))
184 | .pipeThrough(new TransformStream({
185 | transform: toOpenAiStream,
186 | flush: toOpenAiStreamFlush,
187 | streamIncludeUsage: req.stream_options?.include_usage,
188 | model, id, last: [],
189 | }))
190 | .pipeThrough(new TextEncoderStream());
191 | } else {
192 | body = await response.text();
193 | body = processCompletionsResponse(JSON.parse(body), model, id);
194 | }
195 | }
196 | return new Response(body, fixCors(response));
197 | }
198 |
199 | const harmCategory = [
200 | "HARM_CATEGORY_HATE_SPEECH",
201 | "HARM_CATEGORY_SEXUALLY_EXPLICIT",
202 | "HARM_CATEGORY_DANGEROUS_CONTENT",
203 | "HARM_CATEGORY_HARASSMENT",
204 | "HARM_CATEGORY_CIVIC_INTEGRITY",
205 | ];
206 | const safetySettings = harmCategory.map(category => ({
207 | category,
208 | threshold: "BLOCK_NONE",
209 | }));
210 | const fieldsMap = {
211 | stop: "stopSequences",
212 | n: "candidateCount", // not for streaming
213 | max_tokens: "maxOutputTokens",
214 | max_completion_tokens: "maxOutputTokens",
215 | temperature: "temperature",
216 | top_p: "topP",
217 | top_k: "topK", // non-standard
218 | frequency_penalty: "frequencyPenalty",
219 | presence_penalty: "presencePenalty",
220 | };
221 | const transformConfig = (req) => {
222 | let cfg = {};
223 | //if (typeof req.stop === "string") { req.stop = [req.stop]; } // no need
224 | for (let key in req) {
225 | const matchedKey = fieldsMap[key];
226 | if (matchedKey) {
227 | cfg[matchedKey] = req[key];
228 | }
229 | }
230 | if (req.response_format) {
231 | switch(req.response_format.type) {
232 | case "json_schema":
233 | cfg.responseSchema = req.response_format.json_schema?.schema;
234 | if (cfg.responseSchema && "enum" in cfg.responseSchema) {
235 | cfg.responseMimeType = "text/x.enum";
236 | break;
237 | }
238 | // eslint-disable-next-line no-fallthrough
239 | case "json_object":
240 | cfg.responseMimeType = "application/json";
241 | break;
242 | case "text":
243 | cfg.responseMimeType = "text/plain";
244 | break;
245 | default:
246 | throw new HttpError("Unsupported response_format.type", 400);
247 | }
248 | }
249 | return cfg;
250 | };
251 |
252 | const parseImg = async (url) => {
253 | let mimeType, data;
254 | if (url.startsWith("http://") || url.startsWith("https://")) {
255 | try {
256 | const response = await fetch(url);
257 | if (!response.ok) {
258 | throw new Error(`${response.status} ${response.statusText} (${url})`);
259 | }
260 | mimeType = response.headers.get("content-type");
261 | data = Buffer.from(await response.arrayBuffer()).toString("base64");
262 | } catch (err) {
263 | throw new Error("Error fetching image: " + err.toString());
264 | }
265 | } else {
266 | const match = url.match(/^data:(?.*?)(;base64)?,(?.*)$/);
267 | if (!match) {
268 | throw new Error("Invalid image data: " + url);
269 | }
270 | ({ mimeType, data } = match.groups);
271 | }
272 | return {
273 | inlineData: {
274 | mimeType,
275 | data,
276 | },
277 | };
278 | };
279 |
280 | const transformMsg = async ({ role, content }) => {
281 | const parts = [];
282 | if (!Array.isArray(content)) {
283 | // system, user: string
284 | // assistant: string or null (Required unless tool_calls is specified.)
285 | parts.push({ text: content });
286 | return { role, parts };
287 | }
288 | // user:
289 | // An array of content parts with a defined type.
290 | // Supported options differ based on the model being used to generate the response.
291 | // Can contain text, image, or audio inputs.
292 | for (const item of content) {
293 | switch (item.type) {
294 | case "text":
295 | parts.push({ text: item.text });
296 | break;
297 | case "image_url":
298 | parts.push(await parseImg(item.image_url.url));
299 | break;
300 | case "input_audio":
301 | parts.push({
302 | inlineData: {
303 | mimeType: "audio/" + item.input_audio.format,
304 | data: item.input_audio.data,
305 | }
306 | });
307 | break;
308 | default:
309 | throw new TypeError(`Unknown "content" item type: "${item.type}"`);
310 | }
311 | }
312 | if (content.every(item => item.type === "image_url")) {
313 | parts.push({ text: "" }); // to avoid "Unable to submit request because it must have a text parameter"
314 | }
315 | return { role, parts };
316 | };
317 |
318 | const transformMessages = async (messages) => {
319 | if (!messages) { return; }
320 | const contents = [];
321 | let system_instruction;
322 | for (const item of messages) {
323 | if (item.role === "system") {
324 | delete item.role;
325 | system_instruction = await transformMsg(item);
326 | } else {
327 | item.role = item.role === "assistant" ? "model" : "user";
328 | contents.push(await transformMsg(item));
329 | }
330 | }
331 | if (system_instruction && contents.length === 0) {
332 | contents.push({ role: "model", parts: { text: " " } });
333 | }
334 | //console.info(JSON.stringify(contents, 2));
335 | return { system_instruction, contents };
336 | };
337 |
338 | const transformRequest = async (req) => ({
339 | ...await transformMessages(req.messages),
340 | safetySettings,
341 | generationConfig: transformConfig(req),
342 | });
343 |
344 | const generateChatcmplId = () => {
345 | const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
346 | const randomChar = () => characters[Math.floor(Math.random() * characters.length)];
347 | return "chatcmpl-" + Array.from({ length: 29 }, randomChar).join("");
348 | };
349 |
350 | const reasonsMap = { //https://ai.google.dev/api/rest/v1/GenerateContentResponse#finishreason
351 | //"FINISH_REASON_UNSPECIFIED": // Default value. This value is unused.
352 | "STOP": "stop",
353 | "MAX_TOKENS": "length",
354 | "SAFETY": "content_filter",
355 | "RECITATION": "content_filter",
356 | //"OTHER": "OTHER",
357 | // :"function_call",
358 | };
359 | const SEP = "\n\n|>";
360 | const transformCandidates = (key, cand) => ({
361 | index: cand.index || 0, // 0-index is absent in new -002 models response
362 | [key]: {
363 | role: "assistant",
364 | content: cand.content?.parts.map(p => p.text).join(SEP) },
365 | logprobs: null,
366 | finish_reason: reasonsMap[cand.finishReason] || cand.finishReason,
367 | });
368 | const transformCandidatesMessage = transformCandidates.bind(null, "message");
369 | const transformCandidatesDelta = transformCandidates.bind(null, "delta");
370 |
371 | const transformUsage = (data) => ({
372 | completion_tokens: data.candidatesTokenCount,
373 | prompt_tokens: data.promptTokenCount,
374 | total_tokens: data.totalTokenCount
375 | });
376 |
377 | const processCompletionsResponse = (data, model, id) => {
378 | return JSON.stringify({
379 | id,
380 | choices: data.candidates.map(transformCandidatesMessage),
381 | created: Math.floor(Date.now()/1000),
382 | model,
383 | //system_fingerprint: "fp_69829325d0",
384 | object: "chat.completion",
385 | usage: transformUsage(data.usageMetadata),
386 | });
387 | };
388 |
389 | const responseLineRE = /^data: (.*)(?:\n\n|\r\r|\r\n\r\n)/;
390 | async function parseStream (chunk, controller) {
391 | chunk = await chunk;
392 | if (!chunk) { return; }
393 | this.buffer += chunk;
394 | do {
395 | const match = this.buffer.match(responseLineRE);
396 | if (!match) { break; }
397 | controller.enqueue(match[1]);
398 | this.buffer = this.buffer.substring(match[0].length);
399 | } while (true); // eslint-disable-line no-constant-condition
400 | }
401 | async function parseStreamFlush (controller) {
402 | if (this.buffer) {
403 | console.error("Invalid data:", this.buffer);
404 | controller.enqueue(this.buffer);
405 | }
406 | }
407 |
408 | function transformResponseStream (data, stop, first) {
409 | const item = transformCandidatesDelta(data.candidates[0]);
410 | if (stop) { item.delta = {}; } else { item.finish_reason = null; }
411 | if (first) { item.delta.content = ""; } else { delete item.delta.role; }
412 | const output = {
413 | id: this.id,
414 | choices: [item],
415 | created: Math.floor(Date.now()/1000),
416 | model: this.model,
417 | //system_fingerprint: "fp_69829325d0",
418 | object: "chat.completion.chunk",
419 | };
420 | if (data.usageMetadata && this.streamIncludeUsage) {
421 | output.usage = stop ? transformUsage(data.usageMetadata) : null;
422 | }
423 | return "data: " + JSON.stringify(output) + delimiter;
424 | }
425 | const delimiter = "\n\n";
426 | async function toOpenAiStream (chunk, controller) {
427 | const transform = transformResponseStream.bind(this);
428 | const line = await chunk;
429 | if (!line) { return; }
430 | let data;
431 | try {
432 | data = JSON.parse(line);
433 | } catch (err) {
434 | console.error(line);
435 | console.error(err);
436 | const length = this.last.length || 1; // at least 1 error msg
437 | const candidates = Array.from({ length }, (_, index) => ({
438 | finishReason: "error",
439 | content: { parts: [{ text: err }] },
440 | index,
441 | }));
442 | data = { candidates };
443 | }
444 | const cand = data.candidates[0];
445 | console.assert(data.candidates.length === 1, "Unexpected candidates count: %d", data.candidates.length);
446 | cand.index = cand.index || 0; // absent in new -002 models response
447 | if (!this.last[cand.index]) {
448 | controller.enqueue(transform(data, false, "first"));
449 | }
450 | this.last[cand.index] = data;
451 | if (cand.content) { // prevent empty data (e.g. when MAX_TOKENS)
452 | controller.enqueue(transform(data));
453 | }
454 | }
455 | async function toOpenAiStreamFlush (controller) {
456 | const transform = transformResponseStream.bind(this);
457 | if (this.last.length > 0) {
458 | for (const data of this.last) {
459 | controller.enqueue(transform(data, "stop"));
460 | }
461 | controller.enqueue("data: [DONE]" + delimiter);
462 | }
463 | }
464 |
465 | async function recordRequest(request, requestId, kv) {
466 | try {
467 | const timestamp = new Date().toISOString();
468 | const url = new URL(request.url);
469 | const headers = Object.fromEntries([...request.headers]);
470 |
471 | let requestBody = null;
472 | if (request.method === "POST") {
473 | requestBody = await request.json();
474 | }
475 |
476 | const logEntry = {
477 | id: requestId,
478 | timestamp,
479 | method: request.method,
480 | path: url.pathname,
481 | headers,
482 | body: requestBody
483 | };
484 |
485 | // 使用时间戳作为排序键
486 | await kv.put(`request:${timestamp}:${requestId}`, JSON.stringify(logEntry), {
487 | expirationTtl: 60 * 60 * 24 * 30 // 30天过期
488 | });
489 | } catch (error) {
490 | console.error('Error recording request:', error);
491 | }
492 | }
493 |
--------------------------------------------------------------------------------