From de2d970b2069aa6e7b81f1409b2b600bcf5409b0 Mon Sep 17 00:00:00 2001 From: shumengya Date: Wed, 24 Jun 2026 22:10:23 +0800 Subject: [PATCH] chore: sync local changes to Gitea --- .dev.vars.example | 8 + .gitignore | 6 + README.md | 195 +++++ models.json | 1 + package-lock.json | 1587 +++++++++++++++++++++++++++++++++++++ package.json | 18 + scripts/test-api.mjs | 57 ++ src/adapters/anthropic.ts | 502 ++++++++++++ src/adapters/responses.ts | 360 +++++++++ src/auth.ts | 49 ++ src/copilot/models.ts | 23 + src/copilot/routing.ts | 10 + src/copilot/token.ts | 72 ++ src/copilot/upstream.ts | 35 + src/env.ts | 9 + src/handlers/chat.ts | 63 ++ src/index.ts | 14 + src/routes/anthropic.ts | 112 +++ src/routes/openai.ts | 92 +++ tsconfig.json | 16 + wrangler.jsonc | 9 + 21 files changed, 3238 insertions(+) create mode 100644 .dev.vars.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 models.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/test-api.mjs create mode 100644 src/adapters/anthropic.ts create mode 100644 src/adapters/responses.ts create mode 100644 src/auth.ts create mode 100644 src/copilot/models.ts create mode 100644 src/copilot/routing.ts create mode 100644 src/copilot/token.ts create mode 100644 src/copilot/upstream.ts create mode 100644 src/env.ts create mode 100644 src/handlers/chat.ts create mode 100644 src/index.ts create mode 100644 src/routes/anthropic.ts create mode 100644 src/routes/openai.ts create mode 100644 tsconfig.json create mode 100644 wrangler.jsonc diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..12bcf17 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,8 @@ +# Copy to .dev.vars for local development (never commit .dev.vars) +GITHUB_TOKEN=ghu_your_github_oauth_token_or_github_pat_ + +# Optional: override client API key (default in wrangler.jsonc is sk-shumengya666) +# API_KEY=sk-shumengya666 + +# Optional: Copilot Enterprise base URL +# COPILOT_API_BASE=https://api.your-org.githubcopilot.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fc7ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +.dev.vars +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9d2696 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# copilet2api + +Cloudflare Worker that proxies **GitHub Copilot** to **OpenAI** and **Anthropic** compatible HTTP APIs. + +> **Warning:** This uses unofficial Copilot endpoints. GitHub may change or block them at any time. Excessive automated use may trigger abuse detection and limit your Copilot access. Use responsibly. + +## Features + +- OpenAI-compatible: `GET /v1/models`, `POST /v1/chat/completions`, `POST /v1/responses` (streaming supported) +- Models like `gpt-5.4-mini`, `gpt-5.3-codex` auto-route to Copilot **Responses API** when using `/v1/chat/completions` +- Anthropic-compatible: `POST /v1/messages` (streaming supported) +- Client auth via API key (default `sk-shumengya666`, configurable via `API_KEY`) +- GitHub token stored as Worker Secret (`GITHUB_TOKEN`) + +## Quick start + +### 1. Install + +```bash +npm install +``` + +### 2. Configure GitHub token (local) + +```bash +cp .dev.vars.example .dev.vars +# Edit .dev.vars and set GITHUB_TOKEN (ghu_, gho_, or github_pat_ with Copilot access) +``` + +### 3. Run locally + +```bash +npm run dev +``` + +### 4. Deploy + +```bash +npx wrangler secret put GITHUB_TOKEN +npm run deploy +``` + +Optionally override the client API key in production: + +```bash +npx wrangler secret put API_KEY +``` + +## Authentication + +Clients must send one of: + +- `Authorization: Bearer ` (default: `sk-shumengya666`, not shown on `GET /`) +- `x-api-key: ` + +Override with `wrangler secret put API_KEY` or `.dev.vars` for production. + +The Worker uses `GITHUB_TOKEN` (Wrangler secret) to authenticate with Copilot: + +| Token type | Prefix | How it works | +|------------|--------|--------------| +| OAuth (recommended) | `ghu_`, `gho_` | Exchanged via `GET /copilot_internal/v2/token` | +| Fine-grained PAT | `github_pat_` | Used directly; requires **Copilot Requests** permission; uses `Copilot-Integration-Id: copilot-developer-cli` | + +Classic PAT (`ghp_`) is **not supported** by the Copilot API. + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Service info | +| GET | `/health` | Health check | +| GET | `/v1/models` | List Copilot models | +| POST | `/v1/chat/completions` | OpenAI chat completions | +| POST | `/v1/messages` | Anthropic messages | + +## Usage examples + +### gpt-5.4-mini / Codex 模型 + +这类模型**不能**走 `/chat/completions` 直连 Copilot,必须用 **Responses API**。本 Worker 已支持两种方式: + +**方式 1(推荐)**:仍用 Chat Completions,Worker 会自动转发到 `/responses`: + +```bash +curl http://localhost:8787/v1/chat/completions \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.4-mini", + "max_tokens": 256, + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +**方式 2**:直接调用 OpenAI Responses 格式: + +```bash +curl http://localhost:8787/v1/responses \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-5.4-mini", + "input": "Hello", + "max_output_tokens": 256 + }' +``` + +注意:`max_output_tokens` 最小为 **16**。 + +### 流式 / 工具 / 视觉(gpt-5.4-mini 等) + +`/v1/chat/completions` 与 `/v1/messages` 均已支持: + +| 能力 | OpenAI Chat | Anthropic Messages | +|------|-------------|-------------------| +| 流式 | `stream: true` | `stream: true` | +| 工具 | `tools` + `tool_choice` | `tools` + `tool_choice` | +| 图片 | `image_url` 多模态 content | `image` + `source.base64` / `url` | + +工具调用时 Chat 返回 `finish_reason: tool_calls`;Claude 返回 `stop_reason: tool_use` 与 `tool_use` 内容块。 + +### curl (OpenAI) + +```bash +curl http://localhost:8787/v1/chat/completions \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +### curl (Anthropic) + +```bash +curl http://localhost:8787/v1/messages \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +### OpenAI Python SDK + +```python +from openai import OpenAI + +client = OpenAI( + api_key=os.environ["API_KEY"], + base_url="https://.workers.dev/v1", +) +print(client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], +)) +``` + +### Anthropic Python SDK + +```python +import anthropic + +client = anthropic.Anthropic( + api_key=os.environ["API_KEY"], + base_url="https://.workers.dev", +) +print(client.messages.create( + model="claude-sonnet-4", + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], +)) +``` + +## Environment variables + +| Variable | Source | Description | +|----------|--------|-------------| +| `API_KEY` | `wrangler.jsonc` vars or secret | Client API key (default `sk-shumengya666`, not exposed on `GET /`) | +| `GITHUB_TOKEN` | Secret / `.dev.vars` | GitHub OAuth or PAT with Copilot | +| `COPILOT_API_BASE` | vars (optional) | Default `https://api.githubcopilot.com`; set for Enterprise | + +## Security notes + +- Do **not** commit `.dev.vars` or real tokens to git. +- The default API key is public in `wrangler.jsonc`. For public deployments, set a strong `API_KEY` secret and consider Cloudflare Access or IP restrictions. +- Anyone with the API key can consume your Copilot quota. + +## License + +MIT diff --git a/models.json b/models.json new file mode 100644 index 0000000..af0c6dd --- /dev/null +++ b/models.json @@ -0,0 +1 @@ +checking third-party user token: unauthorized: Personal Access Token does not have "Copilot Requests" permission diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6f55463 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1587 @@ +{ + "name": "copilet2api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilet2api", + "version": "1.0.0", + "dependencies": { + "hono": "^4.7.7" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250519.0", + "typescript": "^5.8.3", + "wrangler": "^4.16.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260518.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260518.1.tgz", + "integrity": "sha512-IhZEf5kDd0CLRtFxGS9AUqfM5SY3EFScqqCY1VF9twNMdYpJDYrDZDJAkQitHF8sF/sPVVHYR4Aifpdq6tzmaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260518.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260518.1.tgz", + "integrity": "sha512-uqlNP1psd8SWfN1Lg5p8ePv8/piOOXt+ycvb8+NQopXECGeh9+PQ/yr/IQjpurxBhYpvSaMC+vEeihejahjkJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260518.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260518.1.tgz", + "integrity": "sha512-D9p8Hl0lIQ46nYs4fQZp5F+9hhvgOcQJTF1SMQWpAxQSS5f8oX+vL5YdCrETUYnyoaoyEQETtkRrWYKJkPTFeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260518.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260518.1.tgz", + "integrity": "sha512-+vNRkuOp9E/uRKHgQXVDUBPF5cwtTeXK6+ucLK50QUFzMYycqVl8kTFN2b//BX2H5BI4bjMRhXoBpe/zAlGRWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260518.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260518.1.tgz", + "integrity": "sha512-tnqofUq+ZvKliQHhboygbH7iy/Zm/MaCCotIlrqVj5a988+tPtndxyLM0r4vaAIC10iy/2LWCkwnE67VFTFiUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260519.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260519.1.tgz", + "integrity": "sha512-BMWAwg4RyyZn3zcdoXbqpfogm2DGfNb83DXNCM1oFUMhYtEX8I+B+oxf67YPKvSiAEbzd7nHzW2mLv3eBH8Etw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "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/hono": { + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260518.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260518.0.tgz", + "integrity": "sha512-jbvp43zWa66tuQ+P7bl7s25VJWzGMv4mVhxEEZEEATPvuqAQhGn2wj3rQViVZkZZBZmXQtZ5ZV5kX9VtmWGzuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260518.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260518.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260518.1.tgz", + "integrity": "sha512-rLquk/eeqqJCbdGljSSuIZWW25vzYjTblXkD/tXQXKR5YsSIC91EtlqrzA1L4TJDZCxXKeFXPYqkW7R16UipXQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260518.1", + "@cloudflare/workerd-darwin-arm64": "1.20260518.1", + "@cloudflare/workerd-linux-64": "1.20260518.1", + "@cloudflare/workerd-linux-arm64": "1.20260518.1", + "@cloudflare/workerd-windows-64": "1.20260518.1" + } + }, + "node_modules/wrangler": { + "version": "4.93.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.93.0.tgz", + "integrity": "sha512-qNsPr0oWRTc85SG7s1MjX+mWNTvkNV1zEQvRpTsV6eo8uqtvZoEAq8t8strQi9TtrDP3BOsxmy+N/G3ML6hH2w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260518.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260518.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260518.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ddb1172 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "copilet2api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.7" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250519.0", + "typescript": "^5.8.3", + "wrangler": "^4.16.0" + } +} diff --git a/scripts/test-api.mjs b/scripts/test-api.mjs new file mode 100644 index 0000000..de1ebdb --- /dev/null +++ b/scripts/test-api.mjs @@ -0,0 +1,57 @@ +/** + * Local API smoke test. Usage: + * node scripts/test-api.mjs [baseUrl] + * Default baseUrl: http://127.0.0.1:8788 + */ +const base = process.argv[2] ?? "http://127.0.0.1:8788"; +const apiKey = process.env.API_KEY ?? "sk-shumengya666"; +const headers = { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", +}; + +async function main() { + console.log("=== GET /v1/models ==="); + const modelsRes = await fetch(`${base}/v1/models`, { headers }); + console.log("status:", modelsRes.status); + const modelsText = await modelsRes.text(); + if (!modelsRes.ok) { + console.log(modelsText.slice(0, 500)); + process.exit(1); + } + const models = JSON.parse(modelsText); + const ids = (models.data ?? []).map((m) => m.id); + console.log("count:", ids.length); + console.log("sample:", ids.slice(0, 15).join(", ")); + + const testModels = [ + ids.find((id) => id.includes("gpt-4o")) ?? ids[0], + ids.find((id) => id.includes("claude")) ?? ids[1], + ].filter(Boolean); + + for (const model of [...new Set(testModels)].slice(0, 2)) { + console.log(`\n=== POST /v1/chat/completions (${model}) ===`); + const chatRes = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: 32, + messages: [{ role: "user", content: "Reply with exactly: ok" }], + }), + }); + console.log("status:", chatRes.status); + const chatBody = await chatRes.text(); + if (!chatRes.ok) { + console.log(chatBody.slice(0, 400)); + continue; + } + const chat = JSON.parse(chatBody); + console.log("reply:", chat.choices?.[0]?.message?.content?.slice(0, 120)); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts new file mode 100644 index 0000000..67765bd --- /dev/null +++ b/src/adapters/anthropic.ts @@ -0,0 +1,502 @@ +/* Anthropic Messages API <-> OpenAI Chat Completions */ + +import type { ChatCompletionRequest, ChatMessage } from "./responses"; + +export interface AnthropicMessageRequest { + model: string; + max_tokens: number; + messages: AnthropicMessage[]; + system?: string | AnthropicContentBlock[]; + stream?: boolean; + temperature?: number; + top_p?: number; + tools?: AnthropicTool[]; + tool_choice?: { type: string; name?: string } | string; +} + +interface AnthropicMessage { + role: "user" | "assistant"; + content: string | AnthropicContentBlock[]; +} + +interface AnthropicContentBlock { + type: string; + text?: string; + source?: { type: string; media_type?: string; data?: string; url?: string }; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string | AnthropicContentBlock[]; +} + +interface AnthropicTool { + name: string; + description?: string; + input_schema?: unknown; +} + +export interface OpenAIChatResponse { + id: string; + model: string; + choices: Array<{ + message: { + role: string; + content: string | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + }; + finish_reason: string | null; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +function extractSystem(system: AnthropicMessageRequest["system"]): string | undefined { + if (!system) return undefined; + if (typeof system === "string") return system; + return system + .filter((b) => b.type === "text" && b.text) + .map((b) => b.text!) + .join(""); +} + +function anthropicImageToOpenAI(block: AnthropicContentBlock): unknown | null { + if (block.type !== "image") return null; + const src = block.source; + if (!src) return null; + if (src.type === "base64" && src.media_type && src.data) { + return { + type: "image_url", + image_url: { url: `data:${src.media_type};base64,${src.data}` }, + }; + } + if (src.type === "url" && src.url) { + return { type: "image_url", image_url: { url: src.url } }; + } + return null; +} + +function anthropicContentToOpenAI( + content: string | AnthropicContentBlock[], + role: string, +): string | unknown[] { + if (typeof content === "string") return content; + + const parts: unknown[] = []; + for (const block of content) { + if (block.type === "text" && block.text) { + parts.push({ type: "text", text: block.text }); + } else if (block.type === "image") { + const img = anthropicImageToOpenAI(block); + if (img) parts.push(img); + } + } + return parts.length === 1 && parts[0] && (parts[0] as { type: string }).type === "text" + ? (parts[0] as { text: string }).text + : parts; +} + +function anthropicToolsToOpenAI(tools?: AnthropicTool[]): ChatCompletionRequest["tools"] { + if (!tools?.length) return undefined; + return tools.map((t) => ({ + type: "function", + function: { + name: t.name, + description: t.description ?? "", + parameters: t.input_schema ?? { type: "object", properties: {} }, + }, + })); +} + +function anthropicToolChoiceToOpenAI( + toolChoice?: AnthropicMessageRequest["tool_choice"], +): unknown { + if (!toolChoice) return undefined; + if (typeof toolChoice === "string") return toolChoice; + if (toolChoice.type === "tool" && toolChoice.name) { + return { type: "function", function: { name: toolChoice.name } }; + } + if (toolChoice.type === "any") return "required"; + return toolChoice.type === "auto" ? "auto" : toolChoice; +} + +export function anthropicToOpenAI(req: AnthropicMessageRequest): ChatCompletionRequest { + let model = req.model; + if (model.startsWith("github_copilot/")) { + model = model.slice("github_copilot/".length); + } + + const messages: ChatMessage[] = []; + const systemText = extractSystem(req.system); + if (systemText) { + messages.push({ role: "system", content: systemText }); + } + + for (const msg of req.messages) { + if (typeof msg.content === "string") { + messages.push({ role: msg.role, content: msg.content }); + continue; + } + + const toolResults = msg.content.filter((b) => b.type === "tool_result"); + const toolUses = msg.content.filter((b) => b.type === "tool_use"); + const other = msg.content.filter((b) => b.type !== "tool_result" && b.type !== "tool_use"); + + if (msg.role === "assistant" && toolUses.length > 0) { + const text = other + .filter((b) => b.type === "text" && b.text) + .map((b) => b.text) + .join(""); + messages.push({ + role: "assistant", + content: text || null, + tool_calls: toolUses.map((tu) => ({ + id: tu.id ?? `toolu_${crypto.randomUUID().slice(0, 12)}`, + type: "function", + function: { + name: tu.name ?? "", + arguments: JSON.stringify(tu.input ?? {}), + }, + })), + }); + continue; + } + + if (msg.role === "user" && toolResults.length > 0) { + if (other.length > 0) { + messages.push({ + role: "user", + content: anthropicContentToOpenAI(other, "user"), + }); + } + for (const tr of toolResults) { + const resultContent = + typeof tr.content === "string" + ? tr.content + : Array.isArray(tr.content) + ? tr.content + .filter((b) => b.type === "text" && b.text) + .map((b) => b.text) + .join("") + : JSON.stringify(tr.content ?? ""); + messages.push({ + role: "tool", + tool_call_id: tr.tool_use_id ?? "", + content: resultContent, + }); + } + continue; + } + + messages.push({ + role: msg.role, + content: anthropicContentToOpenAI(msg.content, msg.role), + }); + } + + return { + model, + messages, + max_tokens: req.max_tokens, + stream: req.stream ?? false, + temperature: req.temperature, + top_p: req.top_p, + tools: anthropicToolsToOpenAI(req.tools), + tool_choice: anthropicToolChoiceToOpenAI(req.tool_choice), + }; +} + +export function openAIToAnthropic( + openai: OpenAIChatResponse, + model: string, +): Record { + const message = openai.choices[0]?.message; + const usage = openai.usage; + const content: AnthropicContentBlock[] = []; + + if (message?.content) { + content.push({ type: "text", text: message.content }); + } + + if (message?.tool_calls?.length) { + for (const tc of message.tool_calls) { + let input: unknown = {}; + try { + input = JSON.parse(tc.function.arguments || "{}"); + } catch { + input = { raw: tc.function.arguments }; + } + content.push({ + type: "tool_use", + id: tc.id, + name: tc.function.name, + input, + }); + } + } + + const finishReason = openai.choices[0]?.finish_reason; + const stopReason = + finishReason === "tool_calls" + ? "tool_use" + : finishReason === "length" + ? "max_tokens" + : "end_turn"; + + return { + id: `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`, + type: "message", + role: "assistant", + model, + content: content.length > 0 ? content : [{ type: "text", text: "" }], + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: usage?.prompt_tokens ?? 0, + output_tokens: usage?.completion_tokens ?? 0, + }, + }; +} + +function mapStopReason(reason: string | null | undefined): string { + if (reason === "length") return "max_tokens"; + if (reason === "tool_calls") return "tool_use"; + if (reason === "stop") return "end_turn"; + return "end_turn"; +} + +function anthropicError(message: string, type = "api_error"): Response { + return new Response( + JSON.stringify({ + type: "error", + error: { type, message }, + }), + { status: 502, headers: { "content-type": "application/json" } }, + ); +} + +function sseEvent(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +export function transformOpenAIStreamToAnthropic( + openaiBody: ReadableStream, + model: string, +): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let buffer = ""; + const messageId = `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; + let blockIndex = 0; + let started = false; + let outputTokens = 0; + let toolBlocksStarted = 0; + + const startMessage = (controller: ReadableStreamDefaultController) => { + if (started) return; + started = true; + controller.enqueue( + encoder.encode( + sseEvent("message_start", { + type: "message_start", + message: { + id: messageId, + type: "message", + role: "assistant", + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }), + ), + ); + }; + + const startTextBlock = (controller: ReadableStreamDefaultController) => { + controller.enqueue( + encoder.encode( + sseEvent("content_block_start", { + type: "content_block_start", + index: blockIndex, + content_block: { type: "text", text: "" }, + }), + ), + ); + }; + + return new ReadableStream({ + async start(controller) { + const reader = openaiBody.getReader(); + const pendingTools = new Map(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data: ")) continue; + const payload = trimmed.slice(6); + if (payload === "[DONE]") continue; + + let chunk: { + choices?: Array<{ + delta?: { + content?: string; + role?: string; + tool_calls?: Array<{ + index?: number; + id?: string; + function?: { name?: string; arguments?: string }; + }>; + }; + finish_reason?: string | null; + }>; + usage?: { completion_tokens?: number }; + }; + try { + chunk = JSON.parse(payload); + } catch { + continue; + } + + if (chunk.usage?.completion_tokens) { + outputTokens = chunk.usage.completion_tokens; + } + + const delta = chunk.choices?.[0]?.delta; + const finishReason = chunk.choices?.[0]?.finish_reason; + + if (delta?.content || delta?.tool_calls || finishReason) { + startMessage(controller); + } + + if (delta?.content) { + if (blockIndex === 0 && toolBlocksStarted === 0) { + startTextBlock(controller); + } + controller.enqueue( + encoder.encode( + sseEvent("content_block_delta", { + type: "content_block_delta", + index: blockIndex, + delta: { type: "text_delta", text: delta.content }, + }), + ), + ); + } + + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!pendingTools.has(idx)) { + pendingTools.set(idx, { + id: tc.id ?? `toolu_${idx}`, + name: tc.function?.name ?? "", + args: "", + }); + const bi = blockIndex + 1 + toolBlocksStarted; + toolBlocksStarted++; + controller.enqueue( + encoder.encode( + sseEvent("content_block_start", { + type: "content_block_start", + index: bi, + content_block: { + type: "tool_use", + id: pendingTools.get(idx)!.id, + name: pendingTools.get(idx)!.name, + input: {}, + }, + }), + ), + ); + } + const tool = pendingTools.get(idx)!; + if (tc.id) tool.id = tc.id; + if (tc.function?.name) tool.name = tc.function.name; + if (tc.function?.arguments) { + tool.args += tc.function.arguments; + let partial: unknown = {}; + try { + partial = JSON.parse(tool.args); + } catch { + partial = { partial: tool.args }; + } + controller.enqueue( + encoder.encode( + sseEvent("content_block_delta", { + type: "content_block_delta", + index: blockIndex + toolBlocksStarted, + delta: { type: "input_json_delta", partial_json: tc.function.arguments }, + }), + ), + ); + } + } + } + + if (finishReason) { + if (blockIndex === 0 && !delta?.tool_calls && toolBlocksStarted === 0) { + startTextBlock(controller); + } + if (blockIndex === 0) { + controller.enqueue( + encoder.encode( + sseEvent("content_block_stop", { + type: "content_block_stop", + index: blockIndex, + }), + ), + ); + } + for (let i = 0; i < toolBlocksStarted; i++) { + controller.enqueue( + encoder.encode( + sseEvent("content_block_stop", { + type: "content_block_stop", + index: blockIndex + 1 + i, + }), + ), + ); + } + controller.enqueue( + encoder.encode( + sseEvent("message_delta", { + type: "message_delta", + delta: { + stop_reason: mapStopReason(finishReason), + stop_sequence: null, + }, + usage: { output_tokens: outputTokens || 1 }, + }), + ), + ); + controller.enqueue( + encoder.encode(sseEvent("message_stop", { type: "message_stop" })), + ); + } + } + } + controller.close(); + } catch (e) { + controller.error(e); + } + }, + }); +} + +export { anthropicError }; diff --git a/src/adapters/responses.ts b/src/adapters/responses.ts new file mode 100644 index 0000000..e5ad665 --- /dev/null +++ b/src/adapters/responses.ts @@ -0,0 +1,360 @@ +/* OpenAI Chat Completions <-> OpenAI Responses (Copilot gpt-5.4+ models) */ + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + max_tokens?: number; + stream?: boolean; + temperature?: number; + top_p?: number; + tools?: unknown[]; + tool_choice?: unknown; +} + +export interface ChatMessage { + role: string; + content?: string | unknown[] | null; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + tool_call_id?: string; + name?: string; +} + +interface ResponsesRequest { + model: string; + input: unknown; + max_output_tokens?: number; + stream?: boolean; + temperature?: number; + top_p?: number; + tools?: unknown[]; + tool_choice?: unknown; +} + +export interface ResponsesOutputItem { + type: string; + role?: string; + name?: string; + call_id?: string; + arguments?: string; + content?: Array<{ type: string; text?: string }>; +} + +export interface ResponsesBody { + id: string; + model: string; + output?: ResponsesOutputItem[]; + usage?: { + input_tokens: number; + output_tokens: number; + total_tokens: number; + }; +} + +type ContentPart = { type?: string; text?: string; image_url?: { url?: string } }; + +function openaiContentToResponsesParts(content: unknown, role: string): unknown[] { + if (typeof content === "string") { + const type = role === "assistant" ? "output_text" : "input_text"; + return [{ type, text: content }]; + } + + if (!Array.isArray(content)) { + return [{ type: "input_text", text: String(content ?? "") }]; + } + + const parts: unknown[] = []; + for (const block of content as ContentPart[]) { + if (block.type === "text" && block.text) { + parts.push({ type: role === "assistant" ? "output_text" : "input_text", text: block.text }); + } else if (block.type === "image_url" && block.image_url?.url) { + parts.push({ type: "input_image", image_url: block.image_url.url }); + } + } + + if (parts.length === 0) { + parts.push({ type: "input_text", text: "" }); + } + return parts; +} + +function openaiToolsToResponses(tools?: unknown[]): unknown[] | undefined { + if (!tools?.length) return undefined; + return tools.map((t) => { + const item = t as { + type?: string; + function?: { name: string; description?: string; parameters?: unknown }; + name?: string; + description?: string; + parameters?: unknown; + }; + if (item.function) { + return { + type: "function", + name: item.function.name, + description: item.function.description ?? "", + parameters: item.function.parameters ?? { type: "object", properties: {} }, + }; + } + if (item.name) return item; + return t; + }); +} + +function messagesToResponsesInput(messages: ChatMessage[]): unknown[] { + const input: unknown[] = []; + + for (const msg of messages) { + if (msg.role === "tool") { + input.push({ + type: "function_call_output", + call_id: msg.tool_call_id, + output: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? ""), + }); + continue; + } + + if (msg.role === "assistant" && msg.tool_calls?.length) { + if (msg.content) { + input.push({ + role: "assistant", + content: openaiContentToResponsesParts(msg.content, "assistant"), + }); + } + for (const tc of msg.tool_calls) { + input.push({ + type: "function_call", + call_id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments, + }); + } + continue; + } + + const role = + msg.role === "system" ? "system" : msg.role === "assistant" ? "assistant" : "user"; + input.push({ + role, + content: openaiContentToResponsesParts(msg.content ?? "", role), + }); + } + + return input; +} + +export function chatCompletionsToResponses(req: ChatCompletionRequest): ResponsesRequest { + const maxTokens = req.max_tokens ?? 1024; + return { + model: req.model, + input: messagesToResponsesInput(req.messages), + max_output_tokens: Math.max(16, maxTokens), + stream: req.stream ?? false, + temperature: req.temperature, + top_p: req.top_p, + tools: openaiToolsToResponses(req.tools), + tool_choice: req.tool_choice, + }; +} + +export function extractResponsesText(body: ResponsesBody): string { + for (const item of body.output ?? []) { + if (item.type !== "message") continue; + for (const part of item.content ?? []) { + if (part.type === "output_text" && part.text) return part.text; + } + } + return ""; +} + +function extractFunctionCalls(body: ResponsesBody): ResponsesOutputItem[] { + return (body.output ?? []).filter((o) => o.type === "function_call"); +} + +export function responsesToChatCompletion( + body: ResponsesBody, + requestedModel: string, +): Record { + const text = extractResponsesText(body); + const functionCalls = extractFunctionCalls(body); + const usage = body.usage; + + const toolCalls = functionCalls.map((fc, i) => ({ + id: fc.call_id ?? `call_${i}`, + type: "function" as const, + function: { + name: fc.name ?? "", + arguments: fc.arguments ?? "{}", + }, + })); + + const hasTools = toolCalls.length > 0; + + return { + id: `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: requestedModel, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: text || (hasTools ? null : ""), + ...(hasTools ? { tool_calls: toolCalls } : {}), + }, + finish_reason: hasTools ? "tool_calls" : "stop", + }, + ], + usage: usage + ? { + prompt_tokens: usage.input_tokens, + completion_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + } + : undefined, + }; +} + +export function transformResponsesStreamToChat( + responsesBody: ReadableStream, + model: string, +): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let buffer = ""; + const id = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; + let roleSent = false; + let toolIndex = 0; + let activeTool: { id: string; name: string; arguments: string } | null = null; + + const emitChunk = ( + controller: ReadableStreamDefaultController, + delta: Record, + finishReason: string | null, + ) => { + const chunk = { + id, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + }; + + return new ReadableStream({ + async start(controller) { + const reader = responsesBody.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() ?? ""; + + for (const block of blocks) { + let eventType = ""; + let dataLine = ""; + for (const line of block.split("\n")) { + if (line.startsWith("event: ")) eventType = line.slice(7).trim(); + if (line.startsWith("data: ")) dataLine = line.slice(6); + } + if (!dataLine || dataLine === "[DONE]") continue; + + let data: Record; + try { + data = JSON.parse(dataLine); + } catch { + continue; + } + + if (eventType === "response.output_text.delta" && data.delta) { + const delta: Record = { content: data.delta as string }; + if (!roleSent) { + delta.role = "assistant"; + roleSent = true; + } + emitChunk(controller, delta, null); + } + + if (eventType === "response.output_item.added") { + const item = (data.item ?? {}) as ResponsesOutputItem; + if (item.type === "function_call" || item.name) { + activeTool = { + id: item.call_id ?? `call_${toolIndex}`, + name: item.name ?? "", + arguments: item.arguments ?? "", + }; + if (!roleSent) { + emitChunk(controller, { role: "assistant", content: null }, null); + roleSent = true; + } + emitChunk( + controller, + { + tool_calls: [ + { + index: toolIndex, + id: activeTool.id, + type: "function", + function: { name: activeTool.name, arguments: "" }, + }, + ], + }, + null, + ); + } + } + + if ( + eventType === "response.function_call_arguments.delta" && + activeTool && + data.delta + ) { + activeTool.arguments += data.delta as string; + emitChunk( + controller, + { + tool_calls: [ + { + index: toolIndex, + function: { arguments: data.delta as string }, + }, + ], + }, + null, + ); + } + + if (eventType === "response.output_item.done") { + const item = (data.item ?? {}) as ResponsesOutputItem; + if (item.type === "function_call" && item.name) { + activeTool = { + id: item.call_id ?? activeTool?.id ?? `call_${toolIndex}`, + name: item.name, + arguments: item.arguments ?? activeTool?.arguments ?? "{}", + }; + } + } + + if (eventType === "response.completed") { + const finish = activeTool ? "tool_calls" : "stop"; + emitChunk(controller, {}, finish); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + activeTool = null; + toolIndex++; + } + } + } + controller.close(); + } catch (e) { + controller.error(e); + } + }, + }); +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..7430e44 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "./env"; + +export async function corsMiddleware(c: Context, next: Next) { + if (c.req.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders(), + }); + } + await next(); + for (const [k, v] of corsHeaders()) { + c.header(k, v); + } +} + +function corsHeaders(): Headers { + const h = new Headers(); + h.set("Access-Control-Allow-Origin", "*"); + h.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + h.set("Access-Control-Allow-Headers", "Authorization, Content-Type, x-api-key, anthropic-version"); + return h; +} + +export async function apiKeyAuth(c: Context<{ Bindings: Env }>, next: Next) { + const expected = c.env.API_KEY; + const auth = c.req.header("authorization"); + const apiKey = c.req.header("x-api-key"); + + let provided: string | undefined; + if (auth?.startsWith("Bearer ")) { + provided = auth.slice(7); + } else if (apiKey) { + provided = apiKey; + } + + if (!provided || provided !== expected) { + return c.json( + { + error: { + message: "Invalid API key", + type: "invalid_request_error", + }, + }, + 401, + ); + } + await next(); +} diff --git a/src/copilot/models.ts b/src/copilot/models.ts new file mode 100644 index 0000000..dcb97ac --- /dev/null +++ b/src/copilot/models.ts @@ -0,0 +1,23 @@ +import type { Env } from "../env"; +import { copilotFetch } from "./upstream"; + +export async function fetchModelIds(env: Env): Promise<{ + ids: string[]; + error?: string; +}> { + try { + const res = await copilotFetch(env, "/models", { method: "GET" }); + if (!res.ok) { + const text = await res.text(); + return { ids: [], error: `Failed to fetch models (${res.status}): ${text.slice(0, 200)}` }; + } + const body = (await res.json()) as { data?: Array<{ id?: string }> }; + const ids = (body.data ?? []) + .map((m) => m.id) + .filter((id): id is string => typeof id === "string" && id.length > 0); + return { ids }; + } catch (e) { + const msg = e instanceof Error ? e.message : "Unknown error"; + return { ids: [], error: msg }; + } +} diff --git a/src/copilot/routing.ts b/src/copilot/routing.ts new file mode 100644 index 0000000..e636d0f --- /dev/null +++ b/src/copilot/routing.ts @@ -0,0 +1,10 @@ +/** Models that must use Copilot /responses instead of /chat/completions. */ +export function requiresResponsesApi(model: string): boolean { + const m = model.toLowerCase(); + if (m.includes("codex")) return true; + if (m.startsWith("gpt-5.4")) return true; + if (m.startsWith("gpt-5.3")) return true; + if (m.startsWith("gpt-5.5")) return true; + if (m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4")) return true; + return false; +} diff --git a/src/copilot/token.ts b/src/copilot/token.ts new file mode 100644 index 0000000..e19b06f --- /dev/null +++ b/src/copilot/token.ts @@ -0,0 +1,72 @@ +import type { Env } from "../env"; + +interface TokenCache { + token: string; + expiresAt: number; +} + +let cache: TokenCache | null = null; + +function githubAuthHeader(token: string): string { + if (token.startsWith("ghu_") || token.startsWith("gho_")) { + return `Bearer ${token}`; + } + return `token ${token}`; +} + +/** Fine-grained PAT is used directly; OAuth tokens are exchanged via GitHub API. */ +function usesDirectPat(token: string): boolean { + return token.startsWith("github_pat_"); +} + +export function getCopilotIntegrationId(githubToken: string): string { + if (usesDirectPat(githubToken)) { + return "copilot-developer-cli"; + } + return "vscode-chat"; +} + +export async function getCopilotToken(env: Env): Promise { + const githubToken = env.GITHUB_TOKEN; + if (!githubToken) { + throw new Error("GITHUB_TOKEN is not configured"); + } + + if (githubToken.startsWith("ghp_")) { + throw new Error( + "Classic PAT (ghp_) is not supported. Use a fine-grained PAT (github_pat_) with Copilot Requests, or OAuth token (ghu_/gho_) from `gh auth login -s copilot`.", + ); + } + + if (usesDirectPat(githubToken)) { + return githubToken; + } + + const now = Date.now(); + if (cache && cache.expiresAt > now) { + return cache.token; + } + + const res = await fetch("https://api.github.com/copilot_internal/v2/token", { + method: "GET", + headers: { + authorization: githubAuthHeader(githubToken), + "user-agent": "GithubCopilot/1.155.0", + accept: "application/json", + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Failed to refresh Copilot token (${res.status}): ${body}`); + } + + const data = (await res.json()) as { token: string; expires_at: number }; + const expiresAt = data.expires_at * 1000 - 60_000; + cache = { token: data.token, expiresAt }; + return data.token; +} + +export function clearTokenCache(): void { + cache = null; +} diff --git a/src/copilot/upstream.ts b/src/copilot/upstream.ts new file mode 100644 index 0000000..670d43e --- /dev/null +++ b/src/copilot/upstream.ts @@ -0,0 +1,35 @@ +import type { Env } from "../env"; +import { getCopilotBase } from "../env"; +import { getCopilotIntegrationId, getCopilotToken } from "./token"; + +export async function copilotFetch( + env: Env, + path: string, + init?: RequestInit, +): Promise { + const token = await getCopilotToken(env); + const integrationId = getCopilotIntegrationId(env.GITHUB_TOKEN); + const base = getCopilotBase(env); + const url = `${base}${path.startsWith("/") ? path : `/${path}`}`; + + const headers = new Headers(init?.headers); + headers.set("authorization", `Bearer ${token}`); + headers.set("Copilot-Integration-Id", integrationId); + if (!headers.has("content-type") && init?.body) { + headers.set("content-type", "application/json"); + } + + return fetch(url, { ...init, headers }); +} + +export function openAIError(message: string, status = 502): Response { + return new Response( + JSON.stringify({ + error: { message, type: "api_error" }, + }), + { + status, + headers: { "content-type": "application/json" }, + }, + ); +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..79e0c2d --- /dev/null +++ b/src/env.ts @@ -0,0 +1,9 @@ +export interface Env { + API_KEY: string; + GITHUB_TOKEN: string; + COPILOT_API_BASE?: string; +} + +export function getCopilotBase(env: Env): string { + return env.COPILOT_API_BASE?.replace(/\/$/, "") ?? "https://api.githubcopilot.com"; +} diff --git a/src/handlers/chat.ts b/src/handlers/chat.ts new file mode 100644 index 0000000..5767d64 --- /dev/null +++ b/src/handlers/chat.ts @@ -0,0 +1,63 @@ +import type { ChatCompletionRequest, ResponsesBody } from "../adapters/responses"; +import { + chatCompletionsToResponses, + responsesToChatCompletion, + transformResponsesStreamToChat, +} from "../adapters/responses"; +import type { Env } from "../env"; +import { requiresResponsesApi } from "../copilot/routing"; +import { copilotFetch, openAIError } from "../copilot/upstream"; + +export async function handleChatCompletion( + env: Env, + req: ChatCompletionRequest, +): Promise { + if (req.model && requiresResponsesApi(req.model)) { + return handleChatViaResponses(env, req); + } + + const res = await copilotFetch(env, "/chat/completions", { + method: "POST", + body: JSON.stringify(req), + }); + + const contentType = res.headers.get("content-type") ?? "application/json"; + const headers: Record = { "content-type": contentType }; + if (contentType.includes("text/event-stream")) { + headers["cache-control"] = "no-cache"; + headers["connection"] = "keep-alive"; + } + + return new Response(res.body, { status: res.status, headers }); +} + +async function handleChatViaResponses( + env: Env, + req: ChatCompletionRequest, +): Promise { + const responsesReq = chatCompletionsToResponses(req); + const res = await copilotFetch(env, "/responses", { + method: "POST", + body: JSON.stringify(responsesReq), + }); + + if (!res.ok) { + const errText = await res.text(); + return openAIError(`Copilot responses error (${res.status}): ${errText}`, res.status); + } + + if (responsesReq.stream) { + if (!res.body) return openAIError("Empty stream body"); + const stream = transformResponsesStreamToChat(res.body, req.model); + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); + } + + const body = (await res.json()) as ResponsesBody; + return Response.json(responsesToChatCompletion(body, req.model)); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..54ba67e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import { apiKeyAuth, corsMiddleware } from "./auth"; +import type { Env } from "./env"; +import anthropicRoutes from "./routes/anthropic"; +import openaiRoutes from "./routes/openai"; + +const app = new Hono<{ Bindings: Env }>(); + +app.use("*", corsMiddleware); +app.use("/v1/*", apiKeyAuth); +app.route("/", openaiRoutes); +app.route("/", anthropicRoutes); + +export default app; diff --git a/src/routes/anthropic.ts b/src/routes/anthropic.ts new file mode 100644 index 0000000..e039e09 --- /dev/null +++ b/src/routes/anthropic.ts @@ -0,0 +1,112 @@ +import { Hono } from "hono"; + +import type { Env } from "../env"; + +import { + + anthropicError, + + anthropicToOpenAI, + + openAIToAnthropic, + + transformOpenAIStreamToAnthropic, + + type AnthropicMessageRequest, + + type OpenAIChatResponse, + +} from "../adapters/anthropic"; + +import { handleChatCompletion } from "../handlers/chat"; + + + +const app = new Hono<{ Bindings: Env }>(); + + + +app.post("/v1/messages", async (c) => { + + let anthropicReq: AnthropicMessageRequest; + + try { + + anthropicReq = await c.req.json(); + + } catch { + + return anthropicError("Invalid JSON body", "invalid_request_error"); + + } + + + + const openaiReq = anthropicToOpenAI(anthropicReq); + + + + try { + + const res = await handleChatCompletion(c.env, openaiReq); + + + + if (!res.ok) { + + const errText = await res.text(); + + return anthropicError(`Copilot upstream error (${res.status}): ${errText}`); + + } + + + + if (openaiReq.stream) { + + if (!res.body) { + + return anthropicError("Empty stream body"); + + } + + const stream = transformOpenAIStreamToAnthropic(res.body, anthropicReq.model); + + return new Response(stream, { + + headers: { + + "content-type": "text/event-stream", + + "cache-control": "no-cache", + + connection: "keep-alive", + + }, + + }); + + } + + + + const openaiRes = (await res.json()) as OpenAIChatResponse; + + const anthropicRes = openAIToAnthropic(openaiRes, anthropicReq.model); + + return c.json(anthropicRes); + + } catch (e) { + + const msg = e instanceof Error ? e.message : "Upstream error"; + + return anthropicError(msg); + + } + +}); + + + +export default app; + diff --git a/src/routes/openai.ts b/src/routes/openai.ts new file mode 100644 index 0000000..0ff46fe --- /dev/null +++ b/src/routes/openai.ts @@ -0,0 +1,92 @@ +import { Hono } from "hono"; +import type { ChatCompletionRequest } from "../adapters/responses"; +import type { Env } from "../env"; +import { handleChatCompletion } from "../handlers/chat"; +import { fetchModelIds } from "../copilot/models"; +import { copilotFetch, openAIError } from "../copilot/upstream"; + +const app = new Hono<{ Bindings: Env }>(); + +app.get("/", async (c) => { + const { ids, error } = await fetchModelIds(c.env); + return c.json({ + service: "copilet2api", + description: "GitHub Copilot proxy with OpenAI and Anthropic compatible APIs", + endpoints: { + health: "GET /health", + models: "GET /v1/models", + chat_completions: "POST /v1/chat/completions", + responses: "POST /v1/responses", + messages: "POST /v1/messages", + }, + features: { + streaming: true, + tools: true, + vision: true, + responses_models: "gpt-5.4-mini, gpt-5.3-codex, etc. (auto-routed)", + }, + auth: "Authorization: Bearer or x-api-key header", + models: { + count: ids.length, + ids, + ...(error ? { error } : {}), + }, + }); +}); + +app.get("/health", (c) => c.json({ status: "ok" })); + +app.get("/v1/models", async (c) => { + try { + const res = await copilotFetch(c.env, "/models", { method: "GET" }); + return new Response(res.body, { + status: res.status, + headers: { + "content-type": res.headers.get("content-type") ?? "application/json", + }, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Upstream error"; + return openAIError(msg); + } +}); + +app.post("/v1/responses", async (c) => { + try { + const body = await c.req.text(); + const res = await copilotFetch(c.env, "/responses", { + method: "POST", + body, + }); + + const contentType = res.headers.get("content-type") ?? "application/json"; + const headers: Record = { "content-type": contentType }; + if (contentType.includes("text/event-stream")) { + headers["cache-control"] = "no-cache"; + headers["connection"] = "keep-alive"; + } + + return new Response(res.body, { status: res.status, headers }); + } catch (e) { + const msg = e instanceof Error ? e.message : "Upstream error"; + return openAIError(msg); + } +}); + +app.post("/v1/chat/completions", async (c) => { + try { + const bodyText = await c.req.text(); + let parsed: ChatCompletionRequest; + try { + parsed = JSON.parse(bodyText) as ChatCompletionRequest; + } catch { + return openAIError("Invalid JSON body", 400); + } + return handleChatCompletion(c.env, parsed); + } catch (e) { + const msg = e instanceof Error ? e.message : "Upstream error"; + return openAIError(msg); + } +}); + +export default app; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5310092 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..bc4c186 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "copilet2api", + "main": "src/index.ts", + "compatibility_date": "2025-05-19", + "vars": { + "API_KEY": "sk-shumengya666", + "COPILOT_API_BASE": "https://api.githubcopilot.com" + } +}