├── 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 | [![Deploy with Vercel](https://vercel.com/button)](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 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](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 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](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 | --------------------------------------------------------------------------------