From a9d4672108f199fafdbdc6cf94ab705162794a82 Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 10:17:53 -0800 Subject: [PATCH 1/7] support code-simplifier agent --- src/routes/messages/non-stream-translation.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e6382..efab0419f 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -46,13 +46,49 @@ export function translateToOpenAI( } } +// Exact model name mappings +const MODEL_NAME_MAP: Record = { + haiku: "claude-haiku-4.5", + sonnet: "claude-sonnet-4", + opus: "claude-opus-4.5", + "claude-opus-4": "claude-opus-4.5", + "claude-haiku-4": "claude-haiku-4.5", +} + +// Pattern-based model mappings: [pattern, target] +const MODEL_PATTERN_MAP: Array<[string, string]> = [ + // Claude 3.5 models (older naming convention) + ["claude-3-5-sonnet", "claude-sonnet-4"], + ["claude-3.5-sonnet", "claude-sonnet-4"], + ["claude-3-5-haiku", "claude-haiku-4.5"], + ["claude-3.5-haiku", "claude-haiku-4.5"], + ["claude-3-opus", "claude-opus-4.5"], + ["claude-3.0-opus", "claude-opus-4.5"], + // Claude 4.x models with version suffixes (e.g., claude-sonnet-4-20241022) + ["claude-sonnet-4-", "claude-sonnet-4"], + ["claude-sonnet-4.", "claude-sonnet-4"], + ["claude-opus-4-", "claude-opus-4.5"], + ["claude-opus-4.", "claude-opus-4.5"], + ["claude-haiku-4-", "claude-haiku-4.5"], + ["claude-haiku-4.", "claude-haiku-4.5"], +] + function translateModelName(model: string): string { - // Subagent requests use a specific model number which Copilot doesn't support - if (model.startsWith("claude-sonnet-4-")) { - return model.replace(/^claude-sonnet-4-.*/, "claude-sonnet-4") - } else if (model.startsWith("claude-opus-")) { - return model.replace(/^claude-opus-4-.*/, "claude-opus-4") + // Claude Code uses Anthropic model IDs, but GitHub Copilot uses different naming + // Map common Claude Code model names to GitHub Copilot equivalents + + // Check exact matches first + if (MODEL_NAME_MAP[model]) { + return MODEL_NAME_MAP[model] } + + // Check pattern-based matches + for (const [pattern, target] of MODEL_PATTERN_MAP) { + if (model.includes(pattern)) { + return target + } + } + return model } From 89751b893819dd233fb0911b7d73477fee090b83 Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 23:07:51 -0800 Subject: [PATCH 2/7] add support for gpt 5.2 and codex 5.2 --- CLAUDE.md | 331 ++++++++++++++++++++++++ README.md | 43 +++- bun.lock | 1 + package.json | 17 +- src/lib/api-config.ts | 2 +- src/routes/responses/handler.ts | 256 +++++++++++++++++++ src/routes/responses/route.ts | 15 ++ src/routes/responses/translation.ts | 227 +++++++++++++++++ src/routes/responses/types.ts | 98 +++++++ src/server.ts | 4 + tests/responses-translation.test.ts | 382 ++++++++++++++++++++++++++++ 11 files changed, 1367 insertions(+), 9 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/routes/responses/handler.ts create mode 100644 src/routes/responses/route.ts create mode 100644 src/routes/responses/translation.ts create mode 100644 src/routes/responses/types.ts create mode 100644 tests/responses-translation.test.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fe82371ac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,331 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A reverse-engineered proxy for GitHub Copilot API that exposes it as an OpenAI and Anthropic compatible service. Allows using GitHub Copilot with tools that support OpenAI Chat Completions API (`/v1/chat/completions`), OpenAI Responses API (`/v1/responses`), or Anthropic Messages API (`/v1/messages`), including Claude Code and Codex CLI. + +## Common Commands + +```bash +bun run dev # Development with hot reload +bun run start # Production +bun run build # Build for distribution (uses tsdown) +bun run typecheck # Type checking +bun run lint # Lint staged files +bun run lint:all # Lint all files +bun test # Run all tests +bun test tests/anthropic-request.test.ts # Run a specific test +``` + +## Architecture + +### Entry Points & CLI Structure +- `src/main.ts` - CLI entry point using `citty` for subcommand structure +- Subcommands: `start` (server), `auth` (authentication), `check-usage`, `debug` +- `src/start.ts` - Main server startup logic +- `src/server.ts` - Hono HTTP server with route mounting + +### Request Flow +1. **OpenAI endpoints** (`/v1/chat/completions`, `/v1/responses`, `/v1/models`, `/v1/embeddings`) - pass through to Copilot API directly +2. **Anthropic endpoints** (`/v1/messages`) - translate Anthropic format to OpenAI, call Copilot, translate response back + +### Route Structure +``` +src/routes/ +├── chat-completions/ # OpenAI compatible - direct passthrough +├── responses/ # OpenAI Responses API - translates to Chat Completions +│ ├── handler.ts # Main request handler with streaming +│ ├── translation.ts # Responses <-> Chat Completions conversion +│ └── types.ts # Type definitions +├── messages/ # Anthropic compatible - requires translation +│ ├── handler.ts # Main request handler +│ ├── non-stream-translation.ts # Anthropic <-> OpenAI payload/response conversion +│ ├── stream-translation.ts # Streaming chunk translation +│ └── anthropic-types.ts # Type definitions +├── models/ # Model listing +├── embeddings/ # Embedding generation +├── usage/ # Copilot usage statistics +└── token/ # Token endpoint +``` + +### Key Translation Logic +`src/routes/messages/non-stream-translation.ts` is the core file for Anthropic API compatibility: +- `translateToOpenAI()` - converts Anthropic request payload to OpenAI format +- `translateToAnthropic()` - converts OpenAI response to Anthropic format +- `translateModelName()` - maps Claude Code model names to GitHub Copilot model names (critical for model compatibility) + +### Shared State +`src/lib/state.ts` - Global mutable state object holding: +- GitHub/Copilot tokens +- Account type (individual/business/enterprise) +- Cached models +- Rate limiting configuration + +### Copilot API Services +``` +src/services/ +├── copilot/ +│ ├── create-chat-completions.ts # Main completion endpoint +│ ├── create-embeddings.ts +│ └── get-models.ts +└── github/ + ├── get-copilot-token.ts # Token refresh logic + ├── get-device-code.ts # OAuth device flow + └── poll-access-token.ts +``` + +### Configuration +- `src/lib/api-config.ts` - Copilot API URLs, headers, and version constants +- Base URL varies by account type: `api.githubcopilot.com` (individual) vs `api.{type}.githubcopilot.com` + +## Path Aliases + +Uses `~/` to reference `src/` directory (configured in tsconfig.json): +```typescript +import { state } from "~/lib/state" +``` + +## Testing + +Tests use `bun:test` and focus on translation logic validation: +- `tests/anthropic-request.test.ts` - Anthropic to OpenAI payload translation +- `tests/anthropic-response.test.ts` - OpenAI to Anthropic response translation +- Uses Zod schemas to validate translated payloads match OpenAI spec + +## Detailed Architecture + +### High-Level System Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT APPLICATIONS │ +│ (Claude Code, Codex CLI, OpenAI-compatible tools, Anthropic-compatible tools) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ▼ ▼ + ┌───────────────────────┐ ┌───────────────────────┐ + │ /v1/chat/completions │ │ /v1/messages │ + │ /v1/responses │ │ (Anthropic format) │ + │ (OpenAI format) │ └───────────────────────┘ + └───────────────────────┘ │ + │ ▼ + │ ┌───────────────────────┐ + │ │ translateToOpenAI() │ + │ │ (Anthropic → OpenAI) │ + │ └───────────────────────┘ + │ │ + └─────────────────┬─────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ COPILOT API PROXY │ + │ ┌───────────────────────────────┐ │ + │ │ Rate Limiting Check │ │ + │ │ Token Management │ │ + │ │ Header Construction │ │ + │ └───────────────────────────────┘ │ + └─────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ GITHUB COPILOT API │ + │ api.githubcopilot.com │ + │ api.business.githubcopilot.com │ + │ api.enterprise.githubcopilot.com │ + └─────────────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ▼ ▼ + ┌───────────────────────┐ ┌───────────────────────┐ + │ OpenAI Response │ │ translateToAnthropic()│ + │ (direct return) │ │ (OpenAI → Anthropic) │ + └───────────────────────┘ └───────────────────────┘ +``` + +### Authentication Flow + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ User │ │ copilot-api │ │ GitHub │ │ Copilot API │ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ │ + │ copilot-api auth │ │ │ + │───────────────────>│ │ │ + │ │ │ │ + │ │ POST /login/device/code │ + │ │───────────────────>│ │ + │ │ │ │ + │ │ {device_code, user_code, verification_uri} + │ │<───────────────────│ │ + │ │ │ │ + │ "Enter code XXXX │ │ │ + │ at github.com/..." │ │ │ + │<───────────────────│ │ │ + │ │ │ │ + │ User enters code │ │ │ + │─────────────────────────────────────────> │ + │ │ │ │ + │ │ POST /login/oauth/access_token (poll) │ + │ │───────────────────>│ │ + │ │ │ │ + │ │ {access_token} │ │ + │ │<───────────────────│ │ + │ │ │ │ + │ │ Save to ~/.local/share/copilot-api/github_token + │ │ │ │ + │ │ GET /copilot_internal/v2/token │ + │ │───────────────────>│ │ + │ │ │ │ + │ │ {token, refresh_in, expires_at} │ + │ │<───────────────────│ │ + │ │ │ │ + │ │ Start refresh interval (refresh_in - 60s) + │ │ │ │ + │ Ready! │ │ │ + │<───────────────────│ │ │ +``` + +### Request Flow: Anthropic Messages Endpoint + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ POST /v1/messages │ +│ (Anthropic Format) │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ 1. Parse AnthropicMessagesPayload │ +│ - model, messages, system, max_tokens, tools, stream │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ 2. translateToOpenAI() │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ • translateModelName() - Map Claude names to Copilot names │ │ +│ │ • translateAnthropicMessagesToOpenAI() - Convert message format │ │ +│ │ • translateAnthropicToolsToOpenAI() - Convert tool definitions │ │ +│ │ • translateAnthropicToolChoiceToOpenAI() - Convert tool_choice │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ 3. Rate Limit Check │ +│ - If exceeded and !rateLimitWait → HTTP 429 │ +│ - If exceeded and rateLimitWait → sleep until allowed │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ 4. createChatCompletions(openAIPayload) │ +│ - Add Copilot headers (Authorization, editor-version, etc.) │ +│ - POST to api.githubcopilot.com/chat/completions │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ + stream: false stream: true + │ │ + ▼ ▼ +┌─────────────────────────────┐ ┌─────────────────────────────────────────┐ +│ 5a. Non-Streaming │ │ 5b. Streaming │ +│ translateToAnthropic() │ │ translateChunkToAnthropicEvents() │ +│ - Map text/tool_use blocks │ │ - Emit message_start │ +│ - Map stop_reason │ │ - Emit content_block_start/delta/stop │ +│ - Calculate token usage │ │ - Emit message_delta │ +│ → Return JSON │ │ - Emit message_stop │ +└─────────────────────────────┘ │ → Return SSE stream │ + └─────────────────────────────────────────┘ +``` + +### Model Name Mapping + +| Input (Claude Code) | Output (GitHub Copilot) | +|------------------------------|-------------------------| +| `haiku` | `claude-haiku-4.5` | +| `sonnet` | `claude-sonnet-4` | +| `opus` | `claude-opus-4.5` | +| `claude-3-5-sonnet-*` | `claude-sonnet-4` | +| `claude-3.5-sonnet-*` | `claude-sonnet-4` | +| `claude-3-5-haiku-*` | `claude-haiku-4.5` | +| `claude-3.5-haiku-*` | `claude-haiku-4.5` | +| `claude-3-opus-*` | `claude-opus-4.5` | +| `claude-sonnet-4-*` | `claude-sonnet-4` | +| `claude-opus-4-*` | `claude-opus-4.5` | +| `claude-haiku-4-*` | `claude-haiku-4.5` | +| `claude-opus-4` | `claude-opus-4.5` | +| `claude-haiku-4` | `claude-haiku-4.5` | + +### Global State Structure + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ state (src/lib/state.ts) │ +├─────────────────────────────────────────────────────────────────┤ +│ Authentication │ +│ ├── githubToken?: string # GitHub OAuth token │ +│ └── copilotToken?: string # Copilot API token (auto-refresh)│ +├─────────────────────────────────────────────────────────────────┤ +│ Configuration │ +│ ├── accountType: string # "individual"|"business"|"enterprise" +│ ├── vsCodeVersion?: string # Emulated VS Code version │ +│ └── models?: ModelsResponse # Cached available models │ +├─────────────────────────────────────────────────────────────────┤ +│ Runtime Options │ +│ ├── manualApprove: boolean # Require manual request approval │ +│ ├── rateLimitWait: boolean # Wait vs reject on rate limit │ +│ ├── showToken: boolean # Display tokens in logs │ +│ ├── rateLimitSeconds?: number # Seconds between requests │ +│ └── lastRequestTimestamp?: number │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Copilot API Headers + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ copilotHeaders() Construction │ +├─────────────────────────────────────────────────────────────────┤ +│ Authorization: Bearer ${copilotToken} │ +│ content-type: application/json │ +│ copilot-integration-id: vscode-chat │ +│ editor-version: vscode/${vsCodeVersion} │ +│ editor-plugin-version: copilot-chat/0.26.7 │ +│ user-agent: GitHubCopilotChat/0.26.7 │ +│ openai-intent: conversation-panel │ +│ x-github-api-version: 2025-04-01 │ +│ x-request-id: ${randomUUID()} │ +│ X-Initiator: user | agent │ +│ copilot-vision-request: true (if images in payload) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Key Files Reference + +| File | Purpose | +|------|---------| +| `src/main.ts` | CLI entry point with subcommands (auth, start, check-usage, debug) | +| `src/start.ts` | Server initialization and configuration | +| `src/server.ts` | Hono HTTP server with route mounting | +| `src/lib/state.ts` | Global mutable state for tokens and config | +| `src/lib/token.ts` | GitHub/Copilot token setup and refresh | +| `src/lib/api-config.ts` | API URLs, headers, version constants | +| `src/lib/rate-limit.ts` | Request rate limiting logic | +| `src/services/copilot/create-chat-completions.ts` | Core Copilot API call | +| `src/services/github/get-device-code.ts` | OAuth device code flow | +| `src/services/github/get-copilot-token.ts` | Copilot token retrieval | +| `src/routes/chat-completions/handler.ts` | OpenAI endpoint (passthrough) | +| `src/routes/messages/handler.ts` | Anthropic endpoint (with translation) | +| `src/routes/messages/non-stream-translation.ts` | Anthropic ↔ OpenAI payload conversion | +| `src/routes/messages/stream-translation.ts` | Streaming chunk translation | +| `src/routes/messages/anthropic-types.ts` | Anthropic API type definitions | +| `src/routes/responses/handler.ts` | OpenAI Responses API endpoint (with translation) | +| `src/routes/responses/translation.ts` | Responses ↔ Chat Completions conversion | +| `src/routes/responses/types.ts` | Responses API type definitions | diff --git a/README.md b/README.md index 0d36c13c9..d326192ab 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ 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. +- **Codex CLI Integration**: Use with [OpenAI Codex CLI](https://github.com/openai/codex) via the Responses API endpoint. - **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. @@ -188,6 +189,7 @@ 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 /v1/responses` | `POST` | OpenAI Responses API for newer models (e.g., gpt-5.x). | | `GET /v1/models` | `GET` | Lists the currently available models. | | `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. | @@ -326,6 +328,45 @@ You can find more options here: [Claude Code settings](https://docs.anthropic.co You can also read more about IDE integration here: [Add Claude Code to your IDE](https://docs.anthropic.com/en/docs/claude-code/ide-integrations) +## Using with Codex CLI + +This proxy supports the [OpenAI Codex CLI](https://github.com/openai/codex) through the Responses API endpoint (`/v1/responses`). + +### Configuration + +Add the following to your `~/.codex/config.toml`: + +```toml +model = "gpt-5.2" +model_provider = "copilot-proxy" + +[model_providers.copilot-proxy] +name = "GitHub Copilot (via copilot-api)" +base_url = "http://localhost:4141/v1" +wire_api = "responses" +env_key = "OPENAI_API_KEY" +``` + +Then set the environment variable (the value doesn't matter, it just needs to be set): + +```sh +# Windows (permanent) +setx OPENAI_API_KEY "dummy" + +# Linux/macOS +export OPENAI_API_KEY="dummy" +``` + +Start the proxy server and run Codex: + +```sh +# Start the proxy +npx copilot-api@latest start + +# In another terminal, run Codex +codex +``` + ## Running from Source The project can be run from source in several ways: diff --git a/bun.lock b/bun.lock index 20e895e7f..9ece87578 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "copilot-api", diff --git a/package.json b/package.json index a5adbb8e7..e34d1ef9e 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,25 @@ { - "name": "copilot-api", + "name": "copilot-proxy-api", "version": "0.7.0", - "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!", + "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code and Codex CLI!", "keywords": [ "proxy", "github-copilot", - "openai-compatible" + "openai-compatible", + "anthropic-compatible", + "codex", + "claude-code" ], - "homepage": "https://github.com/ericc-ch/copilot-api", - "bugs": "https://github.com/ericc-ch/copilot-api/issues", + "homepage": "https://github.com/voidsteed/copilot-api", + "bugs": "https://github.com/voidsteed/copilot-api/issues", "repository": { "type": "git", - "url": "git+https://github.com/ericc-ch/copilot-api.git" + "url": "git+https://github.com/voidsteed/copilot-api.git" }, "author": "Erick Christian ", "type": "module", "bin": { - "copilot-api": "./dist/main.js" + "copilot-proxy-api": "./dist/main.js" }, "files": [ "dist" diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 83bce92ad..f7999ab7d 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -7,7 +7,7 @@ export const standardHeaders = () => ({ accept: "application/json", }) -const COPILOT_VERSION = "0.26.7" +const COPILOT_VERSION = "0.37.0" const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}` const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}` diff --git a/src/routes/responses/handler.ts b/src/routes/responses/handler.ts new file mode 100644 index 000000000..35e682ccf --- /dev/null +++ b/src/routes/responses/handler.ts @@ -0,0 +1,256 @@ +import type { Context } from "hono" +import type { SSEStreamingApi } from "hono/streaming" + +import consola from "consola" +import { streamSSE } from "hono/streaming" + +import { awaitApproval } from "~/lib/approval" +import { checkRateLimit } from "~/lib/rate-limit" +import { state } from "~/lib/state" +import { + createChatCompletions, + type ChatCompletionChunk, + type ChatCompletionResponse, +} from "~/services/copilot/create-chat-completions" + +import type { ResponsesApiRequest, ResponsesStreamEvent } from "./types" + +import { + translateChatToResponses, + translateResponsesToChat, +} from "./translation" + +const isNonStreamingResponse = ( + response: Awaited>, +): response is ChatCompletionResponse => "choices" in response + +export async function handleResponses(c: Context): Promise { + await checkRateLimit(state) + + const request = await c.req.json() + consola.debug("Responses API request:", JSON.stringify(request).slice(-400)) + + if (state.manualApprove) await awaitApproval() + + // Translate Responses API request to Chat Completions format + const chatPayload = translateResponsesToChat(request) + consola.debug( + "Translated to Chat payload:", + JSON.stringify(chatPayload).slice(-400), + ) + + const response = await createChatCompletions(chatPayload) + + if (isNonStreamingResponse(response)) { + consola.debug("Non-streaming response:", JSON.stringify(response)) + const responsesResponse = translateChatToResponses(response, request.model) + return c.json(responsesResponse) + } + + consola.debug("Streaming response") + return streamSSE(c, async (stream) => { + const streamState = createStreamState(request.model) + + await sendCreatedEvent(stream, streamState) + + for await (const rawEvent of response) { + await processStreamChunk(rawEvent, stream, streamState) + } + + await sendDoneEvent(stream, streamState) + }) +} + +interface StreamState { + outputText: string + responseId: string + model: string + outputIndex: number +} + +function createStreamState(model: string): StreamState { + return { + outputText: "", + responseId: `resp_${Date.now()}`, + model, + outputIndex: 0, + } +} + +async function sendCreatedEvent( + stream: SSEStreamingApi, + state: StreamState, +): Promise { + const createdEvent: ResponsesStreamEvent = { + type: "response.created", + response: { + id: state.responseId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + model: state.model, + output: [], + status: "in_progress", + }, + } + await stream.writeSSE({ + event: "response.created", + data: JSON.stringify(createdEvent), + }) +} + +async function processStreamChunk( + rawEvent: { data?: unknown }, + stream: SSEStreamingApi, + state: StreamState, +): Promise { + try { + const chunk = JSON.parse(rawEvent.data as string) as ChatCompletionChunk + + if (chunk.id) { + state.responseId = chunk.id + } + + for (const choice of chunk.choices) { + await processChoice(choice, stream, state) + } + } catch { + // Skip malformed chunks + consola.debug("Skipping malformed chunk:", rawEvent) + } +} + +async function processChoice( + choice: ChatCompletionChunk["choices"][0], + stream: SSEStreamingApi, + state: StreamState, +): Promise { + const delta = choice.delta + + if (delta.content) { + state.outputText += delta.content + await sendTextDeltaEvent(stream, delta.content, state.outputIndex) + } + + if (delta.tool_calls) { + await processToolCalls(delta.tool_calls, stream, state.outputIndex) + } + + if (choice.finish_reason && state.outputText) { + await sendTextDoneEvent(stream, state.outputIndex) + } +} + +async function sendTextDeltaEvent( + stream: SSEStreamingApi, + content: string, + outputIndex: number, +): Promise { + const deltaEvent: ResponsesStreamEvent = { + type: "response.output_text.delta", + delta: content, + output_index: outputIndex, + content_index: 0, + } + await stream.writeSSE({ + event: "response.output_text.delta", + data: JSON.stringify(deltaEvent), + }) +} + +async function processToolCalls( + toolCalls: NonNullable< + ChatCompletionChunk["choices"][0]["delta"] + >["tool_calls"], + stream: SSEStreamingApi, + outputIndex: number, +): Promise { + if (!toolCalls) return + + for (const toolCall of toolCalls) { + if (toolCall.function?.name) { + await sendFunctionCallStartEvent(stream, toolCall, outputIndex) + } + + if (toolCall.function?.arguments) { + await sendFunctionCallDeltaEvent( + stream, + toolCall.function.arguments, + outputIndex, + ) + } + } +} + +async function sendFunctionCallStartEvent( + stream: SSEStreamingApi, + toolCall: { id?: string; function?: { name?: string } }, + outputIndex: number, +): Promise { + const funcStartEvent: ResponsesStreamEvent = { + type: "response.function_call_arguments.start", + item: { + id: `fc_${toolCall.id}`, + type: "function_call", + name: toolCall.function?.name, + call_id: toolCall.id, + status: "in_progress", + }, + output_index: outputIndex, + } + await stream.writeSSE({ + event: "response.function_call_arguments.start", + data: JSON.stringify(funcStartEvent), + }) +} + +async function sendFunctionCallDeltaEvent( + stream: SSEStreamingApi, + args: string, + outputIndex: number, +): Promise { + const argsDeltaEvent: ResponsesStreamEvent = { + type: "response.function_call_arguments.delta", + delta: args, + output_index: outputIndex, + } + await stream.writeSSE({ + event: "response.function_call_arguments.delta", + data: JSON.stringify(argsDeltaEvent), + }) +} + +async function sendTextDoneEvent( + stream: SSEStreamingApi, + outputIndex: number, +): Promise { + const textDoneEvent: ResponsesStreamEvent = { + type: "response.output_text.done", + output_index: outputIndex, + content_index: 0, + } + await stream.writeSSE({ + event: "response.output_text.done", + data: JSON.stringify(textDoneEvent), + }) +} + +async function sendDoneEvent( + stream: SSEStreamingApi, + state: StreamState, +): Promise { + const doneEvent: ResponsesStreamEvent = { + type: "response.done", + response: { + id: state.responseId, + object: "response", + created_at: Math.floor(Date.now() / 1000), + model: state.model, + output_text: state.outputText, + status: "completed", + }, + } + await stream.writeSSE({ + event: "response.done", + data: JSON.stringify(doneEvent), + }) +} diff --git a/src/routes/responses/route.ts b/src/routes/responses/route.ts new file mode 100644 index 000000000..6b6e80f07 --- /dev/null +++ b/src/routes/responses/route.ts @@ -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 forwardError(c, error) + } +}) diff --git a/src/routes/responses/translation.ts b/src/routes/responses/translation.ts new file mode 100644 index 000000000..b2ddc666e --- /dev/null +++ b/src/routes/responses/translation.ts @@ -0,0 +1,227 @@ +import type { + ChatCompletionResponse, + ChatCompletionsPayload, + ContentPart, + Message, + Tool, +} from "~/services/copilot/create-chat-completions" + +import type { + ResponsesApiRequest, + ResponsesApiResponse, + ResponsesContentPart, + ResponsesInputItem, + ResponsesOutputItem, +} from "./types" + +/** + * Translate Responses API request to Chat Completions format + */ +export function translateResponsesToChat( + request: ResponsesApiRequest, +): ChatCompletionsPayload { + const messages: Array = [] + + // Handle instructions as system message + if (request.instructions) { + messages.push({ + role: "system", + content: request.instructions, + }) + } + + // Handle input - can be string or array of items + if (typeof request.input === "string") { + messages.push({ + role: "user", + content: request.input, + }) + } else if (Array.isArray(request.input)) { + for (const item of request.input) { + const message = translateInputItem(item) + if (message) { + messages.push(message) + } + } + } + + // Translate tools + const tools = translateTools(request.tools) + + // Translate tool_choice + let toolChoice: ChatCompletionsPayload["tool_choice"] + if (request.tool_choice) { + if (typeof request.tool_choice === "string") { + toolChoice = request.tool_choice as "auto" | "none" | "required" + } else if ( + request.tool_choice.type === "function" + && request.tool_choice.function?.name + ) { + toolChoice = { + type: "function", + function: { name: request.tool_choice.function.name }, + } + } + } + + return { + model: request.model, + messages, + max_tokens: request.max_output_tokens, + temperature: request.temperature, + top_p: request.top_p, + stream: request.stream, + tools, + tool_choice: toolChoice, + } +} + +function translateInputItem(item: ResponsesInputItem): Message | null { + // Handle tool_result type + if (item.type === "tool_result" && item.tool_call_id) { + return { + role: "tool", + tool_call_id: item.tool_call_id, + content: + item.output ?? (typeof item.content === "string" ? item.content : ""), + } + } + + // Handle regular message + const content = translateContent(item.content) + if (!content) return null + + // Map developer role to system + const role = item.role === "developer" ? "system" : item.role + + return { + role, + content, + } +} + +function translateContent( + content: string | Array | undefined, +): string | Array | null { + if (!content) return null + if (typeof content === "string") return content + + const textParts = content + .filter( + (part): part is ResponsesContentPart & { text: string } => + (part.type === "input_text" || part.type === "output_text") + && Boolean(part.text), + ) + .map((part) => part.text) + + return textParts.length > 0 ? textParts.join("\n") : null +} + +function translateTools( + tools: ResponsesApiRequest["tools"], +): Array | undefined { + if (!tools || tools.length === 0) return undefined + + const result: Array = [] + for (const tool of tools) { + if (tool.type === "function" && tool.function) { + result.push({ + type: "function", + function: { + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters ?? {}, + }, + }) + } + } + return result.length > 0 ? result : undefined +} + +/** + * Translate Chat Completions response to Responses API format + */ +export function translateChatToResponses( + response: ChatCompletionResponse, + model: string, +): ResponsesApiResponse { + const output: Array = [] + let outputText = "" + + for (const choice of response.choices) { + const message = choice.message + + // Handle text content + if (message.content) { + const textContent = extractTextContent(message.content) + + if (textContent) { + outputText = textContent + output.push({ + id: `msg_${response.id}`, + type: "message", + role: "assistant", + status: "completed", + content: [ + { + type: "output_text", + text: textContent, + }, + ], + }) + } + } + + // Handle tool calls + if (message.tool_calls) { + for (const toolCall of message.tool_calls) { + output.push({ + id: `fc_${toolCall.id}`, + type: "function_call", + status: "completed", + name: toolCall.function.name, + arguments: toolCall.function.arguments, + call_id: toolCall.id, + }) + } + } + } + + return { + id: response.id, + object: "response", + created_at: response.created, + model: response.model || model, + output, + output_text: outputText, + usage: translateUsage(response.usage), + status: "completed", + } +} + +function translateUsage( + usage: ChatCompletionResponse["usage"], +): ResponsesApiResponse["usage"] { + if (!usage) return undefined + + return { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + total_tokens: usage.prompt_tokens + usage.completion_tokens, + } +} + +function extractTextContent( + content: string | Array | null, +): string { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + + return content + .filter( + (p): p is ContentPart & { type: "text"; text: string } => + p.type === "text" && "text" in p, + ) + .map((p) => p.text) + .join("") +} diff --git a/src/routes/responses/types.ts b/src/routes/responses/types.ts new file mode 100644 index 000000000..c8e14467a --- /dev/null +++ b/src/routes/responses/types.ts @@ -0,0 +1,98 @@ +// OpenAI Responses API Types + +export interface ResponsesApiRequest { + model: string + input: string | Array + instructions?: string + tools?: Array + tool_choice?: string | { type: string; function?: { name: string } } + parallel_tool_calls?: boolean + max_output_tokens?: number + temperature?: number + top_p?: number + stream?: boolean + store?: boolean + reasoning?: { effort?: "minimal" | "medium" | "high" } + text?: { + format?: { + type: "json_schema" + name?: string + strict?: boolean + schema?: Record + json_schema?: { + name: string + strict?: boolean + schema: Record + } + } + } +} + +export interface ResponsesInputItem { + role: "system" | "user" | "assistant" | "developer" + content: string | Array + type?: "message" | "tool_result" + tool_call_id?: string + output?: string +} + +export interface ResponsesContentPart { + type: "input_text" | "output_text" | "input_image" + text?: string + image_url?: string +} + +export interface ResponsesTool { + type: "function" | "web_search_preview" | "file_search" + function?: { + name: string + description?: string + parameters?: Record + } +} + +export interface ResponsesApiResponse { + id: string + object: "response" + created_at: number + model: string + output: Array + output_text: string + usage?: ResponsesUsage + status: "completed" | "failed" | "in_progress" +} + +export interface ResponsesUsage { + input_tokens: number + output_tokens: number + total_tokens: number +} + +export interface ResponsesOutputItem { + id: string + type: "message" | "function_call" | "reasoning" + role?: "assistant" + status?: "completed" | "in_progress" + content?: Array + // For function_call type + name?: string + arguments?: string + call_id?: string + // For reasoning type + summary?: Array<{ type: "summary_text"; text: string }> +} + +export interface ResponsesOutputContent { + type: "output_text" | "refusal" + text?: string +} + +// Streaming event types +export interface ResponsesStreamEvent { + type: string + delta?: string + item?: ResponsesOutputItem + output_index?: number + content_index?: number + response?: Partial +} diff --git a/src/server.ts b/src/server.ts index 462a278f3..9d6c7f924 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ 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" @@ -27,5 +28,8 @@ server.route("/v1/chat/completions", completionRoutes) server.route("/v1/models", modelRoutes) server.route("/v1/embeddings", embeddingRoutes) +// OpenAI Responses API endpoint +server.route("/v1/responses", responsesRoutes) + // Anthropic compatible endpoints server.route("/v1/messages", messageRoutes) diff --git a/tests/responses-translation.test.ts b/tests/responses-translation.test.ts new file mode 100644 index 000000000..0540cac8f --- /dev/null +++ b/tests/responses-translation.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, test } from "bun:test" +import { z } from "zod" + +import type { ResponsesApiRequest } from "~/routes/responses/types" + +import { + translateChatToResponses, + translateResponsesToChat, +} from "../src/routes/responses/translation" + +// Zod schema for a single message in the chat completion request. +const messageSchema = z.object({ + role: z.enum([ + "system", + "user", + "assistant", + "tool", + "function", + "developer", + ]), + content: z.union([z.string(), z.object({}), z.array(z.any())]), + name: z.string().optional(), + tool_calls: z.array(z.any()).optional(), + tool_call_id: z.string().optional(), +}) + +// Zod schema for the chat completion request payload. +const chatCompletionRequestSchema = z.object({ + messages: z.array(messageSchema).min(1, "Messages array cannot be empty."), + model: z.string(), + max_tokens: z.number().int().optional().nullable(), + temperature: z.number().min(0).max(2).optional().nullable(), + top_p: z.number().min(0).max(1).optional().nullable(), + stream: z.boolean().optional().nullable(), + tools: z.array(z.any()).optional(), + tool_choice: z.union([z.string(), z.object({})]).optional(), +}) + +function isValidChatCompletionRequest(payload: unknown): boolean { + const result = chatCompletionRequestSchema.safeParse(payload) + return result.success +} + +describe("Responses API to Chat Completions translation", () => { + test("should translate minimal Responses payload with string input", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: "Hello, world!", + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.model).toBe("gpt-5.2") + expect(chatPayload.messages).toHaveLength(1) + expect(chatPayload.messages[0].role).toBe("user") + expect(chatPayload.messages[0].content).toBe("Hello, world!") + }) + + test("should translate Responses payload with instructions as system message", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: "What is 2+2?", + instructions: "You are a helpful math tutor.", + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.messages).toHaveLength(2) + expect(chatPayload.messages[0].role).toBe("system") + expect(chatPayload.messages[0].content).toBe( + "You are a helpful math tutor.", + ) + expect(chatPayload.messages[1].role).toBe("user") + expect(chatPayload.messages[1].content).toBe("What is 2+2?") + }) + + test("should translate Responses payload with array input", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: [ + { role: "user", content: "Hello!" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "How are you?" }, + ], + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.messages).toHaveLength(3) + expect(chatPayload.messages[0].content).toBe("Hello!") + expect(chatPayload.messages[1].content).toBe("Hi there!") + expect(chatPayload.messages[2].content).toBe("How are you?") + }) + + test("should translate Responses payload with content parts", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: [ + { + role: "user", + content: [ + { type: "input_text", text: "First part." }, + { type: "input_text", text: "Second part." }, + ], + }, + ], + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.messages).toHaveLength(1) + expect(chatPayload.messages[0].content).toBe("First part.\nSecond part.") + }) + + test("should translate developer role to system", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: [{ role: "developer", content: "Be concise." }], + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.messages[0].role).toBe("system") + expect(chatPayload.messages[0].content).toBe("Be concise.") + }) + + test("should translate tool_result to tool message", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: [ + { + role: "user", + content: "Get weather", + type: "tool_result", + tool_call_id: "call_123", + output: "Weather is sunny", + }, + ], + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.messages[0].role).toBe("tool") + expect(chatPayload.messages[0].tool_call_id).toBe("call_123") + expect(chatPayload.messages[0].content).toBe("Weather is sunny") + }) + + test("should translate tools to OpenAI format", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: "What's the weather?", + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + }, + }, + }, + ], + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(isValidChatCompletionRequest(chatPayload)).toBe(true) + expect(chatPayload.tools).toHaveLength(1) + expect(chatPayload.tools?.[0].function.name).toBe("get_weather") + expect(chatPayload.tools?.[0].function.description).toBe( + "Get current weather", + ) + }) + + test("should translate tool_choice string values", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: "Hello", + tool_choice: "auto", + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(chatPayload.tool_choice).toBe("auto") + }) + + test("should translate tool_choice function object", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: "Hello", + tool_choice: { type: "function", function: { name: "get_weather" } }, + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(chatPayload.tool_choice).toEqual({ + type: "function", + function: { name: "get_weather" }, + }) + }) + + test("should pass through optional parameters", () => { + const responsesPayload: ResponsesApiRequest = { + model: "gpt-5.2", + input: "Hello", + max_output_tokens: 1000, + temperature: 0.7, + top_p: 0.9, + stream: true, + } + + const chatPayload = translateResponsesToChat(responsesPayload) + expect(chatPayload.max_tokens).toBe(1000) + expect(chatPayload.temperature).toBe(0.7) + expect(chatPayload.top_p).toBe(0.9) + expect(chatPayload.stream).toBe(true) + }) +}) + +describe("Chat Completions to Responses API translation", () => { + test("should translate simple text response", () => { + const chatResponse = { + id: "chatcmpl-123", + object: "chat.completion" as const, + created: 1700000000, + model: "gpt-5.2", + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: "Hello! How can I help you?", + }, + logprobs: null, + finish_reason: "stop" as const, + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 8, + total_tokens: 18, + }, + } + + const responsesResponse = translateChatToResponses(chatResponse, "gpt-5.2") + expect(responsesResponse.id).toBe("chatcmpl-123") + expect(responsesResponse.object).toBe("response") + expect(responsesResponse.model).toBe("gpt-5.2") + expect(responsesResponse.status).toBe("completed") + expect(responsesResponse.output_text).toBe("Hello! How can I help you?") + expect(responsesResponse.output).toHaveLength(1) + expect(responsesResponse.output[0].type).toBe("message") + expect(responsesResponse.usage?.input_tokens).toBe(10) + expect(responsesResponse.usage?.output_tokens).toBe(8) + expect(responsesResponse.usage?.total_tokens).toBe(18) + }) + + test("should translate tool call response", () => { + const chatResponse = { + id: "chatcmpl-456", + object: "chat.completion" as const, + created: 1700000000, + model: "gpt-5.2", + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: null, + tool_calls: [ + { + id: "call_abc123", + type: "function" as const, + function: { + name: "get_weather", + arguments: '{"location":"New York"}', + }, + }, + ], + }, + logprobs: null, + finish_reason: "tool_calls" as const, + }, + ], + } + + const responsesResponse = translateChatToResponses(chatResponse, "gpt-5.2") + expect(responsesResponse.output).toHaveLength(1) + expect(responsesResponse.output[0].type).toBe("function_call") + expect(responsesResponse.output[0].name).toBe("get_weather") + expect(responsesResponse.output[0].arguments).toBe( + '{"location":"New York"}', + ) + expect(responsesResponse.output[0].call_id).toBe("call_abc123") + }) + + test("should translate response with both text and tool calls", () => { + const chatResponse = { + id: "chatcmpl-789", + object: "chat.completion" as const, + created: 1700000000, + model: "gpt-5.2", + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: "Let me check the weather for you.", + tool_calls: [ + { + id: "call_xyz", + type: "function" as const, + function: { + name: "get_weather", + arguments: '{"location":"Boston"}', + }, + }, + ], + }, + logprobs: null, + finish_reason: "tool_calls" as const, + }, + ], + } + + const responsesResponse = translateChatToResponses(chatResponse, "gpt-5.2") + expect(responsesResponse.output).toHaveLength(2) + expect(responsesResponse.output[0].type).toBe("message") + expect(responsesResponse.output[1].type).toBe("function_call") + expect(responsesResponse.output_text).toBe( + "Let me check the weather for you.", + ) + }) + + test("should use fallback model when response model is missing", () => { + const chatResponse = { + id: "chatcmpl-000", + object: "chat.completion" as const, + created: 1700000000, + model: "", + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: "Hello", + }, + logprobs: null, + finish_reason: "stop" as const, + }, + ], + } + + const responsesResponse = translateChatToResponses( + chatResponse, + "fallback-model", + ) + expect(responsesResponse.model).toBe("fallback-model") + }) + + test("should handle response without usage", () => { + const chatResponse = { + id: "chatcmpl-no-usage", + object: "chat.completion" as const, + created: 1700000000, + model: "gpt-5.2", + choices: [ + { + index: 0, + message: { + role: "assistant" as const, + content: "No usage info", + }, + logprobs: null, + finish_reason: "stop" as const, + }, + ], + } + + const responsesResponse = translateChatToResponses(chatResponse, "gpt-5.2") + expect(responsesResponse.usage).toBeUndefined() + }) +}) From a46d446872b4f841830cc45bd84be3f96f6dce83 Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 23:45:58 -0800 Subject: [PATCH 3/7] update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e34d1ef9e..c726f04ec 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint": "eslint --cache", "lint:all": "eslint --cache .", "prepack": "bun run build", - "prepare": "simple-git-hooks", + "prepare": "bunx simple-git-hooks", "release": "bumpp && bun publish --access public", "start": "NODE_ENV=production bun run ./src/main.ts", "typecheck": "tsc" From 38f7d097cec635ab5b93f297a156ac76744f7de4 Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 23:53:05 -0800 Subject: [PATCH 4/7] chore: rename to copilot-proxy-api, update docs --- README.md | 60 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d326192ab..dc36a7f84 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ > > Use this proxy responsibly to avoid account restrictions. -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E519XS7W) - --- **Note:** If you are using [opencode](https://github.com/sst/opencode), you do not need this project. Opencode supports GitHub Copilot provider out of the box. @@ -63,7 +61,7 @@ bun install Build image ```sh -docker build -t copilot-api . +docker build -t copilot-proxy-api . ``` Run the container @@ -75,11 +73,11 @@ mkdir -p ./copilot-data # Run the container with a bind mount to persist the token # This ensures your authentication survives container restarts -docker run -p 4141:4141 -v $(pwd)/copilot-data:/root/.local/share/copilot-api copilot-api +docker run -p 4141:4141 -v $(pwd)/copilot-data:/root/.local/share/copilot-proxy-api copilot-proxy-api ``` > **Note:** -> The GitHub token and related data will be stored in `copilot-data` on your host. This is mapped to `/root/.local/share/copilot-api` inside the container, ensuring persistence across restarts. +> The GitHub token and related data will be stored in `copilot-data` on your host. This is mapped to `/root/.local/share/copilot-proxy-api` inside the container, ensuring persistence across restarts. ### Docker with Environment Variables @@ -87,13 +85,13 @@ You can pass the GitHub token directly to the container using environment variab ```sh # Build with GitHub token -docker build --build-arg GH_TOKEN=your_github_token_here -t copilot-api . +docker build --build-arg GH_TOKEN=your_github_token_here -t copilot-proxy-api . # Run with GitHub token -docker run -p 4141:4141 -e GH_TOKEN=your_github_token_here copilot-api +docker run -p 4141:4141 -e GH_TOKEN=your_github_token_here copilot-proxy-api # Run with additional options -docker run -p 4141:4141 -e GH_TOKEN=your_token copilot-api start --verbose --port 4141 +docker run -p 4141:4141 -e GH_TOKEN=your_token copilot-proxy-api start --verbose --port 4141 ``` ### Docker Compose Example @@ -101,7 +99,7 @@ docker run -p 4141:4141 -e GH_TOKEN=your_token copilot-api start --verbose --por ```yaml version: "3.8" services: - copilot-api: + copilot-proxy-api: build: . ports: - "4141:4141" @@ -122,19 +120,19 @@ The Docker image includes: You can run the project directly using npx: ```sh -npx copilot-api@latest start +npx copilot-proxy-api@latest start ``` With options: ```sh -npx copilot-api@latest start --port 8080 +npx copilot-proxy-api@latest start --port 8080 ``` For authentication only: ```sh -npx copilot-api@latest auth +npx copilot-proxy-api@latest auth ``` ## Command Structure @@ -217,46 +215,46 @@ Using with npx: ```sh # Basic usage with start command -npx copilot-api@latest start +npx copilot-proxy-api@latest start # Run on custom port with verbose logging -npx copilot-api@latest start --port 8080 --verbose +npx copilot-proxy-api@latest start --port 8080 --verbose # Use with a business plan GitHub account -npx copilot-api@latest start --account-type business +npx copilot-proxy-api@latest start --account-type business # Use with an enterprise plan GitHub account -npx copilot-api@latest start --account-type enterprise +npx copilot-proxy-api@latest start --account-type enterprise # Enable manual approval for each request -npx copilot-api@latest start --manual +npx copilot-proxy-api@latest start --manual # Set rate limit to 30 seconds between requests -npx copilot-api@latest start --rate-limit 30 +npx copilot-proxy-api@latest start --rate-limit 30 # Wait instead of error when rate limit is hit -npx copilot-api@latest start --rate-limit 30 --wait +npx copilot-proxy-api@latest start --rate-limit 30 --wait # Provide GitHub token directly -npx copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE +npx copilot-proxy-api@latest start --github-token ghp_YOUR_TOKEN_HERE # Run only the auth flow -npx copilot-api@latest auth +npx copilot-proxy-api@latest auth # Run auth flow with verbose logging -npx copilot-api@latest auth --verbose +npx copilot-proxy-api@latest auth --verbose # Show your Copilot usage/quota in the terminal (no server needed) -npx copilot-api@latest check-usage +npx copilot-proxy-api@latest check-usage # Display debug information for troubleshooting -npx copilot-api@latest debug +npx copilot-proxy-api@latest debug # Display debug information in JSON format -npx copilot-api@latest debug --json +npx copilot-proxy-api@latest debug --json # Initialize proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, etc.) -npx copilot-api@latest start --proxy-env +npx copilot-proxy-api@latest start --proxy-env ``` ## Using the Usage Viewer @@ -265,7 +263,7 @@ After starting the server, a URL to the Copilot Usage Dashboard will be displaye 1. Start the server. For example, using npx: ```sh - npx copilot-api@latest start + npx copilot-proxy-api@latest start ``` 2. The server will output a URL to the usage viewer. Copy and paste this URL into your browser. It will look something like this: `https://ericc-ch.github.io/copilot-api?endpoint=http://localhost:4141/usage` @@ -291,7 +289,7 @@ There are two ways to configure Claude Code to use this proxy: To get started, run the `start` command with the `--claude-code` flag: ```sh -npx copilot-api@latest start --claude-code +npx copilot-proxy-api@latest start --claude-code ``` You will be prompted to select a primary model and a "small, fast" model for background tasks. After selecting the models, a command will be copied to your clipboard. This command sets the necessary environment variables for Claude Code to use the proxy. @@ -341,7 +339,7 @@ model = "gpt-5.2" model_provider = "copilot-proxy" [model_providers.copilot-proxy] -name = "GitHub Copilot (via copilot-api)" +name = "GitHub Copilot (via copilot-proxy-api)" base_url = "http://localhost:4141/v1" wire_api = "responses" env_key = "OPENAI_API_KEY" @@ -361,7 +359,7 @@ Start the proxy server and run Codex: ```sh # Start the proxy -npx copilot-api@latest start +npx copilot-proxy-api@latest start # In another terminal, run Codex codex @@ -387,6 +385,6 @@ bun run start - To avoid hitting GitHub Copilot's rate limits, you can use the following flags: - `--manual`: Enables manual approval for each request, giving you full control over when requests are sent. - - `--rate-limit `: Enforces a minimum time interval between requests. For example, `copilot-api start --rate-limit 30` will ensure there's at least a 30-second gap between requests. + - `--rate-limit `: Enforces a minimum time interval between requests. For example, `copilot-proxy-api start --rate-limit 30` will ensure there's at least a 30-second gap between requests. - `--wait`: Use this with `--rate-limit`. It makes the server wait for the cooldown period to end instead of rejecting the request with an error. This is useful for clients that don't automatically retry on rate limit errors. - If you have a GitHub business or enterprise plan account with Copilot, use the `--account-type` flag (e.g., `--account-type business`). See the [official documentation](https://docs.github.com/en/enterprise-cloud@latest/copilot/managing-copilot/managing-github-copilot-in-your-organization/managing-access-to-github-copilot-in-your-organization/managing-github-copilot-access-to-your-organizations-network#configuring-copilot-subscription-based-network-routing-for-your-enterprise-or-organization) for more details. From c2ce8a2f056361ff9e421624385a7dfab957bfac Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 23:54:11 -0800 Subject: [PATCH 5/7] chore: rename to copilot-proxy-api, update docs --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c726f04ec..8dfbfb843 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot-proxy-api", - "version": "0.7.0", + "version": "0.7.1", "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code and Codex CLI!", "keywords": [ "proxy", From 7a0034cc4c81e81eb0ff8d6b94ee39ab68f31737 Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 23:58:51 -0800 Subject: [PATCH 6/7] docs: update Claude Code config with latest models --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dc36a7f84..446c58936 100644 --- a/README.md +++ b/README.md @@ -305,12 +305,10 @@ Here is an example `.claude/settings.json` file: ```json { "env": { - "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_BASE_URL": "http://localhost:4141/", "ANTHROPIC_AUTH_TOKEN": "dummy", - "ANTHROPIC_MODEL": "gpt-4.1", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-4.1", - "ANTHROPIC_SMALL_FAST_MODEL": "gpt-4.1", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-4.1", + "ANTHROPIC_MODEL": "claude-opus-4.5", + "ANTHROPIC_SMALL_FAST_MODEL": "claude-sonnet-4.5", "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" }, From f66b00d4fb2cc49cfca49bbd721011547cf640b0 Mon Sep 17 00:00:00 2001 From: Yujun Liu Date: Mon, 19 Jan 2026 23:59:25 -0800 Subject: [PATCH 7/7] docs: update Claude Code config with latest models --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8dfbfb843..7f8c6f283 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "copilot-proxy-api", - "version": "0.7.1", + "version": "0.7.2", "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code and Codex CLI!", "keywords": [ "proxy",