From 7746ae15f2ed4602365249c5d930ed211ab1f2ac Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:11:35 +1100 Subject: [PATCH 01/10] docs: rebrand as actively maintained fork - Add fork notice explaining this is an actively maintained fork - Remove ko-fi donation link - Update usage viewer URLs to jacks0n.github.io - Add "Changes from Original Repository" section with planned PRs --- README.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d36c13c9..ffb8aebec 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # Copilot API Proxy +> [!NOTE] +> **Actively Maintained Fork** +> This is an actively maintained fork of [ericc-ch/copilot-api](https://github.com/ericc-ch/copilot-api). The original repository has been unmaintained since November 2025, with 22 open PRs waiting for review. This fork integrates community contributions and continues development. See [Changes from Original Repository](#changes-from-original-repository) for details. + > [!WARNING] > This is a reverse-engineered proxy of GitHub Copilot API. It is not supported by GitHub, and may break unexpectedly. Use at your own risk. > [!WARNING] -> **GitHub Security Notice:** -> Excessive automated or scripted use of Copilot (including rapid or bulk requests, such as via automated tools) may trigger GitHub's abuse-detection systems. +> **GitHub Security Notice:** +> Excessive automated or scripted use of Copilot (including rapid or bulk requests, such as via automated tools) may trigger GitHub's abuse-detection systems. > You may receive a warning from GitHub Security, and further anomalous activity could result in temporary suspension of your Copilot access. > > GitHub prohibits use of their servers for excessive automated bulk activity or any activity that places undue burden on their infrastructure. @@ -17,8 +21,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. @@ -266,7 +268,7 @@ After starting the server, a URL to the Copilot Usage Dashboard will be displaye npx copilot-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` + `https://jacks0n.github.io/copilot-api?endpoint=http://localhost:4141/usage` - If you use the `start.bat` script on Windows, this page will open automatically. The dashboard provides a user-friendly interface to view your Copilot usage data: @@ -276,7 +278,7 @@ The dashboard provides a user-friendly interface to view your Copilot usage data - **Usage Quotas**: View a summary of your usage quotas for different services like Chat and Completions, displayed with progress bars for a quick overview. - **Detailed Information**: See the full JSON response from the API for a detailed breakdown of all available usage statistics. - **URL-based Configuration**: You can also specify the API endpoint directly in the URL using a query parameter. This is useful for bookmarks or sharing links. For example: - `https://ericc-ch.github.io/copilot-api?endpoint=http://your-api-server/usage` + `https://jacks0n.github.io/copilot-api?endpoint=http://your-api-server/usage` ## Using with Claude Code @@ -349,3 +351,25 @@ bun run start - `--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. - `--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. + +## Changes from Original Repository + +This fork includes the following improvements over the original [ericc-ch/copilot-api](https://github.com/ericc-ch/copilot-api): + +### New Features + +- **GitHub Enterprise Support** - Use with GitHub Enterprise Server/Cloud instances ([#128](https://github.com/ericc-ch/copilot-api/pull/128)) +- **API Key Authentication** - Secure your proxy with multiple API keys ([#144](https://github.com/ericc-ch/copilot-api/pull/144)) +- **Host Binding** - Restrict server to specific network interface via `--host` ([#157](https://github.com/ericc-ch/copilot-api/pull/157)) +- **Claude Thinking/Reasoning** - Support for Claude model thinking blocks ([#167](https://github.com/ericc-ch/copilot-api/pull/167)) +- **Prometheus Metrics** - Monitoring via `/metrics` endpoint ([#132](https://github.com/ericc-ch/copilot-api/pull/132)) +- **Responses API** - Support for `/v1/responses` endpoint ([#170](https://github.com/ericc-ch/copilot-api/pull/170)) +- **Event Logging Endpoint** - Stub for Anthropic event logging ([#165](https://github.com/ericc-ch/copilot-api/pull/165)) + +### Bug Fixes + +- Fix tool 400 error when schema missing properties ([#192](https://github.com/ericc-ch/copilot-api/pull/192)) +- Fix non-streaming response object type for pydantic_ai ([#185](https://github.com/ericc-ch/copilot-api/pull/185)) +- Fix model name translation for dated models like `claude-sonnet-4-5-20250929` ([#180](https://github.com/ericc-ch/copilot-api/pull/180)) +- Filter Anthropic reserved keywords from system prompts ([#175](https://github.com/ericc-ch/copilot-api/pull/175)) +- Fix OpenAI tool name length limit (64 chars max) ([#69](https://github.com/ericc-ch/copilot-api/pull/69)) From a5c10ba5653f08446384763c1152af37b33bdeeb Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:12:22 +1100 Subject: [PATCH 02/10] docs: use repository URL in Docker build examples Replace local folder building with direct link to repo master branch. PR: ericc-ch/copilot-api#172 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ffb8aebec..8b490874f 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ bun install Build image ```sh -docker build -t copilot-api . +docker build -t copilot-api https://github.com/jacks0n/copilot-api.git#master ``` Run the container @@ -88,7 +88,7 @@ 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-api https://github.com/jacks0n/copilot-api.git#master # Run with GitHub token docker run -p 4141:4141 -e GH_TOKEN=your_github_token_here copilot-api From 47fa289d5d5a71be1451a18766d98c3a96a9929b Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:12:46 +1100 Subject: [PATCH 03/10] fix: set response object type for non-streaming responses Needed for pydantic_ai to work properly with the API. PR: ericc-ch/copilot-api#185 --- src/routes/chat-completions/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 04a5ae9ed..527d83849 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -50,6 +50,7 @@ export async function handleCompletion(c: Context) { const response = await createChatCompletions(payload) if (isNonStreaming(response)) { + response.object = "chat.completion" consola.debug("Non-streaming response:", JSON.stringify(response)) return c.json(response) } From 086c48bec13e2551241d17ed5d231ddb3082097f Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:13:27 +1100 Subject: [PATCH 04/10] feat: add Anthropic event logging endpoint Add /api/event_logging/batch endpoint that returns 200 OK. This is a stub for Anthropic telemetry logging. PR: ericc-ch/copilot-api#165 --- README.md | 1 + src/routes/event-logging/route.ts | 7 +++++++ src/server.ts | 2 ++ 3 files changed, 10 insertions(+) create mode 100644 src/routes/event-logging/route.ts diff --git a/README.md b/README.md index 8b490874f..f208a988e 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ These endpoints are designed to be compatible with the Anthropic Messages API. | -------------------------------- | ------ | ------------------------------------------------------------ | | `POST /v1/messages` | `POST` | Creates a model response for a given conversation. | | `POST /v1/messages/count_tokens` | `POST` | Calculates the number of tokens for a given set of messages. | +| `POST /api/event_logging/batch` | `POST` | Anthropic telemetry log endpoint (stub returning 200). | ### Usage Monitoring Endpoints diff --git a/src/routes/event-logging/route.ts b/src/routes/event-logging/route.ts new file mode 100644 index 000000000..fd10db160 --- /dev/null +++ b/src/routes/event-logging/route.ts @@ -0,0 +1,7 @@ +import { Hono } from "hono" + +export const eventLoggingRoutes = new Hono() + +eventLoggingRoutes.post("/batch", (c) => { + return c.text("OK", 200) +}) diff --git a/src/server.ts b/src/server.ts index 462a278f3..c2f2346c8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { logger } from "hono/logger" import { completionRoutes } from "./routes/chat-completions/route" import { embeddingRoutes } from "./routes/embeddings/route" +import { eventLoggingRoutes } from "./routes/event-logging/route" import { messageRoutes } from "./routes/messages/route" import { modelRoutes } from "./routes/models/route" import { tokenRoute } from "./routes/token/route" @@ -29,3 +30,4 @@ server.route("/v1/embeddings", embeddingRoutes) // Anthropic compatible endpoints server.route("/v1/messages", messageRoutes) +server.route("/api/event_logging", eventLoggingRoutes) From 115818cfbf1aabb79aa44c574f25c6bae430a704 Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:14:23 +1100 Subject: [PATCH 05/10] fix: filter Anthropic reserved keywords from system prompts Filter out Anthropic-specific headers like x-anthropic-billing-header and x-anthropic-billing from system prompts before sending to Copilot API. These keywords are internal to Anthropic's billing system and should not be forwarded to the Copilot backend, as they may cause unexpected behavior or errors. Fixes ericc-ch/copilot-api#174 PR: ericc-ch/copilot-api#175 --- src/routes/messages/non-stream-translation.ts | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e6382..a7a053c89 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -71,6 +71,22 @@ function translateAnthropicMessagesToOpenAI( return [...systemMessages, ...otherMessages] } +const ANTHROPIC_RESERVED_KEYWORDS = [ + "x-anthropic-billing-header", + "x-anthropic-billing", +] + +function filterAnthropicReservedContent(text: string): string { + let filtered = text + for (const keyword of ANTHROPIC_RESERVED_KEYWORDS) { + filtered = filtered + .split("\n") + .filter((line) => !line.includes(keyword)) + .join("\n") + } + return filtered +} + function handleSystemPrompt( system: string | Array | undefined, ): Array { @@ -78,12 +94,19 @@ function handleSystemPrompt( return [] } - if (typeof system === "string") { - return [{ role: "system", content: system }] - } else { - const systemText = system.map((block) => block.text).join("\n\n") - return [{ role: "system", content: systemText }] + let systemText: string + systemText = + typeof system === "string" + ? system + : system.map((block) => block.text).join("\n\n") + + systemText = filterAnthropicReservedContent(systemText) + + if (systemText.trim().length === 0) { + return [] } + + return [{ role: "system", content: systemText }] } function handleUserMessage(message: AnthropicUserMessage): Array { From 94993428c728c79e77a2ba5cd51c622312f22070 Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:15:00 +1100 Subject: [PATCH 06/10] fix: translate dated model names like claude-sonnet-4-5-20250929 Add regex patterns to translate dated model names to their base versions: - claude-opus-4-5-YYYYMMDD -> claude-opus-4.5 - claude-sonnet-4-5-YYYYMMDD -> claude-sonnet-4.5 - claude-haiku-4-5-YYYYMMDD -> claude-haiku-4.5 Includes tests for the new model name translations. PR: ericc-ch/copilot-api#180 --- src/routes/messages/non-stream-translation.ts | 9 ++++-- tests/anthropic-request.test.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index a7a053c89..cfecb92fa 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -47,8 +47,13 @@ export function translateToOpenAI( } function translateModelName(model: string): string { - // Subagent requests use a specific model number which Copilot doesn't support - if (model.startsWith("claude-sonnet-4-")) { + if (/^claude-opus-4-5-\d{8}$/.test(model)) { + return "claude-opus-4.5" + } else if (/^claude-sonnet-4-5-\d{8}$/.test(model)) { + return "claude-sonnet-4.5" + } else if (/^claude-haiku-4-5-\d{8}$/.test(model)) { + return "claude-haiku-4.5" + } else 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") diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 06c663778..4d467869e 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -197,6 +197,36 @@ describe("Anthropic to OpenAI translation logic", () => { expect(assistantMessage?.tool_calls).toHaveLength(1) expect(assistantMessage?.tool_calls?.[0].function.name).toBe("get_weather") }) + + test("should translate claude-opus-4-5-20251101 to claude-opus-4.5", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4-5-20251101", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 100, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(openAIPayload.model).toBe("claude-opus-4.5") + }) + + test("should translate claude-sonnet-4-5-20250929 to claude-sonnet-4.5", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 100, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(openAIPayload.model).toBe("claude-sonnet-4.5") + }) + + test("should translate claude-haiku-4-5-20250929 to claude-haiku-4.5", () => { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-haiku-4-5-20250929", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 100, + } + const openAIPayload = translateToOpenAI(anthropicPayload) + expect(openAIPayload.model).toBe("claude-haiku-4.5") + }) }) describe("OpenAI Chat Completion v1 Request Payload Validation with Zod", () => { From 343073f2841db330cada0e7b00c9e148bd170262 Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:15:20 +1100 Subject: [PATCH 07/10] fix: normalize tool parameter schema to avoid 400 error Normalize tool parameter schema so that any object-type tool input has a properties field. This avoids the Copilot 400 error: "object schema missing properties". PR: ericc-ch/copilot-api#192 --- src/routes/messages/non-stream-translation.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index cfecb92fa..9bd3f13a4 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -267,11 +267,26 @@ function translateAnthropicToolsToOpenAI( function: { name: tool.name, description: tool.description, - parameters: tool.input_schema, + parameters: normalizeToolParameters(tool.input_schema), }, })) } +function normalizeToolParameters( + inputSchema: unknown, +): Record { + if (!inputSchema || typeof inputSchema !== "object") { + return { type: "object", properties: {} } + } + + const schema = { ...inputSchema } as Record + if (schema.type === "object" && schema.properties === undefined) { + return { ...schema, properties: {} } + } + + return schema +} + function translateAnthropicToolChoiceToOpenAI( anthropicToolChoice: AnthropicMessagesPayload["tool_choice"], ): ChatCompletionsPayload["tool_choice"] { From 1d8a1cc9f5a4c6f1e07274e09db16e1f9f7a2ef5 Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:16:42 +1100 Subject: [PATCH 08/10] feat: add --host option to restrict listening interface Add -H/--host option to specify which host/interface to listen on. Useful for restricting access to localhost only for security. Examples: --host 127.0.0.1 # IPv4 localhost only --host ::1 # IPv6 localhost only PR: ericc-ch/copilot-api#157 --- README.md | 21 +++++++++++++++++++++ src/start.ts | 9 +++++++++ 2 files changed, 30 insertions(+) diff --git a/README.md b/README.md index f208a988e..6ea9b0326 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ The following command line options are available for the `start` command: | Option | Description | Default | Alias | | -------------- | ----------------------------------------------------------------------------- | ---------- | ----- | | --port | Port to listen on | 4141 | -p | +| --host | Host/interface to listen on (e.g., 127.0.0.1 for IPv4, ::1 for IPv6) | all (::) | -H | | --verbose | Enable verbose logging | false | -v | | --account-type | Account type to use (individual, business, enterprise) | individual | -a | | --manual | Enable manual request approval | false | none | @@ -166,6 +167,26 @@ The following command line options are available for the `start` command: | --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none | | --proxy-env | Initialize proxy from environment variables | false | none | +#### Usage examples with --host + +Listen on IPv4 localhost only: + +```sh +npx copilot-api@latest start --account-type business --host 127.0.0.1 +``` + +Listen on IPv6 localhost only: + +```sh +npx copilot-api@latest start --account-type business --host ::1 +``` + +Default (listen on all interfaces): + +```sh +npx copilot-api@latest start --account-type business +``` + ### Auth Command Options | Option | Description | Default | Alias | diff --git a/src/start.ts b/src/start.ts index 14abbbdff..9478455de 100644 --- a/src/start.ts +++ b/src/start.ts @@ -16,6 +16,7 @@ import { server } from "./server" interface RunServerOptions { port: number + host?: string verbose: boolean accountType: string manual: boolean @@ -117,6 +118,7 @@ export async function runServer(options: RunServerOptions): Promise { serve({ fetch: server.fetch as ServerHandler, port: options.port, + hostname: options.host, }) } @@ -138,6 +140,12 @@ export const start = defineCommand({ default: false, description: "Enable verbose logging", }, + host: { + alias: "H", + type: "string", + description: + "Host/interface to listen on (e.g., 127.0.0.1 for IPv4 localhost, ::1 for IPv6 localhost)", + }, "account-type": { alias: "a", type: "string", @@ -193,6 +201,7 @@ export const start = defineCommand({ return runServer({ port: Number.parseInt(args.port, 10), + host: args.host, verbose: args.verbose, accountType: args["account-type"], manual: args.manual, From 92ff5118bc926ca430f800b540c37921680fd223 Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:18:51 +1100 Subject: [PATCH 09/10] feat: support specifying multiple API keys for authentication Add API key authentication middleware with: - Support for OpenAI format (Authorization: Bearer token) - Support for Anthropic format (x-api-key: token) - Constant time comparison for security - Multiple API keys via --api-key flag When API keys are configured, all API endpoints require authentication. The root endpoint / remains accessible without authentication. PR: ericc-ch/copilot-api#144 --- README.md | 45 +++++++++++++++++++++++++++++++++++++++ src/lib/api-key-auth.ts | 47 +++++++++++++++++++++++++++++++++++++++++ src/lib/state.ts | 3 +++ src/lib/utils.ts | 11 ++++++++++ src/server.ts | 11 ++++++++++ src/start.ts | 19 +++++++++++++++++ 6 files changed, 136 insertions(+) create mode 100644 src/lib/api-key-auth.ts diff --git a/README.md b/README.md index 6ea9b0326..2e746595e 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ The following command line options are available for the `start` command: | --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c | | --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none | | --proxy-env | Initialize proxy from environment variables | false | none | +| --api-key | API keys for authentication. Can be specified multiple times | none | none | #### Usage examples with --host @@ -233,6 +234,44 @@ New endpoints for monitoring your Copilot usage and quotas. | `GET /usage` | `GET` | Get detailed Copilot usage statistics and quota information. | | `GET /token` | `GET` | Get the current Copilot token being used by the API. | +## API Key Authentication + +The proxy supports API key authentication to restrict access to the endpoints. When API keys are configured, all API endpoints require authentication. + +### Authentication Methods + +The proxy supports both OpenAI and Anthropic authentication formats: + +- **OpenAI format**: Include the API key in the `Authorization` header with `Bearer` prefix: + + ```bash + curl -H "Authorization: Bearer your_api_key_here" http://localhost:4141/v1/models + ``` + +- **Anthropic format**: Include the API key in the `x-api-key` header: + + ```bash + curl -H "x-api-key: your_api_key_here" http://localhost:4141/v1/messages + ``` + +### Configuration + +Use the `--api-key` flag to enable API key authentication. You can specify multiple keys for different clients: + +```bash +# Single API key +npx copilot-api@latest start --api-key your_secret_key + +# Multiple API keys +npx copilot-api@latest start --api-key key1 --api-key key2 --api-key key3 +``` + +When API keys are configured: + +- All API endpoints (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`, `/v1/messages`, `/usage`, `/token`) require authentication +- Requests without valid API keys will receive a 401 Unauthorized response +- The root endpoint `/` remains accessible without authentication + ## Example Usage Using with npx: @@ -262,6 +301,12 @@ npx copilot-api@latest start --rate-limit 30 --wait # Provide GitHub token directly npx copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE +# Enable API key authentication with a single key +npx copilot-api@latest start --api-key your_secret_key_here + +# Enable API key authentication with multiple keys +npx copilot-api@latest start --api-key key1 --api-key key2 --api-key key3 + # Run only the auth flow npx copilot-api@latest auth diff --git a/src/lib/api-key-auth.ts b/src/lib/api-key-auth.ts new file mode 100644 index 000000000..2a3f5aa7f --- /dev/null +++ b/src/lib/api-key-auth.ts @@ -0,0 +1,47 @@ +import type { Context, MiddlewareHandler } from "hono" + +import { HTTPException } from "hono/http-exception" + +import { state } from "./state" +import { constantTimeEqual } from "./utils" + +function extractApiKey(c: Context): string | undefined { + const authHeader = c.req.header("authorization") + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice(7) + } + + const anthropicKey = c.req.header("x-api-key") + if (anthropicKey) { + return anthropicKey + } + + return undefined +} + +export const apiKeyAuthMiddleware: MiddlewareHandler = async (c, next) => { + if (!state.apiKeys || state.apiKeys.length === 0) { + await next() + return + } + + const providedKey = extractApiKey(c) + + if (!providedKey) { + throw new HTTPException(401, { + message: "Missing API key", + }) + } + + const isValidKey = state.apiKeys.some((key) => + constantTimeEqual(key, providedKey), + ) + + if (!isValidKey) { + throw new HTTPException(401, { + message: "Invalid API key", + }) + } + + await next() +} diff --git a/src/lib/state.ts b/src/lib/state.ts index 5ba4dc1d1..b611c0e93 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -15,6 +15,9 @@ export interface State { // Rate limiting configuration rateLimitSeconds?: number lastRequestTimestamp?: number + + // API key validation + apiKeys?: Array } export const state: State = { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index cc80be667..dff6bf463 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -24,3 +24,14 @@ export const cacheVSCodeVersion = async () => { consola.info(`Using VSCode version: ${response}`) } + +export const constantTimeEqual = (a: string, b: string): boolean => { + if (a.length !== b.length) { + return false + } + let result = 0 + for (let i = 0; i < a.length; i++) { + result |= (a.codePointAt(i) ?? 0) ^ (b.codePointAt(i) ?? 0) + } + return result === 0 +} diff --git a/src/server.ts b/src/server.ts index c2f2346c8..e387c2d59 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { Hono } from "hono" import { cors } from "hono/cors" import { logger } from "hono/logger" +import { apiKeyAuthMiddleware } from "./lib/api-key-auth" import { completionRoutes } from "./routes/chat-completions/route" import { embeddingRoutes } from "./routes/embeddings/route" import { eventLoggingRoutes } from "./routes/event-logging/route" @@ -15,6 +16,16 @@ export const server = new Hono() server.use(logger()) server.use(cors()) +server.use("/chat/completions", apiKeyAuthMiddleware) +server.use("/models", apiKeyAuthMiddleware) +server.use("/embeddings", apiKeyAuthMiddleware) +server.use("/usage", apiKeyAuthMiddleware) +server.use("/token", apiKeyAuthMiddleware) +server.use("/v1/chat/completions", apiKeyAuthMiddleware) +server.use("/v1/models", apiKeyAuthMiddleware) +server.use("/v1/embeddings", apiKeyAuthMiddleware) +server.use("/v1/messages", apiKeyAuthMiddleware) + server.get("/", (c) => c.text("Server running")) server.route("/chat/completions", completionRoutes) diff --git a/src/start.ts b/src/start.ts index 9478455de..1ea4cf456 100644 --- a/src/start.ts +++ b/src/start.ts @@ -26,6 +26,7 @@ interface RunServerOptions { claudeCode: boolean showToken: boolean proxyEnv: boolean + apiKeys?: Array } export async function runServer(options: RunServerOptions): Promise { @@ -47,6 +48,13 @@ export async function runServer(options: RunServerOptions): Promise { state.rateLimitSeconds = options.rateLimit state.rateLimitWait = options.rateLimitWait state.showToken = options.showToken + state.apiKeys = options.apiKeys + + if (state.apiKeys && state.apiKeys.length > 0) { + consola.info( + `API key authentication enabled with ${state.apiKeys.length} key(s)`, + ) + } await ensurePaths() await cacheVSCodeVersion() @@ -192,6 +200,10 @@ export const start = defineCommand({ default: false, description: "Initialize proxy from environment variables", }, + "api-key": { + type: "string", + description: "API keys for authentication", + }, }, run({ args }) { const rateLimitRaw = args["rate-limit"] @@ -199,6 +211,12 @@ export const start = defineCommand({ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition rateLimitRaw === undefined ? undefined : Number.parseInt(rateLimitRaw, 10) + const apiKeyRaw = args["api-key"] + let apiKeys: Array | undefined + if (apiKeyRaw) { + apiKeys = Array.isArray(apiKeyRaw) ? apiKeyRaw : [apiKeyRaw] + } + return runServer({ port: Number.parseInt(args.port, 10), host: args.host, @@ -211,6 +229,7 @@ export const start = defineCommand({ claudeCode: args["claude-code"], showToken: args["show-token"], proxyEnv: args["proxy-env"], + apiKeys, }) }, }) From 3d048495490aa87804edd540779856f0cab80ff7 Mon Sep 17 00:00:00 2001 From: Jackson Cooper Date: Fri, 13 Feb 2026 10:21:48 +1100 Subject: [PATCH 10/10] feat: add GitHub Enterprise Server/Cloud support Add support for GitHub Enterprise instances via --enterprise-url flag. The enterprise URL is persisted to disk for reuse across sessions. Changes: - Add new url.ts with normalizeDomain, githubBaseUrl, githubApiBaseUrl - Convert GITHUB_BASE_URL and GITHUB_API_BASE_URL to functions - Update all GitHub API calls to use dynamic base URLs - Add enterprise_url persistence in token.ts - Pass enterprise URL to setupGitHubToken Usage: --enterprise-url ghe.example.com PR: ericc-ch/copilot-api#128 --- README.md | 1 + src/lib/api-config.ts | 20 ++++++++++++++------ src/lib/paths.ts | 3 +++ src/lib/state.ts | 2 ++ src/lib/token.ts | 21 +++++++++++++++++++++ src/lib/url.ts | 16 ++++++++++++++++ src/services/github/get-copilot-token.ts | 2 +- src/services/github/get-copilot-usage.ts | 9 ++++++--- src/services/github/get-device-code.ts | 2 +- src/services/github/get-user.ts | 2 +- src/services/github/poll-access-token.ts | 2 +- src/start.ts | 10 +++++++++- 12 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 src/lib/url.ts diff --git a/README.md b/README.md index 2e746595e..f96d8e7c3 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ The following command line options are available for the `start` command: | --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none | | --proxy-env | Initialize proxy from environment variables | false | none | | --api-key | API keys for authentication. Can be specified multiple times | none | none | +| --enterprise-url | GitHub Enterprise host (eg. ghe.example.com) | none | none | #### Usage examples with --host diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 83bce92ad..6afed5c12 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto" import type { State } from "./state" +import { state } from "./state" +import { githubApiBaseUrl, githubBaseUrl } from "./url" + export const standardHeaders = () => ({ "content-type": "application/json", accept: "application/json", @@ -13,10 +16,15 @@ const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}` const API_VERSION = "2025-04-01" -export const copilotBaseUrl = (state: State) => - state.accountType === "individual" ? - "https://api.githubcopilot.com" - : `https://api.${state.accountType}.githubcopilot.com` +export const copilotBaseUrl = (st: State) => { + if (st.enterpriseUrl) { + return `https://copilot-api.${st.enterpriseUrl}` + } + + return st.accountType === "individual" + ? "https://api.githubcopilot.com" + : `https://api.${st.accountType}.githubcopilot.com` +} export const copilotHeaders = (state: State, vision: boolean = false) => { const headers: Record = { Authorization: `Bearer ${state.copilotToken}`, @@ -36,7 +44,7 @@ export const copilotHeaders = (state: State, vision: boolean = false) => { return headers } -export const GITHUB_API_BASE_URL = "https://api.github.com" +export const GITHUB_API_BASE_URL = () => githubApiBaseUrl(state.enterpriseUrl) export const githubHeaders = (state: State) => ({ ...standardHeaders(), authorization: `token ${state.githubToken}`, @@ -47,6 +55,6 @@ export const githubHeaders = (state: State) => ({ "x-vscode-user-agent-library-version": "electron-fetch", }) -export const GITHUB_BASE_URL = "https://github.com" +export const GITHUB_BASE_URL = () => githubBaseUrl(state.enterpriseUrl) export const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98" export const GITHUB_APP_SCOPES = ["read:user"].join(" ") diff --git a/src/lib/paths.ts b/src/lib/paths.ts index 8d0a9f02b..9427df91e 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -5,15 +5,18 @@ import path from "node:path" const APP_DIR = path.join(os.homedir(), ".local", "share", "copilot-api") const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token") +const ENTERPRISE_URL_PATH = path.join(APP_DIR, "enterprise_url") export const PATHS = { APP_DIR, GITHUB_TOKEN_PATH, + ENTERPRISE_URL_PATH, } export async function ensurePaths(): Promise { await fs.mkdir(PATHS.APP_DIR, { recursive: true }) await ensureFile(PATHS.GITHUB_TOKEN_PATH) + await ensureFile(PATHS.ENTERPRISE_URL_PATH) } async function ensureFile(filePath: string): Promise { diff --git a/src/lib/state.ts b/src/lib/state.ts index b611c0e93..d303bd3ee 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -3,6 +3,7 @@ import type { ModelsResponse } from "~/services/copilot/get-models" export interface State { githubToken?: string copilotToken?: string + enterpriseUrl?: string accountType: string models?: ModelsResponse @@ -25,4 +26,5 @@ export const state: State = { manualApprove: false, rateLimitWait: false, showToken: false, + enterpriseUrl: undefined, } diff --git a/src/lib/token.ts b/src/lib/token.ts index fc8d2785f..5c63d7cb6 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -15,6 +15,19 @@ const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8") const writeGithubToken = (token: string) => fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token) +const readEnterpriseUrl = async () => { + try { + const txt = await fs.readFile(PATHS.ENTERPRISE_URL_PATH, "utf8") + const trimmed = txt.trim() + return trimmed || undefined + } catch { + return undefined + } +} + +const writeEnterpriseUrl = (url?: string) => + fs.writeFile(PATHS.ENTERPRISE_URL_PATH, url || "") + export const setupCopilotToken = async () => { const { token, refresh_in } = await getCopilotToken() state.copilotToken = token @@ -44,6 +57,7 @@ export const setupCopilotToken = async () => { interface SetupGitHubTokenOptions { force?: boolean + enterpriseUrl?: string } export async function setupGitHubToken( @@ -51,6 +65,8 @@ export async function setupGitHubToken( ): Promise { try { const githubToken = await readGithubToken() + const persistedEnterprise = await readEnterpriseUrl() + if (persistedEnterprise) state.enterpriseUrl = persistedEnterprise if (githubToken && !options?.force) { state.githubToken = githubToken @@ -63,6 +79,10 @@ export async function setupGitHubToken( } consola.info("Not logged in, getting new access token") + + const enterpriseFromOptions = options?.enterpriseUrl + if (enterpriseFromOptions) state.enterpriseUrl = enterpriseFromOptions + const response = await getDeviceCode() consola.debug("Device code response:", response) @@ -72,6 +92,7 @@ export async function setupGitHubToken( const token = await pollAccessToken(response) await writeGithubToken(token) + await writeEnterpriseUrl(state.enterpriseUrl) state.githubToken = token if (state.showToken) { diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 000000000..44a8ab593 --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,16 @@ +export function normalizeDomain(url: string | undefined): string | undefined { + if (!url) return undefined + return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") +} + +export function githubBaseUrl(enterprise?: string): string { + if (!enterprise) return "https://github.com" + const domain = normalizeDomain(enterprise) + return `https://${domain}` +} + +export function githubApiBaseUrl(enterprise?: string): string { + if (!enterprise) return "https://api.github.com" + const domain = normalizeDomain(enterprise) + return `https://api.${domain}` +} diff --git a/src/services/github/get-copilot-token.ts b/src/services/github/get-copilot-token.ts index 98744bab1..fb5febdef 100644 --- a/src/services/github/get-copilot-token.ts +++ b/src/services/github/get-copilot-token.ts @@ -4,7 +4,7 @@ import { state } from "~/lib/state" export const getCopilotToken = async () => { const response = await fetch( - `${GITHUB_API_BASE_URL}/copilot_internal/v2/token`, + `${GITHUB_API_BASE_URL()}/copilot_internal/v2/token`, { headers: githubHeaders(state), }, diff --git a/src/services/github/get-copilot-usage.ts b/src/services/github/get-copilot-usage.ts index 6cdd8bc10..28d7c0f35 100644 --- a/src/services/github/get-copilot-usage.ts +++ b/src/services/github/get-copilot-usage.ts @@ -3,9 +3,12 @@ import { HTTPError } from "~/lib/error" import { state } from "~/lib/state" export const getCopilotUsage = async (): Promise => { - const response = await fetch(`${GITHUB_API_BASE_URL}/copilot_internal/user`, { - headers: githubHeaders(state), - }) + const response = await fetch( + `${GITHUB_API_BASE_URL()}/copilot_internal/user`, + { + headers: githubHeaders(state), + }, + ) if (!response.ok) { throw new HTTPError("Failed to get Copilot usage", response) diff --git a/src/services/github/get-device-code.ts b/src/services/github/get-device-code.ts index cf35f4ec9..06d04080c 100644 --- a/src/services/github/get-device-code.ts +++ b/src/services/github/get-device-code.ts @@ -7,7 +7,7 @@ import { import { HTTPError } from "~/lib/error" export async function getDeviceCode(): Promise { - const response = await fetch(`${GITHUB_BASE_URL}/login/device/code`, { + const response = await fetch(`${GITHUB_BASE_URL()}/login/device/code`, { method: "POST", headers: standardHeaders(), body: JSON.stringify({ diff --git a/src/services/github/get-user.ts b/src/services/github/get-user.ts index 23e1b1c1c..fd0da0486 100644 --- a/src/services/github/get-user.ts +++ b/src/services/github/get-user.ts @@ -3,7 +3,7 @@ import { HTTPError } from "~/lib/error" import { state } from "~/lib/state" export async function getGitHubUser() { - const response = await fetch(`${GITHUB_API_BASE_URL}/user`, { + const response = await fetch(`${GITHUB_API_BASE_URL()}/user`, { headers: { authorization: `token ${state.githubToken}`, ...standardHeaders(), diff --git a/src/services/github/poll-access-token.ts b/src/services/github/poll-access-token.ts index 4639ee0dc..fa158d0f9 100644 --- a/src/services/github/poll-access-token.ts +++ b/src/services/github/poll-access-token.ts @@ -19,7 +19,7 @@ export async function pollAccessToken( while (true) { const response = await fetch( - `${GITHUB_BASE_URL}/login/oauth/access_token`, + `${GITHUB_BASE_URL()}/login/oauth/access_token`, { method: "POST", headers: standardHeaders(), diff --git a/src/start.ts b/src/start.ts index 1ea4cf456..79bd93b52 100644 --- a/src/start.ts +++ b/src/start.ts @@ -27,6 +27,7 @@ interface RunServerOptions { showToken: boolean proxyEnv: boolean apiKeys?: Array + enterpriseUrl?: string } export async function runServer(options: RunServerOptions): Promise { @@ -49,6 +50,7 @@ export async function runServer(options: RunServerOptions): Promise { state.rateLimitWait = options.rateLimitWait state.showToken = options.showToken state.apiKeys = options.apiKeys + if (options.enterpriseUrl) state.enterpriseUrl = options.enterpriseUrl if (state.apiKeys && state.apiKeys.length > 0) { consola.info( @@ -63,7 +65,7 @@ export async function runServer(options: RunServerOptions): Promise { state.githubToken = options.githubToken consola.info("Using provided GitHub token") } else { - await setupGitHubToken() + await setupGitHubToken({ enterpriseUrl: options.enterpriseUrl }) } await setupCopilotToken() @@ -204,6 +206,11 @@ export const start = defineCommand({ type: "string", description: "API keys for authentication", }, + "enterprise-url": { + type: "string", + description: + "GitHub Enterprise host to use (eg. https://ghe.example.com or ghe.example.com)", + }, }, run({ args }) { const rateLimitRaw = args["rate-limit"] @@ -230,6 +237,7 @@ export const start = defineCommand({ showToken: args["show-token"], proxyEnv: args["proxy-env"], apiKeys, + enterpriseUrl: args["enterprise-url"], }) }, })