diff --git a/README.md b/README.md index 0d36c13c9..f96d8e7c3 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. @@ -62,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 @@ -86,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 @@ -154,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 | @@ -163,6 +166,28 @@ 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 | +| --enterprise-url | GitHub Enterprise host (eg. ghe.example.com) | none | 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 @@ -199,6 +224,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 @@ -209,6 +235,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: @@ -238,6 +302,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 @@ -266,7 +336,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 +346,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 +419,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)) 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/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/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 5ba4dc1d1..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 @@ -15,6 +16,9 @@ export interface State { // Rate limiting configuration rateLimitSeconds?: number lastRequestTimestamp?: number + + // API key validation + apiKeys?: Array } export const state: State = { @@ -22,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/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/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) } 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/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e6382..9bd3f13a4 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") @@ -71,6 +76,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 +99,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 { @@ -239,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"] { diff --git a/src/server.ts b/src/server.ts index 462a278f3..e387c2d59 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,8 +2,10 @@ 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" import { messageRoutes } from "./routes/messages/route" import { modelRoutes } from "./routes/models/route" import { tokenRoute } from "./routes/token/route" @@ -14,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) @@ -29,3 +41,4 @@ server.route("/v1/embeddings", embeddingRoutes) // Anthropic compatible endpoints server.route("/v1/messages", messageRoutes) +server.route("/api/event_logging", eventLoggingRoutes) 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 14abbbdff..79bd93b52 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 @@ -25,6 +26,8 @@ interface RunServerOptions { claudeCode: boolean showToken: boolean proxyEnv: boolean + apiKeys?: Array + enterpriseUrl?: string } export async function runServer(options: RunServerOptions): Promise { @@ -46,6 +49,14 @@ export async function runServer(options: RunServerOptions): Promise { state.rateLimitSeconds = options.rateLimit 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( + `API key authentication enabled with ${state.apiKeys.length} key(s)`, + ) + } await ensurePaths() await cacheVSCodeVersion() @@ -54,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() @@ -117,6 +128,7 @@ export async function runServer(options: RunServerOptions): Promise { serve({ fetch: server.fetch as ServerHandler, port: options.port, + hostname: options.host, }) } @@ -138,6 +150,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", @@ -184,6 +202,15 @@ export const start = defineCommand({ default: false, description: "Initialize proxy from environment variables", }, + "api-key": { + 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"] @@ -191,8 +218,15 @@ 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, verbose: args.verbose, accountType: args["account-type"], manual: args.manual, @@ -202,6 +236,8 @@ export const start = defineCommand({ claudeCode: args["claude-code"], showToken: args["show-token"], proxyEnv: args["proxy-env"], + apiKeys, + enterpriseUrl: args["enterprise-url"], }) }, }) 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", () => {