Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ A reverse-engineered proxy for the GitHub Copilot API that exposes it as an Open

## Features

- **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API.
- **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/responses`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API.
- **Claude Code Integration**: Easily configure and launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) to use Copilot as its backend with a simple command-line flag (`--claude-code`).
- **Usage Dashboard**: A web-based dashboard to monitor your Copilot API usage, view quotas, and see detailed statistics.
- **Rate Limit Control**: Manage API usage with rate-limiting options (`--rate-limit`) and a waiting mechanism (`--wait`) to prevent errors from rapid requests.
Expand Down Expand Up @@ -188,6 +188,8 @@ These endpoints mimic the OpenAI API structure.
| Endpoint | Method | Description |
| --------------------------- | ------ | --------------------------------------------------------- |
| `POST /v1/chat/completions` | `POST` | Creates a model response for the given chat conversation. |
| `POST /responses` | `POST` | Creates a model response using the OpenAI Responses API. |
| `POST /v1/responses` | `POST` | Alias for `POST /responses`. |
| `GET /v1/models` | `GET` | Lists the currently available models. |
| `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. |

Expand Down
27 changes: 27 additions & 0 deletions src/routes/responses/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Context } from "hono"

import consola from "consola"

import { awaitApproval } from "~/lib/approval"
import { checkRateLimit } from "~/lib/rate-limit"
import { state } from "~/lib/state"
import {
createResponses,
type ResponsesPayload,
} from "~/services/copilot/create-responses"

export async function handleResponses(c: Context) {
await checkRateLimit(state)

const requestBody = c.req.raw.clone()
const payload = await c.req.json<ResponsesPayload>()
const body = await requestBody.text()
consola.debug(
"Responses request payload:",
JSON.stringify(payload).slice(-400),
)

if (state.manualApprove) await awaitApproval()

return createResponses(payload, body, c.req.raw.signal)
}
15 changes: 15 additions & 0 deletions src/routes/responses/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Hono } from "hono"

import { forwardError } from "~/lib/error"

import { handleResponses } from "./handler"

export const responsesRoutes = new Hono()

responsesRoutes.post("/", async (c) => {
try {
return await handleResponses(c)
} catch (error) {
return await forwardError(c, error)
}
})
10 changes: 9 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,32 @@ import { completionRoutes } from "./routes/chat-completions/route"
import { embeddingRoutes } from "./routes/embeddings/route"
import { messageRoutes } from "./routes/messages/route"
import { modelRoutes } from "./routes/models/route"
import { responsesRoutes } from "./routes/responses/route"
import { tokenRoute } from "./routes/token/route"
import { usageRoute } from "./routes/usage/route"

export const server = new Hono()

server.use(logger())
server.use(cors())
server.use(
cors({
origin: "*",
exposeHeaders: ["retry-after", "x-should-retry", "x-request-id"],
}),
)

server.get("/", (c) => c.text("Server running"))

server.route("/chat/completions", completionRoutes)
server.route("/responses", responsesRoutes)
server.route("/models", modelRoutes)
server.route("/embeddings", embeddingRoutes)
server.route("/usage", usageRoute)
server.route("/token", tokenRoute)

// Compatibility with tools that expect v1/ prefix
server.route("/v1/chat/completions", completionRoutes)
server.route("/v1/responses", responsesRoutes)
server.route("/v1/models", modelRoutes)
server.route("/v1/embeddings", embeddingRoutes)

Expand Down
131 changes: 131 additions & 0 deletions src/services/copilot/create-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import consola from "consola"

import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
import { state } from "~/lib/state"

export const createResponses = async (
payload: ResponsesPayload,
body = JSON.stringify(payload),
signal?: AbortSignal,
) => {
if (!state.copilotToken) throw new Error("Copilot token not found")

const headers: Record<string, string> = {
...copilotHeaders(state, containsVisionContent(payload.input)),
"accept-encoding": "identity",
"X-Initiator": isAgentInitiated(payload.input) ? "agent" : "user",
}

const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
method: "POST",
headers,
body,
signal,
})

if (!response.ok) {
consola.error("Failed to create response", response)
}

return normalizeResponse(response)
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null

const containsVisionContent = (input: unknown): boolean => {
if (Array.isArray(input)) {
return input.some((item) => containsVisionContent(item))
}
if (!isRecord(input)) return false

return (
input.type === "input_image"
|| input.type === "computer_screenshot"
|| Object.values(input).some((value) => containsVisionContent(value))
)
}

const agentInitiatedInputTypes = new Set([
"tool_search_output",
"mcp_list_tools",
"mcp_approval_request",
"mcp_approval_response",
"program",
"program_output",
"compaction",
])

const isAgentInitiated = (input: unknown): boolean => {
if (!Array.isArray(input)) return false

return input.some(
(item) =>
isRecord(item)
&& (item.role === "assistant"
|| item.type === "reasoning"
|| (typeof item.type === "string"
&& (agentInitiatedInputTypes.has(item.type)
|| item.type.endsWith("_call")
|| item.type.endsWith("_call_output")))),
)
}

const normalizeResponse = (response: Response): Response => {
const headers = new Headers(response.headers)
headers.delete("content-encoding")
headers.delete("content-length")

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}

export interface ResponsesPayload {
model: string
input?: string | Array<ResponsesInputItem> | null
background?: boolean | null
conversation?: string | Record<string, unknown> | null
include?: Array<string> | null
instructions?: string | null
max_output_tokens?: number | null
max_tool_calls?: number | null
metadata?: Record<string, unknown> | null
parallel_tool_calls?: boolean | null
previous_response_id?: string | null
prompt?: Record<string, unknown> | null
prompt_cache_key?: string | null
reasoning?: Record<string, unknown> | null
safety_identifier?: string | null
service_tier?: string | null
store?: boolean | null
stream?: boolean | null
temperature?: number | null
text?: Record<string, unknown> | null
tool_choice?: string | Record<string, unknown> | null
tools?: Array<ResponsesTool> | null
top_logprobs?: number | null
top_p?: number | null
truncation?: "auto" | "disabled" | null
user?: string | null
[key: string]: unknown
}

export interface ResponsesInputItem {
type?: string
role?: "user" | "assistant" | "system" | "developer"
content?: string | Array<ResponsesContentPart>
[key: string]: unknown
}

export interface ResponsesContentPart {
type?: string
[key: string]: unknown
}

export interface ResponsesTool {
type: string
[key: string]: unknown
}
Loading