chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:23 +08:00
commit de2d970b20
21 changed files with 3238 additions and 0 deletions

502
src/adapters/anthropic.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;