commit acb1e552cd8e9a0d1d0c6286eeb47a4c2154666e Author: wehub-resource-sync Date: Mon Jul 13 12:22:23 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6ae0591 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_style = space diff --git a/.github/workflows/cf-deploy.yml b/.github/workflows/cf-deploy.yml new file mode 100644 index 0000000..4a9dbf4 --- /dev/null +++ b/.github/workflows/cf-deploy.yml @@ -0,0 +1,18 @@ +name: Deploy Worker +on: + push: + branches: + - main + pull_request: + repository_dispatch: +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy + steps: + - uses: actions/checkout@v4 + - name: Build & Deploy Worker + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ed593f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.netlify +.vercel +.wrangler diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f18a28b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 PublicAffairs + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb5d090 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +## Why + +The Gemini API has [Free](https://ai.google.dev/gemini-api/docs/pricing#free) Tier +with *generous limits*, but there are still many tools that work exclusively with the OpenAI API. + +This project provides a personal OpenAI-compatible endpoint for free. + + +## Serverless? + +Although it runs in the cloud, it does not require server maintenance. +It can be easily deployed to various providers for free +(with generous limits suitable for personal use). + +> [!TIP] +> Running the proxy endpoint locally is also an [option](#serve-locally---with-node-deno-bun)! + + +## How to start + +You will need a personal Google [API key](https://aistudio.google.com/app/api-keys). + +> [!IMPORTANT] +> Even if you are located outside of the [supported regions](https://ai.google.dev/gemini-api/docs/available-regions#available_regions), +> it is still possible to acquire one using a VPN. + +Deploy the project to one of the providers, using the instructions below. +You will need to set up an account there. + +If you opt for “button-deploy”, you'll be guided through the process of forking the repository first, +which is necessary for continuous integration (CI). + + +### Deploy with Vercel + + [![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) +- Alternatively can be deployed with [cli](https://vercel.com/docs/cli): + `vercel deploy` +- Serve locally: `vercel dev` +- Vercel _Functions_ [limitations](https://vercel.com/docs/functions/limitations) (with _Edge_ runtime) + + +### Deploy to Netlify + +[![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) +- Alternatively can be deployed with [cli](https://docs.netlify.com/cli/get-started/): + `netlify deploy` +- Serve locally: `netlify dev` +- Two different api bases provided: + - `/v1` (e.g. `/v1/chat/completions` endpoint) + _Functions_ [limits](https://docs.netlify.com/build/functions/get-started/#synchronous-function) + - `/edge/v1` + _Edge functions_ [limits](https://docs.netlify.com/build/edge-functions/limits/) + + +### Deploy to Cloudflare + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PublicAffairs/openai-gemini) +- Alternatively can be deployed manually pasting content of [`src/worker.mjs`](src/worker.mjs) + to https://workers.cloudflare.com/playground (see there `Deploy` button). +- Alternatively can be deployed with [cli](https://developers.cloudflare.com/workers/wrangler/): + `wrangler deploy` +- Serve locally: `wrangler dev` +- _Worker_ [limits](https://developers.cloudflare.com/workers/platform/limits/#worker-limits) + + +### Deploy to Deno + +See details [here](https://github.com/PublicAffairs/openai-gemini/discussions/19). + + +### Serve locally - with Node, Deno, Bun + +Only for Node: `npm install`. + +Then `npm run start` / `npm run start:deno` / `npm run start:bun`. + + +#### Dev mode (watch source changes) + +Only for Node: `npm install --include=dev` + +Then: `npm run dev` / `npm run dev:deno` / `npm run dev:bun`. + + +## How to use +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. +To utilize it, you should enter your API address and your Gemini API key into the corresponding fields in your software settings. + +> [!NOTE] +> Not all software tools allow overriding the OpenAI endpoint, but many do +> (however these settings can sometimes be deeply hidden). + +Typically, you should specify the API base in this format: +`https://my-super-proxy.vercel.app/v1` + +The relevant field may be labeled as "_OpenAI proxy_". +You might need to look under "_Advanced settings_" or similar sections. +Alternatively, it could be in some config file (check the relevant documentation for details). + +For some command-line tools, you may need to set an environment variable, _e.g._: +```sh +OPENAI_BASE_URL="https://my-super-proxy.vercel.app/v1" +``` +_..or_: +```sh +OPENAI_API_BASE="https://my-super-proxy.vercel.app/v1" +``` + + +## Models + +Requests use the specified [model] if its name starts with "gemini-", "gemma-", or "models/". +Otherwise, these defaults apply: + +- `chat/completions`: `gemini-flash-latest` +- `embeddings`: `gemini-embedding-001` + +[model]: https://ai.google.dev/gemini-api/docs/models + + +## Built-in tools + +To use the **web search** tool, append ":search" to the model name +(e.g., "gemini-2.5-flash:search"). + +Note: The `annotations` message property is not implemented. + + +## Media + +[Vision] and [audio] input supported as per OpenAI [specs]. +Implemented via [`inlineData`](https://ai.google.dev/api/caching#Part). + +[vision]: https://platform.openai.com/docs/guides/images-vision?api-mode=chat&format=url#giving-a-model-images-as-input +[audio]: https://platform.openai.com/docs/guides/audio?example=audio-in&lang=curl#add-audio-to-your-existing-application +[specs]: https://platform.openai.com/docs/api-reference/chat/create + + +## Gemini-specific functions + +There are several features supported by Gemini that are not available in OpenAI models +but can be enabled using the `extra_body` field. +The most notable of these is [`thinking_config`](https://ai.google.dev/gemini-api/docs/openai#thinking). + +For more details, refer to the [Gemini API docs](https://ai.google.dev/gemini-api/docs/openai#extra-body). + +--- + +## Supported API endpoints and applicable parameters + +- [x] `chat/completions` + + Currently, most of the parameters that are applicable to both APIs have been implemented. +
+ + - [x] `messages` + - [x] `content` + - [x] `role` + - [x] "system" (=>`system_instruction`) + - [x] "user" + - [x] "assistant" + - [x] "tool" + - [x] `tool_calls` + - [x] `model` + - [x] `frequency_penalty` + - [ ] `logit_bias` + - [ ] `logprobs` + - [ ] `top_logprobs` + - [x] `max_tokens`, `max_completion_tokens` + - [x] `n` (`candidateCount` <8, not for streaming) + - [x] `presence_penalty` + - [x] `reasoning_effort` + - [x] `response_format` + - [x] "json_object" + - [x] "json_schema" (a select subset of an OpenAPI 3.0 schema object) + - [x] "text" + - [x] `seed` + - [x] `stop`: string|array (`stopSequences` [1,5]) + - [x] `stream` + - [x] `stream_options` + - [x] `include_usage` + - [x] `temperature` (0.0..2.0 for OpenAI, but Gemini supports up to infinity) + - [x] `top_p` + - [x] `tools` + - [x] `tool_choice` + - [ ] `parallel_tool_calls` (is always active in Gemini) + - [x] [`extra_body`](#gemini-specific-functions) + +
+- [ ] `completions` +- [x] `embeddings` + - [x] `dimensions` +- [x] `models` diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..e8d207d --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`PublicAffairs/openai-gemini` +- 原始仓库:https://github.com/PublicAffairs/openai-gemini +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/api/handler.mjs b/api/handler.mjs new file mode 100644 index 0000000..c772206 --- /dev/null +++ b/api/handler.mjs @@ -0,0 +1,32 @@ +import worker from "../src/worker.mjs"; + +export default worker.fetch; + +export const config = { + runtime: "edge", + // Available languages and regions for Google AI Studio and Gemini API + // https://ai.google.dev/gemini-api/docs/available-regions#available_regions + // Vercel Edge Network Regions + // https://vercel.com/docs/regions#region-list + regions: [ + "arn1", + "bom1", + "cdg1", + "cle1", + "cpt1", + "dub1", + "dxb1", + "fra1", + "gru1", + //"hkg1", + "hnd1", + "iad1", + "icn1", + "kix1", + "lhr1", + "pdx1", + "sfo1", + "sin1", + "syd1", + ], +}; diff --git a/bun.mjs b/bun.mjs new file mode 100644 index 0000000..99a901d --- /dev/null +++ b/bun.mjs @@ -0,0 +1,8 @@ +import worker from "./src/worker.mjs"; + +worker.port = +(process.env.PORT || 8080); +const environment = process.env.NODE_ENV ?? "development"; +worker.development = process.env.NODE_ENV==="development"; + +const server = Bun.serve(worker); +console.log(`[${environment}] Listening on ${server.url.origin}`); diff --git a/deno.mjs b/deno.mjs new file mode 100644 index 0000000..3f032ab --- /dev/null +++ b/deno.mjs @@ -0,0 +1,8 @@ +//deprecated: +//import {serve} from "https://deno.land/std/http/mod.ts" + +import worker from "./src/worker.mjs"; + +const port = +(Deno.env.get("PORT") ?? 8080); + +Deno.serve({port}, worker.fetch); diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..bb21b47 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,5 @@ +[build] + publish = "public/" + +[functions] + node_bundler = "esbuild" \ No newline at end of file diff --git a/netlify/edge-functions/handler.mjs b/netlify/edge-functions/handler.mjs new file mode 100644 index 0000000..175ad3b --- /dev/null +++ b/netlify/edge-functions/handler.mjs @@ -0,0 +1,5 @@ +export const config = { path: "/edge/*" }; + +import worker from "../../src/worker.mjs"; + +export default worker.fetch; diff --git a/netlify/functions/handler.mjs b/netlify/functions/handler.mjs new file mode 100644 index 0000000..c23543d --- /dev/null +++ b/netlify/functions/handler.mjs @@ -0,0 +1,5 @@ +export const config = { path: "/*" }; + +import worker from "../../src/worker.mjs"; + +export default worker.fetch; diff --git a/node.mjs b/node.mjs new file mode 100644 index 0000000..131352b --- /dev/null +++ b/node.mjs @@ -0,0 +1,11 @@ +import { createServerAdapter } from "@whatwg-node/server"; +import { createServer } from "node:http"; +import worker from "./src/worker.mjs"; + +const port = +(process.env.PORT || 8080); + +const serverAdapter = createServerAdapter(worker.fetch); +const server = createServer(serverAdapter); +server.listen(port, () => { + console.log("Listening on:", server.address()); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5545433 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,481 @@ +{ + "name": "openai-gemini", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@whatwg-node/server": "0.9" + }, + "devDependencies": { + "nodemon": "^3.1.7" + } + }, + "node_modules/@kamilkisiela/fast-url-parser": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@kamilkisiela/fast-url-parser/-/fast-url-parser-1.1.4.tgz", + "integrity": "sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew==", + "license": "MIT" + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.9.22", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.22.tgz", + "integrity": "sha512-+RIBffgoaRlWV9cKV6wAX71sbeoU2APOI3G13ZRMkabYHwkvDMeZDTyxJcsMXA5CpieJ7NFXF9Xyu72jwvdzqA==", + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.5.27", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.5.27", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.5.27.tgz", + "integrity": "sha512-0OaMj5W4fzWimRSFq07qFiWfquaUMNB+695GwE76LYKVuah+jwCdzSgsIOtwPkiyJ35w0XGhXmJPiIJCdLwopg==", + "license": "MIT", + "dependencies": { + "@kamilkisiela/fast-url-parser": "^1.1.4", + "busboy": "^1.6.0", + "fast-querystring": "^1.1.1", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.9.50", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.9.50.tgz", + "integrity": "sha512-7Vd8k6iu+ps8bkZT+Y/wPm42EDh8KojAL+APKa79mntgkyPtdq0r1//CO+0eYqQBz6HGrDxHRT4KChSOy4jGIw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/fetch": "^0.9.22", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", + "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "license": "0BSD" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7711d46 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "dependencies": { + "@whatwg-node/server": "0.9" + }, + "devDependencies": { + "nodemon": "3" + }, + "scripts": { + "start": "node node.mjs", + "dev": "nodemon --watch node.mjs --watch src/*.mjs node.mjs", + "start:deno": "deno --allow-env --allow-net deno.mjs", + "dev:deno": "deno --watch --allow-env --allow-net deno.mjs", + "start:bun": "bun bun.mjs", + "dev:bun": "bun --watch bun.mjs" + } +} diff --git a/public/empty.html b/public/empty.html new file mode 100644 index 0000000..e69de29 diff --git a/src/worker.mjs b/src/worker.mjs new file mode 100644 index 0000000..4cd47f7 --- /dev/null +++ b/src/worker.mjs @@ -0,0 +1,746 @@ +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"); + const apiKey = auth?.split(" ")[1]; + 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/googleapis/js-genai/blob/main/src/_api_client.ts#L21 +const API_CLIENT = "google-genai-sdk/1.34.0"; // npm view @google/genai 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 = "gemini-embedding-001"; +async function handleEmbeddings (req, apiKey) { + let modelFull, model; + switch (true) { + case typeof req.model !== "string": + throw new HttpError("model is not specified", 400); + case req.model.startsWith("models/"): + modelFull = req.model; + model = modelFull.substring(7); + break; + case req.model.startsWith("gemini-"): + model = req.model; + break; + default: + model = DEFAULT_EMBEDDINGS_MODEL; + } + modelFull ??= "models/" + model; + if (!Array.isArray(req.input)) { + req.input = [ req.input ]; + } + const response = await fetch(`${BASE_URL}/${API_VERSION}/${modelFull}:batchEmbedContents`, { + method: "POST", + headers: makeHeaders(apiKey, { "Content-Type": "application/json" }), + body: JSON.stringify({ + "requests": req.input.map(text => ({ + model: modelFull, + 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, + }, null, " "); + } + return new Response(body, fixCors(response)); +} + +const DEFAULT_MODEL = "gemini-flash-latest"; +async function handleCompletions (req, apiKey) { + let model = req.model; + switch (true) { + case typeof model !== "string": + throw new HttpError("model is not specified", 400); + case model.startsWith("models/"): + model = model.substring(7); + break; + case model.startsWith("gemini-"): + case model.startsWith("gemma-"): + break; + default: + model = DEFAULT_MODEL; + } + let isV3 = model.startsWith("gemini-3"); + let body = await transformRequest(req, isV3); + 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.slice(0,-7); + // eslint-disable-next-line no-fallthrough + case req.model?.includes("-search-preview"): + body.tools ??= []; + body.tools.push({googleSearch: {}}); + } + 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; + delete obj.parameters?.$schema; + 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", +}; +//https://ai.google.dev/gemini-api/docs/openai#thinking +//https://platform.openai.com/docs/api-reference/chat/create#chat_create-reasoning_effort +const thinkingBudgetMap = { + none: 0, + minimal: 1024, + low: 1024, + medium: 8192, + high: 24576, + xhigh: 32768, // 2.5 Pro +}; +const thinkingLevelMap = { + none: "minimal", + xhigh: "high", +}; +const transformConfig = (req, isV3) => { + 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 = + isV3 + ? { thinkingLevel: thinkingLevelMap[req.reasoning_effort] ?? req.reasoning_effort } + : { 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:(?.*?)(;base64)?,(?.*)$/); + 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, extra_content }, 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, + }, + thoughtSignature: extra_content?.google?.thought_signature, + }; + }); + parts.calls = calls; + return parts; +}; + +const transformMsg = async ({ content, extra_content }) => { + const thoughtSignature = extra_content?.google?.thought_signature; + const parts = []; + if (!Array.isArray(content)) { + // system, user: string + // assistant: string or null (Required unless tool_calls is specified.) + parts.push({ text: content, thoughtSignature }); + 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 (thoughtSignature) { + if (parts.length === 1) { + parts[0].thoughtSignature = thoughtSignature; + } else { + parts.push({ text:"", thoughtSignature }); + } + } + 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"); + 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, isV3) => ({ + ...await transformMessages(req.messages), + safetySettings, + generationConfig: transformConfig(req,isV3), + ...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|>"; +function transformCandidates (key, cand) { + const message = { role: "assistant", content: [] }; + let thought_signature; + for (const part of cand.content?.parts ?? []) { + if (part.functionCall) { + const fc = part.functionCall; + message.tool_calls ??= []; + const thought_signature = fc.thoughtSignature; + message.tool_calls.push({ + id: fc.id ?? "call_" + generateId(), + type: "function", + function: { + name: fc.name, + arguments: JSON.stringify(fc.args), + }, + extra_content: thought_signature ? {google: { thought_signature }} : undefined, + }); + } else if (typeof part.text === "string") { + const len = message.content.length; + if (part.thought !== this.isThinking) { + this.isThinking = part.thought; + let prefix; + if (part.thought) { + prefix = "\n"; + } else { + prefix = "\n\n"; + if (len) { + message.content[len-1] = message.content[len-1].trimEnd() + "\n"; + } else { + prefix += "\n"; + } + } + part.text = prefix + part.text; + } else if (len) { + message.content[len-1] += SEP; + } + message.content.push(part.text); + if (thought_signature && part.thoughtSignature) { + throw new Error("Unexpected multiple thoughtSignature"); + } + thought_signature = part.thoughtSignature; + } else { + throw new Error("Unexpected part type: " + JSON.stringify(part,2)); + } + } + message.content = message.content.join("") ?? null; + if (thought_signature) { + message.extra_content = {google: { thought_signature }}; + } + 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 notEmpty = (el) => Object.values(el).some(Boolean) ? el : undefined; +const sum = (...numbers) => numbers.reduce((total, num) => total + (num ?? 0), 0); +const transformUsage = (data) => ({ + completion_tokens: sum(data.candidatesTokenCount, data.toolUsePromptTokenCount, data.thoughtsTokenCount), + prompt_tokens: data.promptTokenCount, + total_tokens: data.totalTokenCount, + completion_tokens_details: notEmpty({ + audio_tokens: data.candidatesTokensDetails + ?.find(el => el.modality === "AUDIO") + ?.tokenCount, + reasoning_tokens: data.thoughtsTokenCount, + }), + prompt_tokens_details: notEmpty({ + audio_tokens: data.promptTokensDetails + ?.find(el => el.modality === "AUDIO") + ?.tokenCount, + cached_tokens: data.cacheTokensDetails + ?.reduce((acc,el) => acc + el.tokenCount, 0), + }), +}); + +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: data.responseId ?? id, + choices: data.candidates.map(transformCandidates.bind({}, "message")), + 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; + } + let obj; + try { + obj = { + id: data.responseId ?? this.id, + choices: data.candidates.map(transformCandidates.bind(this, "delta")), + //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, + }; + } catch (err) { + console.error(err); + controller.enqueue("Unexpected error while handling request: " + err.message); + controller.enqueue("\n\n" + line); + controller.terminate(); + return; + } + 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 ??= 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); + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..6f9052e --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { "source": "/(.*)", "destination": "api/handler" } + ] +} \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..43b9d40 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,4 @@ +name = "gemini" +main = "src/worker.mjs" +compatibility_date = "2024-09-23" +compatibility_flags = [ "nodejs_compat" ]