chore: sync local changes to Gitea
This commit is contained in:
8
.dev.vars.example
Normal file
8
.dev.vars.example
Normal file
@@ -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
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
195
README.md
Normal file
195
README.md
Normal file
@@ -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 <API_KEY>` (default: `sk-shumengya666`, not shown on `GET /`)
|
||||
- `x-api-key: <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://<your-worker>.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://<your-worker>.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
|
||||
1
models.json
Normal file
1
models.json
Normal file
@@ -0,0 +1 @@
|
||||
checking third-party user token: unauthorized: Personal Access Token does not have "Copilot Requests" permission
|
||||
1587
package-lock.json
generated
Normal file
1587
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
57
scripts/test-api.mjs
Normal file
57
scripts/test-api.mjs
Normal file
@@ -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);
|
||||
});
|
||||
502
src/adapters/anthropic.ts
Normal file
502
src/adapters/anthropic.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<Uint8Array>,
|
||||
model: string,
|
||||
): ReadableStream<Uint8Array> {
|
||||
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<Uint8Array>) => {
|
||||
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<Uint8Array>) => {
|
||||
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<number, { id: string; name: string; args: string }>();
|
||||
|
||||
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 };
|
||||
360
src/adapters/responses.ts
Normal file
360
src/adapters/responses.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<Uint8Array>,
|
||||
model: string,
|
||||
): ReadableStream<Uint8Array> {
|
||||
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<Uint8Array>,
|
||||
delta: Record<string, unknown>,
|
||||
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<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(dataLine);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "response.output_text.delta" && data.delta) {
|
||||
const delta: Record<string, unknown> = { 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
49
src/auth.ts
Normal file
49
src/auth.ts
Normal file
@@ -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();
|
||||
}
|
||||
23
src/copilot/models.ts
Normal file
23
src/copilot/models.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
10
src/copilot/routing.ts
Normal file
10
src/copilot/routing.ts
Normal file
@@ -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;
|
||||
}
|
||||
72
src/copilot/token.ts
Normal file
72
src/copilot/token.ts
Normal file
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
35
src/copilot/upstream.ts
Normal file
35
src/copilot/upstream.ts
Normal file
@@ -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<Response> {
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
9
src/env.ts
Normal file
9
src/env.ts
Normal file
@@ -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";
|
||||
}
|
||||
63
src/handlers/chat.ts
Normal file
63
src/handlers/chat.ts
Normal file
@@ -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<Response> {
|
||||
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<string, string> = { "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<Response> {
|
||||
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));
|
||||
}
|
||||
14
src/index.ts
Normal file
14
src/index.ts
Normal file
@@ -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;
|
||||
112
src/routes/anthropic.ts
Normal file
112
src/routes/anthropic.ts
Normal file
@@ -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<AnthropicMessageRequest>();
|
||||
|
||||
} 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;
|
||||
|
||||
92
src/routes/openai.ts
Normal file
92
src/routes/openai.ts
Normal file
@@ -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 <API_KEY> 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<string, string> = { "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;
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
9
wrangler.jsonc
Normal file
9
wrangler.jsonc
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user