From 0d43a22cf2c217c7b9a31fe2e4b11b14419a717b Mon Sep 17 00:00:00 2001 From: betaHi Date: Tue, 14 Apr 2026 14:27:44 +0800 Subject: [PATCH 01/19] Add GPT-5.4 support for Claude Code --- README.md | 143 +++++++++++++++--- bun.lock | 3 +- package.json | 12 +- src/lib/claude-settings.ts | 56 +++++++ src/lib/models.ts | 112 ++++++++++++++ src/routes/chat-completions/handler.ts | 16 +- src/routes/messages/anthropic-types.ts | 1 + src/routes/messages/count-tokens-handler.ts | 15 +- src/routes/messages/non-stream-translation.ts | 64 +++++++- .../copilot/create-chat-completions.ts | 74 ++++++++- tests/anthropic-request.test.ts | 110 ++++++++++++++ tests/create-chat-completions.test.ts | 141 ++++++++++++++++- 12 files changed, 699 insertions(+), 48 deletions(-) create mode 100644 src/lib/claude-settings.ts create mode 100644 src/lib/models.ts diff --git a/README.md b/README.md index 0d36c13c9..c3be2f55a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A reverse-engineered proxy for the GitHub Copilot API that exposes it as an Open - **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API. - **Claude Code Integration**: Easily configure and launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) to use Copilot as its backend with a simple command-line flag (`--claude-code`). +- **GPT-5 Reasoning Effort Support**: Supports GPT-5 family reasoning controls through `reasoning_effort` and the `COPILOT_REASONING_EFFORT` environment variable (`low`, `medium`, `high`, `max`; `xhigh` is accepted as an alias for `max`). - **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. - **Manual Request Approval**: Manually approve or deny each API request for fine-grained control over usage (`--manual`). @@ -121,19 +122,19 @@ The Docker image includes: You can run the project directly using npx: ```sh -npx copilot-api@latest start +npx betahi-copilot-api@latest start ``` With options: ```sh -npx copilot-api@latest start --port 8080 +npx betahi-copilot-api@latest start --port 8080 ``` For authentication only: ```sh -npx copilot-api@latest auth +npx betahi-copilot-api@latest auth ``` ## Command Structure @@ -211,50 +212,154 @@ New endpoints for monitoring your Copilot usage and quotas. ## Example Usage +### GPT-5 Reasoning Effort + +When using GPT-5 family models, you can force the reasoning effort used for upstream Copilot requests by setting: + +```sh +COPILOT_REASONING_EFFORT=high +``` + +The proxy normalizes effort values as follows: + +| Claude Code / Anthropic side | Copilot / GPT-5 side | +| ---------------------------- | -------------------- | +| `low` | `low` | +| `medium` | `medium` | +| `high` | `high` | +| `max` | `max` | +| `xhigh` | `max` | + +For user-facing configuration, prefer `xhigh` if you want to match the Claude Code UI label exactly. The proxy will normalize `xhigh` to GPT-5 `max` automatically. + +You can control this in three ways: + +1. Set `COPILOT_REASONING_EFFORT` in the server environment. +2. Set `COPILOT_REASONING_EFFORT` inside Claude Code `settings.json` under `env`. +3. Send `reasoning_effort` in the Anthropic-compatible `/v1/messages` payload. +4. Send `thinking.budget_tokens` in the Anthropic-compatible `/v1/messages` payload, which will be mapped automatically. + +When `thinking.budget_tokens` is used, the proxy maps it like this: + +| `thinking.budget_tokens` | Mapped `reasoning_effort` | +| ------------------------ | ------------------------- | +| `<= 2048` | `low` | +| `2049 - 8192` | `medium` | +| `8193 - 24576` | `high` | +| `> 24576` | `max` | + +### Default Behavior + +There are two different defaults to keep in mind: + +| Layer | Default | +| ----- | ------- | +| Claude Code UI | Usually `Medium` in the current UI | +| This proxy | `medium` for GPT-5 family models | + +So the effective behavior is: + +1. If you explicitly set `COPILOT_REASONING_EFFORT`, that wins. +2. Else if `COPILOT_REASONING_EFFORT` is present in Claude Code `settings.json`, the proxy uses that value. +3. Else if Claude Code sends `reasoning_effort`, the proxy forwards it. +4. Else if Claude Code sends `thinking` with no `budget_tokens`, the proxy uses `medium`. +5. Else for GPT-5 family models, the proxy defaults to `medium`. + +### Using Claude Code `settings.json` + +Your Claude Code `settings.json` can configure the proxy endpoint and the default model selection, for example in `~/.claude/settings.json`. + +It is a good place to set: + +| Setting | Purpose | +| ------- | ------- | +| `ANTHROPIC_BASE_URL` | Point Claude Code to this proxy | +| `ANTHROPIC_AUTH_TOKEN` | Dummy token for local proxy auth | +| `ANTHROPIC_MODEL` | Default main model | +| `ANTHROPIC_DEFAULT_SONNET_MODEL` | Default sonnet-style model alias | +| `ANTHROPIC_SMALL_FAST_MODEL` | Default small/fast model | +| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Default haiku-style model alias | +| `COPILOT_REASONING_EFFORT` | Default GPT-5 reasoning effort for this proxy | + +Example: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_AUTH_TOKEN": "dummy", + "ANTHROPIC_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4", + "COPILOT_REASONING_EFFORT": "medium", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + } +} +``` + +For reasoning effort specifically: + +1. You can set `COPILOT_REASONING_EFFORT` in `~/.claude/settings.json` or project-level `.claude/settings.json`. +2. Supported values are `low`, `medium`, `high`, and `xhigh`. `xhigh` is normalized to GPT-5 `max`. +3. If Claude Code sends `reasoning_effort`, the proxy will honor it. +4. If Claude Code sends `thinking.budget_tokens`, the proxy will map that to the matching GPT-5 effort. +5. If nothing is configured, the proxy now defaults GPT-5 family models to `medium`. + +So with a `settings.json` like the example above, GPT-5.4 will effectively run at `medium` by default even without any extra effort setting. + +If the Anthropic-compatible `/v1/messages` payload includes `reasoning_effort` or `thinking.budget_tokens`, the proxy will map that to GPT-5 `reasoning_effort` automatically. + +Example: + +```sh +COPILOT_REASONING_EFFORT=medium bun run ./src/main.ts start --claude-code +``` + Using with npx: ```sh # Basic usage with start command -npx copilot-api@latest start +npx betahi-copilot-api@latest start # Run on custom port with verbose logging -npx copilot-api@latest start --port 8080 --verbose +npx betahi-copilot-api@latest start --port 8080 --verbose # Use with a business plan GitHub account -npx copilot-api@latest start --account-type business +npx betahi-copilot-api@latest start --account-type business # Use with an enterprise plan GitHub account -npx copilot-api@latest start --account-type enterprise +npx betahi-copilot-api@latest start --account-type enterprise # Enable manual approval for each request -npx copilot-api@latest start --manual +npx betahi-copilot-api@latest start --manual # Set rate limit to 30 seconds between requests -npx copilot-api@latest start --rate-limit 30 +npx betahi-copilot-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 betahi-copilot-api@latest start --rate-limit 30 --wait # Provide GitHub token directly -npx copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE +npx betahi-copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE # Run only the auth flow -npx copilot-api@latest auth +npx betahi-copilot-api@latest auth # Run auth flow with verbose logging -npx copilot-api@latest auth --verbose +npx betahi-copilot-api@latest auth --verbose # Show your Copilot usage/quota in the terminal (no server needed) -npx copilot-api@latest check-usage +npx betahi-copilot-api@latest check-usage # Display debug information for troubleshooting -npx copilot-api@latest debug +npx betahi-copilot-api@latest debug # Display debug information in JSON format -npx copilot-api@latest debug --json +npx betahi-copilot-api@latest debug --json # Initialize proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, etc.) -npx copilot-api@latest start --proxy-env +npx betahi-copilot-api@latest start --proxy-env ``` ## Using the Usage Viewer @@ -263,7 +368,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 betahi-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` @@ -289,7 +394,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 betahi-copilot-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. diff --git a/bun.lock b/bun.lock index 20e895e7f..64ce2bfd4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,8 +1,9 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { - "name": "copilot-api", + "name": "betahi-copilot-api", "dependencies": { "citty": "^0.1.6", "clipboardy": "^5.0.0", diff --git a/package.json b/package.json index a5adbb8e7..c98a0242d 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,19 @@ { - "name": "copilot-api", + "name": "betahi-copilot-api", "version": "0.7.0", - "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!", + "description": "Fork of copilot-api with GPT-5.4 and reasoning effort support for Claude Code.", "keywords": [ "proxy", "github-copilot", "openai-compatible" ], - "homepage": "https://github.com/ericc-ch/copilot-api", - "bugs": "https://github.com/ericc-ch/copilot-api/issues", + "homepage": "https://github.com/betaHi/copilot-api", + "bugs": "https://github.com/betaHi/copilot-api/issues", "repository": { "type": "git", - "url": "git+https://github.com/ericc-ch/copilot-api.git" + "url": "git+https://github.com/betaHi/copilot-api.git" }, - "author": "Erick Christian ", + "author": "betaHi", "type": "module", "bin": { "copilot-api": "./dist/main.js" diff --git a/src/lib/claude-settings.ts b/src/lib/claude-settings.ts new file mode 100644 index 000000000..d2a7a2212 --- /dev/null +++ b/src/lib/claude-settings.ts @@ -0,0 +1,56 @@ +import consola from "consola" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +interface ClaudeSettingsFile { + env?: Record +} + +const getClaudeSettingsPaths = (): Array => { + const currentWorkingDirectory = process.cwd() + const homeDirectory = process.env.HOME ?? os.homedir() + + return [ + path.join(homeDirectory, ".claude", "settings.json"), + path.join(currentWorkingDirectory, ".claude", "settings.json"), + path.join(currentWorkingDirectory, ".claude", "settings.local.json"), + ] +} + +const readClaudeSettingsFile = async ( + filePath: string, +): Promise => { + try { + const content = await fs.readFile(filePath) + return JSON.parse(content) as ClaudeSettingsFile + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined + } + + consola.warn(`Failed to read Claude settings from ${filePath}:`, error) + return undefined + } +} + +export const getClaudeSettingsEnv = async (): Promise< + Record +> => { + const mergedEnv: Record = {} + + for (const filePath of getClaudeSettingsPaths()) { + const settings = await readClaudeSettingsFile(filePath) + if (!settings?.env) { + continue + } + + for (const [key, value] of Object.entries(settings.env)) { + if (typeof value === "string") { + mergedEnv[key] = value + } + } + } + + return mergedEnv +} diff --git a/src/lib/models.ts b/src/lib/models.ts new file mode 100644 index 000000000..d110bca11 --- /dev/null +++ b/src/lib/models.ts @@ -0,0 +1,112 @@ +import type { Model } from "~/services/copilot/get-models" + +import { state } from "./state" + +const normalizeModelId = (modelId: string): string => + modelId + .trim() + .toLowerCase() + .replaceAll(/[\s._-]+/g, "") + +const stripSnapshotSuffix = (modelId: string): string => { + if (modelId.startsWith("claude-sonnet-4-")) { + return "claude-sonnet-4" + } + + if (modelId.startsWith("claude-opus-4-")) { + return "claude-opus-4" + } + + return modelId +} + +const getAliasCandidates = (modelId: string): Array => { + const canonicalModelId = stripSnapshotSuffix(modelId.trim().toLowerCase()) + const aliases = new Set([canonicalModelId]) + + const familyMatch = canonicalModelId.match( + /^[a-z]+(?:-[a-z]+)*-\d+(?:\.\d+)?/, + ) + if (familyMatch) { + aliases.add(familyMatch[0]) + aliases.add(familyMatch[0].replace(/\.\d+$/, "")) + } + + if (/^gpt-5(?:[.-]\d+)?$/i.test(canonicalModelId)) { + aliases.add("gpt-5") + } + + return [...aliases] +} + +const scoreModelCandidate = (model: Model): number => { + let score = 0 + + if (model.model_picker_enabled) { + score += 20 + } + + if (!model.preview) { + score += 10 + } + + if (/mini|nano|fast|flash|haiku/i.test(model.id)) { + score -= 15 + } + + return score - model.id.length / 1000 +} + +const pickBestModel = (models: Array): Model | undefined => { + return [...models].sort( + (left, right) => scoreModelCandidate(right) - scoreModelCandidate(left), + )[0] +} + +export const resolveModel = ( + requestedModelId: string, + models: Array | undefined = state.models?.data, +): Model | undefined => { + if (!models || models.length === 0) { + return undefined + } + + const exactMatch = models.find((model) => model.id === requestedModelId) + if (exactMatch) { + return exactMatch + } + + const aliasCandidates = getAliasCandidates(requestedModelId) + const normalizedAliases = new Set( + aliasCandidates.map((candidate) => normalizeModelId(candidate)), + ) + + const normalizedExactMatches = models.filter((model) => + normalizedAliases.has(normalizeModelId(model.id)), + ) + if (normalizedExactMatches.length > 0) { + return pickBestModel(normalizedExactMatches) + } + + const familyPrefixMatches = models.filter((model) => { + const normalizedModelId = normalizeModelId(model.id) + + return aliasCandidates.some((candidate) => { + const normalizedCandidate = normalizeModelId(candidate) + + return ( + normalizedCandidate.length >= 4 + && normalizedModelId.startsWith(normalizedCandidate) + ) + }) + }) + + return pickBestModel(familyPrefixMatches) +} + +export const resolveModelId = ( + requestedModelId: string, + models: Array | undefined = state.models?.data, +): string => + resolveModel(requestedModelId, models)?.id + ?? stripSnapshotSuffix(requestedModelId) diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 04a5ae9ed..6d7539e6c 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -4,6 +4,7 @@ import consola from "consola" import { streamSSE, type SSEMessage } from "hono/streaming" import { awaitApproval } from "~/lib/approval" +import { resolveModel } from "~/lib/models" import { checkRateLimit } from "~/lib/rate-limit" import { state } from "~/lib/state" import { getTokenCount } from "~/lib/tokenizer" @@ -20,10 +21,19 @@ export async function handleCompletion(c: Context) { let payload = await c.req.json() consola.debug("Request payload:", JSON.stringify(payload).slice(-400)) + const resolvedModel = resolveModel(payload.model) + if (resolvedModel && resolvedModel.id !== payload.model) { + consola.info(`Resolved model alias ${payload.model} -> ${resolvedModel.id}`) + payload = { + ...payload, + model: resolvedModel.id, + } + } + // Find the selected model - const selectedModel = state.models?.data.find( - (model) => model.id === payload.model, - ) + const selectedModel = + resolvedModel + ?? state.models?.data.find((model) => model.id === payload.model) // Calculate and display token count try { diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 881fffcc8..18bc2e02d 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -22,6 +22,7 @@ export interface AnthropicMessagesPayload { type: "enabled" budget_tokens?: number } + reasoning_effort?: "low" | "medium" | "high" | "max" | "xhigh" service_tier?: "auto" | "standard_only" } diff --git a/src/routes/messages/count-tokens-handler.ts b/src/routes/messages/count-tokens-handler.ts index 2ec849cb8..98df2a54f 100644 --- a/src/routes/messages/count-tokens-handler.ts +++ b/src/routes/messages/count-tokens-handler.ts @@ -2,7 +2,7 @@ import type { Context } from "hono" import consola from "consola" -import { state } from "~/lib/state" +import { resolveModel } from "~/lib/models" import { getTokenCount } from "~/lib/tokenizer" import { type AnthropicMessagesPayload } from "./anthropic-types" @@ -19,9 +19,7 @@ export async function handleCountTokens(c: Context) { const openAIPayload = translateToOpenAI(anthropicPayload) - const selectedModel = state.models?.data.find( - (model) => model.id === anthropicPayload.model, - ) + const selectedModel = resolveModel(anthropicPayload.model) if (!selectedModel) { consola.warn("Model not found, returning default token count") @@ -31,6 +29,7 @@ export async function handleCountTokens(c: Context) { } const tokenCount = await getTokenCount(openAIPayload, selectedModel) + const effectiveModelId = selectedModel.id if (anthropicPayload.tools && anthropicPayload.tools.length > 0) { let mcpToolExist = false @@ -40,19 +39,19 @@ export async function handleCountTokens(c: Context) { ) } if (!mcpToolExist) { - if (anthropicPayload.model.startsWith("claude")) { + if (effectiveModelId.startsWith("claude")) { // https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview#pricing tokenCount.input = tokenCount.input + 346 - } else if (anthropicPayload.model.startsWith("grok")) { + } else if (effectiveModelId.startsWith("grok")) { tokenCount.input = tokenCount.input + 480 } } } let finalTokenCount = tokenCount.input + tokenCount.output - if (anthropicPayload.model.startsWith("claude")) { + if (effectiveModelId.startsWith("claude")) { finalTokenCount = Math.round(finalTokenCount * 1.15) - } else if (anthropicPayload.model.startsWith("grok")) { + } else if (effectiveModelId.startsWith("grok")) { finalTokenCount = Math.round(finalTokenCount * 1.03) } diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e6382..e4631877c 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -1,3 +1,4 @@ +import { resolveModelId } from "~/lib/models" import { type ChatCompletionResponse, type ChatCompletionsPayload, @@ -40,20 +41,69 @@ export function translateToOpenAI( stream: payload.stream, temperature: payload.temperature, top_p: payload.top_p, + reasoning_effort: translateReasoningEffort(payload), user: payload.metadata?.user_id, tools: translateAnthropicToolsToOpenAI(payload.tools), tool_choice: translateAnthropicToolChoiceToOpenAI(payload.tool_choice), } } -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") +function translateReasoningEffort( + payload: AnthropicMessagesPayload, +): ChatCompletionsPayload["reasoning_effort"] { + if (payload.reasoning_effort) { + return normalizeReasoningEffort(payload.reasoning_effort) + } + + if (payload.thinking?.type !== "enabled") { + return undefined + } + + const budgetTokens = payload.thinking.budget_tokens + if (budgetTokens === undefined) { + return "medium" } - return model + + if (budgetTokens <= 2_048) { + return "low" + } + + if (budgetTokens <= 8_192) { + return "medium" + } + + if (budgetTokens <= 24_576) { + return "high" + } + + return "max" +} + +function normalizeReasoningEffort( + value: string, +): ChatCompletionsPayload["reasoning_effort"] { + switch (value.toLowerCase()) { + case "low": { + return "low" + } + case "medium": { + return "medium" + } + case "high": { + return "high" + } + case "xhigh": + case "max": { + return "max" + } + default: { + return undefined + } + } +} + +function translateModelName(model: string): string { + return resolveModelId(model) } function translateAnthropicMessagesToOpenAI( diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 8534151da..034b2f0e9 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -2,9 +2,69 @@ import consola from "consola" import { events } from "fetch-event-stream" import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" +import { getClaudeSettingsEnv } from "~/lib/claude-settings" import { HTTPError } from "~/lib/error" import { state } from "~/lib/state" +const usesMaxCompletionTokens = (modelId: string): boolean => + modelId.startsWith("gpt-5") + +const defaultReasoningEffort = ( + modelId: string, +): ChatCompletionsPayload["reasoning_effort"] => + usesMaxCompletionTokens(modelId) ? "medium" : undefined + +const normalizeReasoningEffort = ( + value: string | undefined | null, +): ChatCompletionsPayload["reasoning_effort"] => { + switch (value?.toLowerCase()) { + case "low": { + return "low" + } + case "medium": { + return "medium" + } + case "high": { + return "high" + } + case "xhigh": + case "max": { + return "max" + } + default: { + return undefined + } + } +} + +const buildRequestPayload = ( + payload: ChatCompletionsPayload, + claudeSettingsEnv: Record, +): ChatCompletionsRequestPayload => { + const reasoningEffort = + payload.reasoning_effort + ?? normalizeReasoningEffort(process.env.COPILOT_REASONING_EFFORT) + ?? normalizeReasoningEffort(claudeSettingsEnv.COPILOT_REASONING_EFFORT) + ?? defaultReasoningEffort(payload.model) + + if ( + !usesMaxCompletionTokens(payload.model) + || payload.max_tokens === null + || payload.max_tokens === undefined + ) { + return reasoningEffort === null || reasoningEffort === undefined ? + payload + : { ...payload, reasoning_effort: reasoningEffort } + } + + return { + ...payload, + max_tokens: undefined, + max_completion_tokens: payload.max_tokens, + reasoning_effort: reasoningEffort, + } +} + export const createChatCompletions = async ( payload: ChatCompletionsPayload, ) => { @@ -28,10 +88,13 @@ export const createChatCompletions = async ( "X-Initiator": isAgentCall ? "agent" : "user", } + const claudeSettingsEnv = await getClaudeSettingsEnv() + const requestPayload = buildRequestPayload(payload, claudeSettingsEnv) + const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { method: "POST", headers, - body: JSON.stringify(payload), + body: JSON.stringify(requestPayload), }) if (!response.ok) { @@ -130,6 +193,7 @@ export interface ChatCompletionsPayload { temperature?: number | null top_p?: number | null max_tokens?: number | null + reasoning_effort?: "low" | "medium" | "high" | "max" | null stop?: string | Array | null n?: number | null stream?: boolean | null @@ -150,6 +214,14 @@ export interface ChatCompletionsPayload { user?: string | null } +type ChatCompletionsRequestPayload = Omit< + ChatCompletionsPayload, + "max_tokens" +> & { + max_tokens?: number | null + max_completion_tokens?: number | null +} + export interface Tool { type: "function" function: { diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 06c663778..439a44cde 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -2,9 +2,38 @@ import { describe, test, expect } from "bun:test" import { z } from "zod" import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" +import type { ModelsResponse } from "~/services/copilot/get-models" + +import { state } from "~/lib/state" import { translateToOpenAI } from "../src/routes/messages/non-stream-translation" +const originalModels = state.models + +function createModel( + id: string, + options?: Partial, +) { + return { + capabilities: { + family: id, + limits: {}, + object: "chat.completion", + supports: {}, + tokenizer: "o200k_base", + type: "chat", + }, + id, + model_picker_enabled: true, + name: id, + object: "model", + preview: false, + vendor: "openai", + version: "1", + ...options, + } +} + // Zod schema for a single message in the chat completion request. const messageSchema = z.object({ role: z.enum([ @@ -63,7 +92,76 @@ function isValidChatCompletionRequest(payload: unknown): boolean { } describe("Anthropic to OpenAI translation logic", () => { + test("should resolve GPT-5 family aliases to a supported Copilot model", () => { + state.models = { + object: "list", + data: [createModel("gpt-5"), createModel("gpt-5-mini")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "gpt-5.4", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.model).toBe("gpt-5") + }) + + test("should resolve Claude snapshot aliases to the base Copilot model", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4-20260110", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.model).toBe("claude-opus-4") + }) + + test("should map Anthropic thinking budget to GPT reasoning effort", () => { + state.models = originalModels + + const anthropicPayload: AnthropicMessagesPayload = { + model: "gpt-5.4", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + thinking: { + type: "enabled", + budget_tokens: 10_000, + }, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.reasoning_effort).toBe("high") + }) + + test("should honor explicit reasoning effort aliases", () => { + state.models = originalModels + + const anthropicPayload: AnthropicMessagesPayload = { + model: "gpt-5.4", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + reasoning_effort: "xhigh", + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.reasoning_effort).toBe("max") + }) + test("should translate minimal Anthropic payload to valid OpenAI payload", () => { + state.models = originalModels + const anthropicPayload: AnthropicMessagesPayload = { model: "gpt-4o", messages: [{ role: "user", content: "Hello!" }], @@ -75,6 +173,8 @@ describe("Anthropic to OpenAI translation logic", () => { }) test("should translate comprehensive Anthropic payload to valid OpenAI payload", () => { + state.models = originalModels + const anthropicPayload: AnthropicMessagesPayload = { model: "gpt-4o", system: "You are a helpful assistant.", @@ -104,6 +204,8 @@ describe("Anthropic to OpenAI translation logic", () => { }) test("should handle missing fields gracefully", () => { + state.models = originalModels + const anthropicPayload: AnthropicMessagesPayload = { model: "gpt-4o", messages: [{ role: "user", content: "Hello!" }], @@ -114,6 +216,8 @@ describe("Anthropic to OpenAI translation logic", () => { }) test("should handle invalid types in Anthropic payload", () => { + state.models = originalModels + const anthropicPayload = { model: "gpt-4o", messages: [{ role: "user", content: "Hello!" }], @@ -126,6 +230,8 @@ describe("Anthropic to OpenAI translation logic", () => { }) test("should handle thinking blocks in assistant messages", () => { + state.models = originalModels + const anthropicPayload: AnthropicMessagesPayload = { model: "claude-3-5-sonnet-20241022", messages: [ @@ -157,6 +263,8 @@ describe("Anthropic to OpenAI translation logic", () => { }) test("should handle thinking blocks with tool calls", () => { + state.models = originalModels + const anthropicPayload: AnthropicMessagesPayload = { model: "claude-3-5-sonnet-20241022", messages: [ @@ -199,6 +307,8 @@ describe("Anthropic to OpenAI translation logic", () => { }) }) +state.models = originalModels + describe("OpenAI Chat Completion v1 Request Payload Validation with Zod", () => { test("should return true for a minimal valid request payload", () => { const validPayload = { diff --git a/tests/create-chat-completions.test.ts b/tests/create-chat-completions.test.ts index d18e741aa..ea3705d00 100644 --- a/tests/create-chat-completions.test.ts +++ b/tests/create-chat-completions.test.ts @@ -1,4 +1,7 @@ -import { test, expect, mock } from "bun:test" +import { afterEach, test, expect, mock } from "bun:test" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" import type { ChatCompletionsPayload } from "../src/services/copilot/create-chat-completions" @@ -12,17 +15,26 @@ state.accountType = "individual" // Helper to mock fetch const fetchMock = mock( - (_url: string, opts: { headers: Record }) => { + (_url: string, opts: { headers: Record; body?: string }) => { return { ok: true, json: () => ({ id: "123", object: "chat.completion", choices: [] }), headers: opts.headers, + body: opts.body, } }, ) // @ts-expect-error - Mock fetch doesn't implement all fetch properties ;(globalThis as unknown as { fetch: typeof fetch }).fetch = fetchMock +afterEach(() => { + fetchMock.mockClear() + delete process.env.COPILOT_REASONING_EFFORT + process.env.HOME = originalHome +}) + +const originalHome = process.env.HOME + test("sets X-Initiator to agent if tool/assistant present", async () => { const payload: ChatCompletionsPayload = { messages: [ @@ -50,7 +62,130 @@ test("sets X-Initiator to user if only user present", async () => { await createChatCompletions(payload) expect(fetchMock).toHaveBeenCalled() const headers = ( - fetchMock.mock.calls[1][1] as { headers: Record } + fetchMock.mock.calls[0][1] as { headers: Record } ).headers expect(headers["X-Initiator"]).toBe("user") }) + +test("uses max_completion_tokens for GPT-5 family models", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 512, + } + + await createChatCompletions(payload) + + expect(fetchMock).toHaveBeenCalled() + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_completion_tokens).toBe(512) + expect(body.max_tokens).toBeUndefined() +}) + +test("keeps max_tokens for non GPT-5 models", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "claude-sonnet-4.6", + max_tokens: 256, + } + + await createChatCompletions(payload) + + expect(fetchMock).toHaveBeenCalled() + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_tokens).toBe(256) + expect(body.max_completion_tokens).toBeUndefined() +}) + +test("passes through explicit reasoning_effort for GPT-5 family models", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + reasoning_effort: "high", + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.reasoning_effort).toBe("high") + expect(body.max_completion_tokens).toBe(128) +}) + +test("uses COPILOT_REASONING_EFFORT env override for GPT-5 family models", async () => { + process.env.COPILOT_REASONING_EFFORT = "xhigh" + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.reasoning_effort).toBe("max") +}) + +test("defaults GPT-5 family models to medium reasoning effort", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.reasoning_effort).toBe("medium") +}) + +test("uses COPILOT_REASONING_EFFORT from Claude settings.json", async () => { + const tempHome = await mkdtemp(path.join(os.tmpdir(), "claude-settings-")) + const claudeDirectory = path.join(tempHome, ".claude") + + try { + await mkdir(claudeDirectory, { recursive: true }) + await writeFile( + path.join(claudeDirectory, "settings.json"), + JSON.stringify({ + env: { + COPILOT_REASONING_EFFORT: "xhigh", + }, + }), + ) + + process.env.HOME = tempHome + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.reasoning_effort).toBe("max") + } finally { + await rm(tempHome, { recursive: true, force: true }) + } +}) From 1203b800538dc9c6d6a7dca08b4126498455a3a5 Mon Sep 17 00:00:00 2001 From: betaHi Date: Tue, 14 Apr 2026 16:26:13 +0800 Subject: [PATCH 02/19] Fix GPT-5.4 tool calls and bump version to 0.7.1 --- README.md | 8 ++--- package.json | 2 +- .../copilot/create-chat-completions.ts | 12 +++++++- tests/create-chat-completions.test.ts | 30 +++++++++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c3be2f55a..4e9dbd888 100644 --- a/README.md +++ b/README.md @@ -412,10 +412,10 @@ Here is an example `.claude/settings.json` file: "env": { "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": "gpt-5.4", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4", "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" }, diff --git a/package.json b/package.json index c98a0242d..ffbd41312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betahi-copilot-api", - "version": "0.7.0", + "version": "0.7.1", "description": "Fork of copilot-api with GPT-5.4 and reasoning effort support for Claude Code.", "keywords": [ "proxy", diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 034b2f0e9..ebd6f0fa4 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -41,12 +41,22 @@ const buildRequestPayload = ( payload: ChatCompletionsPayload, claudeSettingsEnv: Record, ): ChatCompletionsRequestPayload => { - const reasoningEffort = + const requestedReasoningEffort = payload.reasoning_effort ?? normalizeReasoningEffort(process.env.COPILOT_REASONING_EFFORT) ?? normalizeReasoningEffort(claudeSettingsEnv.COPILOT_REASONING_EFFORT) ?? defaultReasoningEffort(payload.model) + const reasoningEffort = + ( + usesMaxCompletionTokens(payload.model) + && payload.tools !== null + && payload.tools !== undefined + && payload.tools.length > 0 + ) ? + undefined + : requestedReasoningEffort + if ( !usesMaxCompletionTokens(payload.model) || payload.max_tokens === null diff --git a/tests/create-chat-completions.test.ts b/tests/create-chat-completions.test.ts index ea3705d00..20bee5b23 100644 --- a/tests/create-chat-completions.test.ts +++ b/tests/create-chat-completions.test.ts @@ -189,3 +189,33 @@ test("uses COPILOT_REASONING_EFFORT from Claude settings.json", async () => { await rm(tempHome, { recursive: true, force: true }) } }) + +test("omits reasoning_effort for GPT-5 family tool calls", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + reasoning_effort: "high", + tools: [ + { + type: "function", + function: { + name: "lookup_weather", + parameters: { + type: "object", + properties: {}, + }, + }, + }, + ], + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.reasoning_effort).toBeUndefined() + expect(body.max_completion_tokens).toBe(128) +}) From 32b3da7dfc94f321ccacbeb9c92ad4be24d0b050 Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 12:24:55 +0800 Subject: [PATCH 03/19] support gpt 5.3 codex and 5.4 mini --- README.md | 113 +--- src/routes/messages/anthropic-types.ts | 2 +- src/routes/messages/non-stream-translation.ts | 19 +- .../copilot/create-chat-completions.ts | 152 ++++- src/services/copilot/responses.ts | 583 ++++++++++++++++++ tests/create-chat-completions.test.ts | 345 ++++++++++- 6 files changed, 1108 insertions(+), 106 deletions(-) create mode 100644 src/services/copilot/responses.ts diff --git a/README.md b/README.md index 4e9dbd888..8df0bd107 100644 --- a/README.md +++ b/README.md @@ -210,76 +210,29 @@ 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. | -## Example Usage - -### GPT-5 Reasoning Effort - -When using GPT-5 family models, you can force the reasoning effort used for upstream Copilot requests by setting: - -```sh -COPILOT_REASONING_EFFORT=high -``` - -The proxy normalizes effort values as follows: - -| Claude Code / Anthropic side | Copilot / GPT-5 side | -| ---------------------------- | -------------------- | -| `low` | `low` | -| `medium` | `medium` | -| `high` | `high` | -| `max` | `max` | -| `xhigh` | `max` | - -For user-facing configuration, prefer `xhigh` if you want to match the Claude Code UI label exactly. The proxy will normalize `xhigh` to GPT-5 `max` automatically. - -You can control this in three ways: - -1. Set `COPILOT_REASONING_EFFORT` in the server environment. -2. Set `COPILOT_REASONING_EFFORT` inside Claude Code `settings.json` under `env`. -3. Send `reasoning_effort` in the Anthropic-compatible `/v1/messages` payload. -4. Send `thinking.budget_tokens` in the Anthropic-compatible `/v1/messages` payload, which will be mapped automatically. - -When `thinking.budget_tokens` is used, the proxy maps it like this: - -| `thinking.budget_tokens` | Mapped `reasoning_effort` | -| ------------------------ | ------------------------- | -| `<= 2048` | `low` | -| `2049 - 8192` | `medium` | -| `8193 - 24576` | `high` | -| `> 24576` | `max` | - -### Default Behavior - -There are two different defaults to keep in mind: +## **GPT Support** -| Layer | Default | -| ----- | ------- | -| Claude Code UI | Usually `Medium` in the current UI | -| This proxy | `medium` for GPT-5 family models | +| Model | Status | Notes | +| ----- | ------ | ----- | +| `gpt-5.4` | Supported | Uses the standard GPT-5 chat/completions path. | +| `gpt-5.3-codex` | Supported | Uses the Responses API bridge internally. | +| `gpt-5.4-mini` | Supported | Uses the Responses API bridge internally. | -So the effective behavior is: +### Reasoning Effort Matrix -1. If you explicitly set `COPILOT_REASONING_EFFORT`, that wins. -2. Else if `COPILOT_REASONING_EFFORT` is present in Claude Code `settings.json`, the proxy uses that value. -3. Else if Claude Code sends `reasoning_effort`, the proxy forwards it. -4. Else if Claude Code sends `thinking` with no `budget_tokens`, the proxy uses `medium`. -5. Else for GPT-5 family models, the proxy defaults to `medium`. +Reasoning effort is model-specific. -### Using Claude Code `settings.json` +| Model | Supported values | Default | +| ----- | ---------------- | ------- | +| `gpt-5.4` | `low`, `medium`, `high`, `xhigh` | `medium` | +| `gpt-5.3-codex` | `low`, `medium`, `high`, `xhigh` | `medium` | +| `gpt-5.4-mini` | `none`, `low`, `medium` | `medium` | -Your Claude Code `settings.json` can configure the proxy endpoint and the default model selection, for example in `~/.claude/settings.json`. +If an unsupported effort value is sent for one of these models, the proxy falls back to that model's default `medium` instead of forwarding an invalid upstream request. -It is a good place to set: +### `settings.json` -| Setting | Purpose | -| ------- | ------- | -| `ANTHROPIC_BASE_URL` | Point Claude Code to this proxy | -| `ANTHROPIC_AUTH_TOKEN` | Dummy token for local proxy auth | -| `ANTHROPIC_MODEL` | Default main model | -| `ANTHROPIC_DEFAULT_SONNET_MODEL` | Default sonnet-style model alias | -| `ANTHROPIC_SMALL_FAST_MODEL` | Default small/fast model | -| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Default haiku-style model alias | -| `COPILOT_REASONING_EFFORT` | Default GPT-5 reasoning effort for this proxy | +Your Claude Code `settings.json` can configure the proxy endpoint and default model selection, for example in `~/.claude/settings.json`. Example: @@ -288,33 +241,25 @@ Example: "env": { "ANTHROPIC_BASE_URL": "http://localhost:4141", "ANTHROPIC_AUTH_TOKEN": "dummy", - "ANTHROPIC_MODEL": "gpt-5.4", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", - "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4", + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini", "COPILOT_REASONING_EFFORT": "medium", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" - } + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1" + }, + "model": "gpt-5.3-codex" } ``` -For reasoning effort specifically: +Notes: -1. You can set `COPILOT_REASONING_EFFORT` in `~/.claude/settings.json` or project-level `.claude/settings.json`. -2. Supported values are `low`, `medium`, `high`, and `xhigh`. `xhigh` is normalized to GPT-5 `max`. -3. If Claude Code sends `reasoning_effort`, the proxy will honor it. -4. If Claude Code sends `thinking.budget_tokens`, the proxy will map that to the matching GPT-5 effort. -5. If nothing is configured, the proxy now defaults GPT-5 family models to `medium`. +1. `COPILOT_REASONING_EFFORT` is currently a global default, not a per-model setting. +2. Claude Code's top-level `model` field is interpreted by Claude Code itself, not by this proxy. +3. For one-off validation, `claude --model ` is the most reliable way to force the active session model. -So with a `settings.json` like the example above, GPT-5.4 will effectively run at `medium` by default even without any extra effort setting. - -If the Anthropic-compatible `/v1/messages` payload includes `reasoning_effort` or `thinking.budget_tokens`, the proxy will map that to GPT-5 `reasoning_effort` automatically. - -Example: - -```sh -COPILOT_REASONING_EFFORT=medium bun run ./src/main.ts start --claude-code -``` +## Example Usage Using with npx: diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 18bc2e02d..2732e7d9d 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -22,7 +22,7 @@ export interface AnthropicMessagesPayload { type: "enabled" budget_tokens?: number } - reasoning_effort?: "low" | "medium" | "high" | "max" | "xhigh" + reasoning_effort?: "none" | "low" | "medium" | "high" | "max" | "xhigh" service_tier?: "auto" | "standard_only" } diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index e4631877c..f86801e92 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -1,5 +1,6 @@ import { resolveModelId } from "~/lib/models" import { + sanitizeReasoningEffortForModel, type ChatCompletionResponse, type ChatCompletionsPayload, type ContentPart, @@ -51,8 +52,13 @@ export function translateToOpenAI( function translateReasoningEffort( payload: AnthropicMessagesPayload, ): ChatCompletionsPayload["reasoning_effort"] { + const modelId = translateModelName(payload.model) + if (payload.reasoning_effort) { - return normalizeReasoningEffort(payload.reasoning_effort) + return sanitizeReasoningEffortForModel( + modelId, + normalizeReasoningEffort(payload.reasoning_effort), + ) } if (payload.thinking?.type !== "enabled") { @@ -73,16 +79,19 @@ function translateReasoningEffort( } if (budgetTokens <= 24_576) { - return "high" + return sanitizeReasoningEffortForModel(modelId, "high") } - return "max" + return sanitizeReasoningEffortForModel(modelId, "xhigh") } function normalizeReasoningEffort( value: string, ): ChatCompletionsPayload["reasoning_effort"] { switch (value.toLowerCase()) { + case "none": { + return "none" + } case "low": { return "low" } @@ -92,7 +101,9 @@ function normalizeReasoningEffort( case "high": { return "high" } - case "xhigh": + case "xhigh": { + return "xhigh" + } case "max": { return "max" } diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index ebd6f0fa4..7bd41d675 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -6,18 +6,81 @@ import { getClaudeSettingsEnv } from "~/lib/claude-settings" import { HTTPError } from "~/lib/error" import { state } from "~/lib/state" +import { + buildResponsesRequestPayload, + shouldUseResponsesApiForModel, + translateResponsesStreamToChatCompletionStream, + translateResponsesToChatCompletion, + type ResponsesApiResponse, + type ResponsesReasoningEffort, +} from "./responses" + const usesMaxCompletionTokens = (modelId: string): boolean => modelId.startsWith("gpt-5") +// Copilot rejects user identifiers longer than 64 characters. +const MAX_USER_LENGTH = 64 + const defaultReasoningEffort = ( modelId: string, ): ChatCompletionsPayload["reasoning_effort"] => usesMaxCompletionTokens(modelId) ? "medium" : undefined +const getAllowedReasoningEfforts = ( + modelId: string, +): Array< + Exclude +> => { + if (modelId.startsWith("gpt-5.4-mini")) { + return ["none", "low", "medium"] + } + + if (modelId.startsWith("gpt-5.4") || modelId.startsWith("gpt-5.3-codex")) { + return ["low", "medium", "high", "xhigh"] + } + + if (usesMaxCompletionTokens(modelId)) { + return ["low", "medium", "high", "xhigh"] + } + + return [] +} + +export const sanitizeReasoningEffortForModel = ( + modelId: string, + reasoningEffort: ChatCompletionsPayload["reasoning_effort"], +): ChatCompletionsPayload["reasoning_effort"] => { + if (!reasoningEffort) { + return undefined + } + + return getAllowedReasoningEfforts(modelId).includes(reasoningEffort) ? + reasoningEffort + : undefined +} + +const getRequestedReasoningEffort = ( + payload: ChatCompletionsPayload, + claudeSettingsEnv: Record, +): ChatCompletionsPayload["reasoning_effort"] => { + const requestedReasoningEffort = + payload.reasoning_effort + ?? normalizeReasoningEffort(process.env.COPILOT_REASONING_EFFORT) + ?? normalizeReasoningEffort(claudeSettingsEnv.COPILOT_REASONING_EFFORT) + + return ( + sanitizeReasoningEffortForModel(payload.model, requestedReasoningEffort) + ?? defaultReasoningEffort(payload.model) + ) +} + const normalizeReasoningEffort = ( value: string | undefined | null, ): ChatCompletionsPayload["reasoning_effort"] => { switch (value?.toLowerCase()) { + case "none": { + return "none" + } case "low": { return "low" } @@ -27,7 +90,9 @@ const normalizeReasoningEffort = ( case "high": { return "high" } - case "xhigh": + case "xhigh": { + return "xhigh" + } case "max": { return "max" } @@ -37,15 +102,24 @@ const normalizeReasoningEffort = ( } } +export const sanitizeUserIdentifier = ( + user: string | null | undefined, +): string | undefined => { + if (!user) { + return undefined + } + + return user.slice(0, MAX_USER_LENGTH) +} + const buildRequestPayload = ( payload: ChatCompletionsPayload, claudeSettingsEnv: Record, ): ChatCompletionsRequestPayload => { - const requestedReasoningEffort = - payload.reasoning_effort - ?? normalizeReasoningEffort(process.env.COPILOT_REASONING_EFFORT) - ?? normalizeReasoningEffort(claudeSettingsEnv.COPILOT_REASONING_EFFORT) - ?? defaultReasoningEffort(payload.model) + const requestedReasoningEffort = getRequestedReasoningEffort( + payload, + claudeSettingsEnv, + ) const reasoningEffort = ( @@ -62,9 +136,14 @@ const buildRequestPayload = ( || payload.max_tokens === null || payload.max_tokens === undefined ) { + const sanitizedPayload = { + ...payload, + user: sanitizeUserIdentifier(payload.user), + } + return reasoningEffort === null || reasoningEffort === undefined ? - payload - : { ...payload, reasoning_effort: reasoningEffort } + sanitizedPayload + : { ...sanitizedPayload, reasoning_effort: reasoningEffort } } return { @@ -72,6 +151,7 @@ const buildRequestPayload = ( max_tokens: undefined, max_completion_tokens: payload.max_tokens, reasoning_effort: reasoningEffort, + user: sanitizeUserIdentifier(payload.user), } } @@ -101,6 +181,10 @@ export const createChatCompletions = async ( const claudeSettingsEnv = await getClaudeSettingsEnv() const requestPayload = buildRequestPayload(payload, claudeSettingsEnv) + if (shouldUseResponsesApiForModel(payload.model)) { + return createResponses(payload, headers, claudeSettingsEnv) + } + const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { method: "POST", headers, @@ -108,6 +192,10 @@ export const createChatCompletions = async ( }) if (!response.ok) { + if (await shouldRetryWithResponses(response)) { + return createResponses(payload, headers, claudeSettingsEnv) + } + consola.error("Failed to create chat completions", response) throw new HTTPError("Failed to create chat completions", response) } @@ -119,6 +207,52 @@ export const createChatCompletions = async ( return (await response.json()) as ChatCompletionResponse } +async function createResponses( + payload: ChatCompletionsPayload, + headers: Record, + claudeSettingsEnv: Record, +) { + const reasoningEffort = getRequestedReasoningEffort( + payload, + claudeSettingsEnv, + ) as ResponsesReasoningEffort | undefined + + const response = await fetch(`${copilotBaseUrl(state)}/responses`, { + method: "POST", + headers, + body: JSON.stringify( + buildResponsesRequestPayload(payload, reasoningEffort), + ), + }) + + if (!response.ok) { + consola.error("Failed to create responses", response) + throw new HTTPError("Failed to create responses", response) + } + + if (payload.stream) { + return translateResponsesStreamToChatCompletionStream(events(response)) + } + + return translateResponsesToChatCompletion( + (await response.json()) as ResponsesApiResponse, + ) +} + +async function shouldRetryWithResponses(response: Response): Promise { + try { + const errorBody = (await response.clone().json()) as { + error?: { + code?: string + } + } + + return errorBody.error?.code === "unsupported_api_for_model" + } catch { + return false + } +} + // Streaming types export interface ChatCompletionChunk { @@ -203,7 +337,7 @@ export interface ChatCompletionsPayload { temperature?: number | null top_p?: number | null max_tokens?: number | null - reasoning_effort?: "low" | "medium" | "high" | "max" | null + reasoning_effort?: "none" | "low" | "medium" | "high" | "max" | "xhigh" | null stop?: string | Array | null n?: number | null stream?: boolean | null diff --git a/src/services/copilot/responses.ts b/src/services/copilot/responses.ts new file mode 100644 index 000000000..2db4bbc63 --- /dev/null +++ b/src/services/copilot/responses.ts @@ -0,0 +1,583 @@ +import { randomUUID } from "node:crypto" + +import type { + ChatCompletionChunk, + ChatCompletionResponse, + ChatCompletionsPayload, + ContentPart, + Message, + Tool, +} from "./create-chat-completions" + +import { sanitizeUserIdentifier } from "./create-chat-completions" + +export interface ResponseStreamEventMessage { + data?: string + event?: string +} + +export interface ResponsesApiResponse { + id: string + created_at: number + model: string + output: Array + usage?: { + input_tokens: number + input_tokens_details?: { + cached_tokens?: number + } + output_tokens: number + output_tokens_details?: { + reasoning_tokens?: number + } + total_tokens: number + } + incomplete_details?: { + reason?: string | null + } | null +} + +export type ResponsesReasoningEffort = + | "none" + | "low" + | "medium" + | "high" + | "max" + | "xhigh" + +type ResponsesInput = string | Array + +type ResponsesInputItem = + | ResponsesMessageInput + | ResponsesFunctionCallInput + | ResponsesFunctionCallOutputInput + +type ResponsesMessageInput = { + role: "user" | "assistant" | "system" | "developer" + content: string | Array +} + +type ResponsesInputContentPart = + | { + type: "input_text" + text: string + } + | { + type: "input_image" + image_url: string + detail: "low" | "high" | "auto" + } + +type ResponsesFunctionCallInput = { + type: "function_call" + call_id: string + name: string + arguments: string +} + +type ResponsesFunctionCallOutputInput = { + type: "function_call_output" + call_id: string + output: string +} + +type ResponsesToolChoice = + | "none" + | "auto" + | "required" + | { type: "function"; name: string } + +export interface ResponsesRequestPayload { + model: string + input: ResponsesInput + stream?: boolean | null + max_output_tokens?: number | null + temperature?: number | null + top_p?: number | null + user?: string | null + reasoning?: { + effort: ResponsesReasoningEffort + } + tools?: Array + tool_choice?: ResponsesToolChoice | null + text?: { + format: { + type: "json_object" + } + } +} + +interface ResponsesTool { + type: "function" + name: string + description?: string + parameters: Record +} + +type ResponsesOutputItem = + | ResponsesMessageOutputItem + | ResponsesFunctionCallOutputItem + | ResponsesReasoningOutputItem + +interface ResponsesMessageOutputItem { + type: "message" + role: "assistant" + content: Array +} + +interface ResponsesMessageContentPart { + type: "output_text" + text: string +} + +interface ResponsesFunctionCallOutputItem { + type: "function_call" + call_id: string + name: string + arguments: string +} + +interface ResponsesReasoningOutputItem { + type: "reasoning" +} + +interface ResponsesStreamEnvelope { + type: string + response?: { + id: string + created_at: number + model: string + usage?: ResponsesApiResponse["usage"] + output?: Array + incomplete_details?: ResponsesApiResponse["incomplete_details"] + } + item?: + | Partial + | Partial + output_index?: number + delta?: string +} + +interface ResponseTranslationState { + responseId: string + createdAt: number + model: string + started: boolean +} + +interface CreateChunkOptions { + delta: ChatCompletionChunk["choices"][0]["delta"] + finishReason?: ChatCompletionChunk["choices"][0]["finish_reason"] + usage?: ChatCompletionChunk["usage"] +} + +const RESPONSES_ONLY_MODEL_PATTERN = /^(?:gpt-5\.3-codex|gpt-5\.4-mini)(?:-|$)/i + +export function shouldUseResponsesApiForModel(model: string): boolean { + return RESPONSES_ONLY_MODEL_PATTERN.test(model) +} + +export function buildResponsesRequestPayload( + payload: ChatCompletionsPayload, + reasoningEffort: ResponsesReasoningEffort | undefined, +): ResponsesRequestPayload { + return { + model: payload.model, + input: translateMessagesToResponsesInput(payload.messages), + stream: payload.stream, + max_output_tokens: payload.max_tokens, + temperature: payload.temperature, + top_p: payload.top_p, + user: sanitizeUserIdentifier(payload.user), + tools: translateTools(payload.tools), + tool_choice: translateToolChoice(payload.tool_choice), + reasoning: reasoningEffort ? { effort: reasoningEffort } : undefined, + text: + payload.response_format?.type === "json_object" ? + { format: { type: "json_object" } } + : undefined, + } +} + +export function translateResponsesToChatCompletion( + response: ResponsesApiResponse, +): ChatCompletionResponse { + const assistantMessages = response.output.filter( + (item): item is ResponsesMessageOutputItem => item.type === "message", + ) + const functionCalls = response.output.filter( + (item): item is ResponsesFunctionCallOutputItem => + item.type === "function_call", + ) + + const content = assistantMessages + .flatMap((item) => item.content) + .map((part) => part.text) + .join("") + + return { + id: response.id, + object: "chat.completion", + created: response.created_at, + model: response.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: content || null, + ...(functionCalls.length > 0 && { + tool_calls: functionCalls.map((toolCall) => ({ + id: toolCall.call_id, + type: "function" as const, + function: { + name: toolCall.name, + arguments: toolCall.arguments, + }, + })), + }), + }, + logprobs: null, + finish_reason: getFinishReason(response, functionCalls.length > 0), + }, + ], + usage: translateUsage(response.usage), + } +} + +export async function* translateResponsesStreamToChatCompletionStream( + responseStream: AsyncIterable, +): AsyncGenerator { + const state: ResponseTranslationState = { + responseId: randomUUID(), + createdAt: Math.floor(Date.now() / 1000), + model: "", + started: false, + } + + for await (const rawEvent of responseStream) { + if (!rawEvent.data || rawEvent.data === "[DONE]") { + continue + } + + const event = JSON.parse(rawEvent.data) as ResponsesStreamEnvelope + + if (event.response) { + state.responseId = event.response.id + state.createdAt = event.response.created_at + state.model = event.response.model + } + + if (event.type === "response.output_item.added") { + const chunk = handleOutputItemAdded(state, event) + if (chunk) { + yield chunk + } + continue + } + + if (event.type === "response.output_text.delta") { + yield createRoleChunk(state) + yield createChunk(state, { delta: { content: event.delta } }) + continue + } + + if (event.type === "response.function_call_arguments.delta") { + if (event.output_index === undefined) { + continue + } + + yield createRoleChunk(state) + yield createChunk(state, { + delta: { + tool_calls: [ + { + index: event.output_index, + type: "function", + function: { + arguments: event.delta ?? "", + }, + }, + ], + }, + }) + continue + } + + if (event.type === "response.completed") { + if (!event.response) { + continue + } + + yield createChunk( + { + ...state, + responseId: event.response.id, + createdAt: event.response.created_at, + model: event.response.model, + }, + { + delta: {}, + finishReason: getFinishReason( + { + output: event.response.output ?? [], + incomplete_details: event.response.incomplete_details, + }, + (event.response.output ?? []).some( + (item) => item.type === "function_call", + ), + ), + usage: translateUsage(event.response.usage), + }, + ) + yield { data: "[DONE]" } + return + } + } +} + +function handleOutputItemAdded( + state: ResponseTranslationState, + event: ResponsesStreamEnvelope, +): ResponseStreamEventMessage | undefined { + if (event.item?.type === "message") { + return createRoleChunk(state) + } + + if ( + event.item?.type === "function_call" + && event.output_index !== undefined + ) { + state.started = true + return createChunk(state, { + delta: { + role: "assistant", + tool_calls: [ + { + index: event.output_index, + id: event.item.call_id, + type: "function", + function: { + name: event.item.name, + arguments: event.item.arguments ?? "", + }, + }, + ], + }, + }) + } + + return undefined +} + +function createRoleChunk( + state: ResponseTranslationState, +): ResponseStreamEventMessage { + if (state.started) { + return { data: "" } + } + + state.started = true + return createChunk(state, { delta: { role: "assistant" } }) +} + +function createChunk( + state: ResponseTranslationState, + { delta, finishReason = null, usage }: CreateChunkOptions, +): ResponseStreamEventMessage { + return { + data: JSON.stringify({ + id: state.responseId, + object: "chat.completion.chunk", + created: state.createdAt, + model: state.model, + choices: [ + { + index: 0, + delta, + finish_reason: finishReason, + logprobs: null, + }, + ], + usage, + } satisfies ChatCompletionChunk), + } +} + +function translateMessagesToResponsesInput( + messages: Array, +): ResponsesInput { + const items = messages.flatMap((message) => translateMessage(message)) + + if ( + items.length === 1 + && "role" in items[0] + && items[0].role === "user" + && typeof items[0].content === "string" + ) { + return items[0].content + } + + return items +} + +function translateMessage(message: Message): Array { + if (message.role === "tool") { + return [ + { + type: "function_call_output", + call_id: message.tool_call_id ?? "", + output: stringifyToolOutput(message.content), + }, + ] + } + + const translated: Array = [] + + if (hasContent(message.content)) { + translated.push({ + role: message.role, + content: translateContent(message.content), + }) + } + + if (message.role === "assistant" && message.tool_calls) { + translated.push( + ...message.tool_calls.map((toolCall) => ({ + type: "function_call" as const, + call_id: toolCall.id, + name: toolCall.function.name, + arguments: toolCall.function.arguments, + })), + ) + } + + return translated +} + +function hasContent(content: Message["content"]): boolean { + if (content === null) { + return false + } + + if (typeof content === "string") { + return content.length > 0 + } + + return content.length > 0 +} + +function translateContent( + content: Message["content"], +): ResponsesMessageInput["content"] { + if (typeof content === "string") { + return content + } + + if (!content || content.length === 0) { + return "" + } + + return content.map((part) => translateContentPart(part)) +} + +function translateContentPart(part: ContentPart): ResponsesInputContentPart { + if (part.type === "text") { + return { + type: "input_text", + text: part.text, + } + } + + return { + type: "input_image", + image_url: part.image_url.url, + detail: part.image_url.detail ?? "auto", + } +} + +function stringifyToolOutput(content: Message["content"]): string { + if (typeof content === "string") { + return content + } + + if (!content) { + return "" + } + + const text = content + .filter( + (part): part is Extract => + part.type === "text", + ) + .map((part) => part.text) + .join("\n\n") + + return text || JSON.stringify(content) +} + +function translateTools( + tools: Array | null | undefined, +): Array | undefined { + if (!tools) { + return undefined + } + + return tools.map((tool) => ({ + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + })) +} + +function translateToolChoice( + toolChoice: ChatCompletionsPayload["tool_choice"], +): ResponsesToolChoice | undefined { + if (!toolChoice) { + return undefined + } + + if (typeof toolChoice === "string") { + return toolChoice + } + + return { + type: "function", + name: toolChoice.function.name, + } +} + +function translateUsage( + usage: ResponsesApiResponse["usage"] | undefined, +): ChatCompletionResponse["usage"] | undefined { + if (!usage) { + return undefined + } + + return { + prompt_tokens: usage.input_tokens, + completion_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + ...(usage.input_tokens_details?.cached_tokens !== undefined && { + prompt_tokens_details: { + cached_tokens: usage.input_tokens_details.cached_tokens, + }, + }), + } +} + +function getFinishReason( + response: Pick, + hasFunctionCalls: boolean, +): "stop" | "length" | "tool_calls" | "content_filter" { + if (hasFunctionCalls) { + return "tool_calls" + } + + if (response.incomplete_details?.reason?.includes("max_output_tokens")) { + return "length" + } + + return "stop" +} diff --git a/tests/create-chat-completions.test.ts b/tests/create-chat-completions.test.ts index 20bee5b23..2bb8f943a 100644 --- a/tests/create-chat-completions.test.ts +++ b/tests/create-chat-completions.test.ts @@ -13,15 +13,26 @@ state.copilotToken = "test-token" state.vsCodeVersion = "1.0.0" state.accountType = "individual" +const createMockResponse = (jsonBody: unknown, ok: boolean = true) => ({ + ok, + status: ok ? 200 : 400, + json: () => Promise.resolve(jsonBody), + text: () => Promise.resolve(JSON.stringify(jsonBody)), + clone() { + return createMockResponse(jsonBody, ok) + }, +}) + // Helper to mock fetch const fetchMock = mock( (_url: string, opts: { headers: Record; body?: string }) => { - return { - ok: true, - json: () => ({ id: "123", object: "chat.completion", choices: [] }), - headers: opts.headers, - body: opts.body, - } + return Object.assign( + createMockResponse({ id: "123", object: "chat.completion", choices: [] }), + { + headers: opts.headers, + body: opts.body, + }, + ) }, ) // @ts-expect-error - Mock fetch doesn't implement all fetch properties @@ -85,6 +96,163 @@ test("uses max_completion_tokens for GPT-5 family models", async () => { expect(body.max_tokens).toBeUndefined() }) +test("uses max_completion_tokens for gpt-5.3-codex", async () => { + fetchMock.mockImplementationOnce( + (_url: string, opts: { headers: Record; body?: string }) => + Object.assign( + createMockResponse({ + id: "resp-123", + created_at: 123, + model: "gpt-5.3-codex", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "OK" }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }), + { headers: opts.headers, body: opts.body }, + ), + ) + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.3-codex", + max_tokens: 384, + } + + const response = await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(fetchMock.mock.calls[0][0]).toContain("/responses") + expect(body.max_output_tokens).toBe(384) + expect(body.reasoning).toEqual({ effort: "medium" }) + expect(body.input).toBe("hi") + expect((response as { model: string }).model).toBe("gpt-5.3-codex") +}) + +test("uses max_completion_tokens for gpt-5.4-mini", async () => { + fetchMock.mockImplementationOnce( + (_url: string, opts: { headers: Record; body?: string }) => + Object.assign( + createMockResponse({ + id: "resp-234", + created_at: 234, + model: "gpt-5.4-mini-2026-03-17", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Mini OK" }], + }, + ], + usage: { + input_tokens: 11, + output_tokens: 7, + total_tokens: 18, + }, + }), + { headers: opts.headers, body: opts.body }, + ), + ) + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4-mini", + max_tokens: 192, + } + + const response = await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(fetchMock.mock.calls[0][0]).toContain("/responses") + expect(body.max_output_tokens).toBe(192) + expect(body.reasoning).toEqual({ effort: "medium" }) + expect((response as { model: string }).model).toBe("gpt-5.4-mini-2026-03-17") +}) + +test("translates responses tool calls back to chat completions", async () => { + fetchMock.mockImplementationOnce( + (_url: string, opts: { headers: Record; body?: string }) => + Object.assign( + createMockResponse({ + id: "resp-tool", + created_at: 345, + model: "gpt-5.4-mini-2026-03-17", + output: [ + { + type: "function_call", + call_id: "call_123", + name: "lookup_weather", + arguments: '{"city":"Tokyo"}', + }, + ], + usage: { + input_tokens: 20, + output_tokens: 12, + total_tokens: 32, + }, + }), + { headers: opts.headers, body: opts.body }, + ), + ) + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "weather?" }], + model: "gpt-5.4-mini", + max_tokens: 64, + tools: [ + { + type: "function", + function: { + name: "lookup_weather", + parameters: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + }, + }, + ], + } + + const response = await createChatCompletions(payload) + + expect(fetchMock.mock.calls[0][0]).toContain("/responses") + expect( + ( + response as { + choices: Array<{ + finish_reason: string + message: { tool_calls: Array<{ function: { name: string } }> } + }> + } + ).choices[0]?.finish_reason, + ).toBe("tool_calls") + expect( + ( + response as { + choices: Array<{ + message: { tool_calls: Array<{ function: { name: string } }> } + }> + } + ).choices[0]?.message.tool_calls[0]?.function.name, + ).toBe("lookup_weather") +}) + test("keeps max_tokens for non GPT-5 models", async () => { const payload: ChatCompletionsPayload = { messages: [{ role: "user", content: "hi" }], @@ -121,6 +289,167 @@ test("passes through explicit reasoning_effort for GPT-5 family models", async ( expect(body.max_completion_tokens).toBe(128) }) +test("truncates long user identifiers for gpt-5.4", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + user: "u".repeat(100), + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect((body.user as string).length).toBe(64) +}) + +test("falls back to medium for unsupported none on gpt-5.4", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4", + max_tokens: 128, + reasoning_effort: "none", + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.reasoning_effort).toBe("medium") + expect(body.max_completion_tokens).toBe(128) +}) + +test("passes through reasoning_effort none for gpt-5.4-mini", async () => { + fetchMock.mockImplementationOnce( + (_url: string, opts: { headers: Record; body?: string }) => + Object.assign( + createMockResponse({ + id: "resp-none-mini", + created_at: 456, + model: "gpt-5.4-mini-2026-03-17", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "OK" }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }), + { headers: opts.headers, body: opts.body }, + ), + ) + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4-mini", + max_tokens: 128, + reasoning_effort: "none", + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(fetchMock.mock.calls[0][0]).toContain("/responses") + expect(body.reasoning).toEqual({ effort: "none" }) + expect(body.max_output_tokens).toBe(128) +}) + +test("truncates long user identifiers for gpt-5.4-mini", async () => { + fetchMock.mockImplementationOnce( + (_url: string, opts: { headers: Record; body?: string }) => + Object.assign( + createMockResponse({ + id: "resp-user-mini", + created_at: 678, + model: "gpt-5.4-mini-2026-03-17", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "OK" }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }), + { headers: opts.headers, body: opts.body }, + ), + ) + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4-mini", + max_tokens: 128, + user: "u".repeat(100), + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect((body.user as string).length).toBe(64) +}) + +test("falls back to medium for unsupported high on gpt-5.4-mini", async () => { + fetchMock.mockImplementationOnce( + (_url: string, opts: { headers: Record; body?: string }) => + Object.assign( + createMockResponse({ + id: "resp-high-mini", + created_at: 567, + model: "gpt-5.4-mini-2026-03-17", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "OK" }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }), + { headers: opts.headers, body: opts.body }, + ), + ) + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.4-mini", + max_tokens: 128, + reasoning_effort: "high", + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(fetchMock.mock.calls[0][0]).toContain("/responses") + expect(body.reasoning).toEqual({ effort: "medium" }) +}) + test("uses COPILOT_REASONING_EFFORT env override for GPT-5 family models", async () => { process.env.COPILOT_REASONING_EFFORT = "xhigh" @@ -136,7 +465,7 @@ test("uses COPILOT_REASONING_EFFORT env override for GPT-5 family models", async (fetchMock.mock.calls[0][1] as { body: string }).body, ) as Record - expect(body.reasoning_effort).toBe("max") + expect(body.reasoning_effort).toBe("xhigh") }) test("defaults GPT-5 family models to medium reasoning effort", async () => { @@ -184,7 +513,7 @@ test("uses COPILOT_REASONING_EFFORT from Claude settings.json", async () => { (fetchMock.mock.calls[0][1] as { body: string }).body, ) as Record - expect(body.reasoning_effort).toBe("max") + expect(body.reasoning_effort).toBe("xhigh") } finally { await rm(tempHome, { recursive: true, force: true }) } From 68af3d4a7136a47e6c1b5319e04b898eb508ef8b Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 12:43:03 +0800 Subject: [PATCH 04/19] test: align anthropic xhigh expectation --- tests/anthropic-request.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 439a44cde..dee6ad6a5 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -156,7 +156,7 @@ describe("Anthropic to OpenAI translation logic", () => { const openAIPayload = translateToOpenAI(anthropicPayload) - expect(openAIPayload.reasoning_effort).toBe("max") + expect(openAIPayload.reasoning_effort).toBe("xhigh") }) test("should translate minimal Anthropic payload to valid OpenAI payload", () => { From 9f5006ca1d7b80fd50d68e0eabb3631c1480b08e Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 12:58:54 +0800 Subject: [PATCH 05/19] upgrade to 0.7.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ffbd41312..ab6719309 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betahi-copilot-api", - "version": "0.7.1", + "version": "0.7.2", "description": "Fork of copilot-api with GPT-5.4 and reasoning effort support for Claude Code.", "keywords": [ "proxy", From dea0f0a3c636f1432bb1508ff5ab2ed31fec9dfb Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 16:43:09 +0800 Subject: [PATCH 06/19] change user url --- .github/FUNDING.yml | 2 +- README.md | 4 ++-- src/start.ts | 2 +- start.bat | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0c71cd828..ad1f32c50 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -3,7 +3,7 @@ github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username -ko_fi: ericc_ch +ko_fi: tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username diff --git a/README.md b/README.md index 8df0bd107..e51833d20 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ After starting the server, a URL to the Copilot Usage Dashboard will be displaye npx betahi-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://betahi.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: @@ -326,7 +326,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://betahi.github.io/copilot-api?endpoint=http://your-api-server/usage` ## Using with Claude Code diff --git a/src/start.ts b/src/start.ts index 14abbbdff..e96860b72 100644 --- a/src/start.ts +++ b/src/start.ts @@ -111,7 +111,7 @@ export async function runServer(options: RunServerOptions): Promise { } consola.box( - `🌐 Usage Viewer: https://ericc-ch.github.io/copilot-api?endpoint=${serverUrl}/usage`, + `🌐 Usage Viewer: https://betahi.github.io/copilot-api?endpoint=${serverUrl}/usage`, ) serve({ diff --git a/start.bat b/start.bat index 1a0f8cb83..f681710ae 100644 --- a/start.bat +++ b/start.bat @@ -14,7 +14,7 @@ echo Starting server... echo The usage viewer page will open automatically after the server starts echo. -start "" "https://ericc-ch.github.io/copilot-api?endpoint=http://localhost:4141/usage" +start "" "https://betahi.github.io/copilot-api?endpoint=http://localhost:4141/usage" bun run dev pause From 2e64c078a5c274700f4f885d54e73d7eebee675c Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 16:49:15 +0800 Subject: [PATCH 07/19] add master --- .github/workflows/deploy-pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index e5b05974c..7c3cdc329 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -2,7 +2,7 @@ name: Deploy to GitHub Pages on: push: - branches: [ "main" ] + branches: [ "main", "master" ] workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages From 4d7e4a71bbac3350b6314b9c88d0eafbed4267c5 Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 17:54:51 +0800 Subject: [PATCH 08/19] support gemini-3.1-pro and gemini-3-flash aliases --- README.md | 6 +++- package.json | 3 +- src/lib/models.ts | 55 ++++++++++++++++++++++++++------- tests/anthropic-request.test.ts | 44 +++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e51833d20..3995f4f34 100644 --- a/README.md +++ b/README.md @@ -210,13 +210,17 @@ 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. | -## **GPT Support** +## **GPT and Gemini Support** | Model | Status | Notes | | ----- | ------ | ----- | | `gpt-5.4` | Supported | Uses the standard GPT-5 chat/completions path. | | `gpt-5.3-codex` | Supported | Uses the Responses API bridge internally. | | `gpt-5.4-mini` | Supported | Uses the Responses API bridge internally. | +| `gemini-3.1-pro` | Supported | Resolves to Copilot's current `gemini-3.1-pro-preview` model. | +| `gemini-3-flash` | Supported | Resolves to Copilot's current `gemini-3-flash-preview` model. | + +Gemini preview IDs can also be used directly, for example `gemini-3.1-pro-preview` and `gemini-3-flash-preview`. ### Reasoning Effort Matrix diff --git a/package.json b/package.json index ab6719309..b67c00f58 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "betahi-copilot-api", "version": "0.7.2", - "description": "Fork of copilot-api with GPT-5.4 and reasoning effort support for Claude Code.", + "description": "GitHub Copilot proxy with OpenAI/Anthropic compatibility, GPT-5 and Gemini support for Claude Code.", "keywords": [ "proxy", "github-copilot", @@ -54,6 +54,7 @@ }, "devDependencies": { "@echristian/eslint-config": "^0.0.54", + "@eslint/markdown": "^8.0.1", "@types/bun": "^1.2.23", "@types/proxy-from-env": "^1.0.4", "bumpp": "^10.2.3", diff --git a/src/lib/models.ts b/src/lib/models.ts index d110bca11..fd2c4efb0 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -63,6 +63,48 @@ const pickBestModel = (models: Array): Model | undefined => { )[0] } +const getBestPrefixMatches = ( + models: Array, + aliasCandidates: Array, +): Array => { + let bestMatchLength = 0 + const matches: Array = [] + + for (const model of models) { + const normalizedModelId = normalizeModelId(model.id) + const matchedAliasLength = Math.max( + 0, + ...aliasCandidates.map((candidate) => { + const normalizedCandidate = normalizeModelId(candidate) + + return ( + normalizedCandidate.length >= 4 + && normalizedModelId.startsWith(normalizedCandidate) + ) ? + normalizedCandidate.length + : 0 + }), + ) + + if (matchedAliasLength === 0) { + continue + } + + if (matchedAliasLength > bestMatchLength) { + bestMatchLength = matchedAliasLength + matches.length = 0 + matches.push(model) + continue + } + + if (matchedAliasLength === bestMatchLength) { + matches.push(model) + } + } + + return matches +} + export const resolveModel = ( requestedModelId: string, models: Array | undefined = state.models?.data, @@ -88,18 +130,7 @@ export const resolveModel = ( return pickBestModel(normalizedExactMatches) } - const familyPrefixMatches = models.filter((model) => { - const normalizedModelId = normalizeModelId(model.id) - - return aliasCandidates.some((candidate) => { - const normalizedCandidate = normalizeModelId(candidate) - - return ( - normalizedCandidate.length >= 4 - && normalizedModelId.startsWith(normalizedCandidate) - ) - }) - }) + const familyPrefixMatches = getBestPrefixMatches(models, aliasCandidates) return pickBestModel(familyPrefixMatches) } diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index dee6ad6a5..bf8f61711 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -91,7 +91,7 @@ function isValidChatCompletionRequest(payload: unknown): boolean { return result.success } -describe("Anthropic to OpenAI translation logic", () => { +describe("Anthropic model resolution", () => { test("should resolve GPT-5 family aliases to a supported Copilot model", () => { state.models = { object: "list", @@ -126,6 +126,48 @@ describe("Anthropic to OpenAI translation logic", () => { expect(openAIPayload.model).toBe("claude-opus-4") }) + test("should resolve Gemini 3.1 Pro alias to the preview Copilot model", () => { + state.models = { + object: "list", + data: [ + createModel("gemini-3.1-pro-preview"), + createModel("gemini-3-flash-preview"), + ], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "gemini-3.1-pro", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.model).toBe("gemini-3.1-pro-preview") + }) + + test("should resolve Gemini 3 Flash alias to the flash preview model", () => { + state.models = { + object: "list", + data: [ + createModel("gemini-3.1-pro-preview"), + createModel("gemini-3-flash-preview"), + ], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "gemini-3-flash", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.model).toBe("gemini-3-flash-preview") + }) +}) + +describe("Anthropic to OpenAI translation logic", () => { test("should map Anthropic thinking budget to GPT reasoning effort", () => { state.models = originalModels From 37ccc8cd5322f1348613ee318887d5ba0b8ba2e8 Mon Sep 17 00:00:00 2001 From: betaHi Date: Wed, 15 Apr 2026 18:02:53 +0800 Subject: [PATCH 09/19] update version to 0.7.3 --- eslint.config.js | 11 +++++++++-- package.json | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index c9f79bea5..3bd75aa0f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,14 @@ import config from "@echristian/eslint-config" +import markdown from "@eslint/markdown" +import { defineConfig } from "eslint/config" -export default config({ +const baseConfig = config({ prettier: { plugins: ["prettier-plugin-packagejson"], }, -}) +}).map((entry) => ({ + ...entry, + ignores: [...(entry.ignores ?? []), "**/*.md"], +})) + +export default defineConfig([...baseConfig, ...markdown.configs.recommended]) diff --git a/package.json b/package.json index b67c00f58..9d5bea3fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betahi-copilot-api", - "version": "0.7.2", + "version": "0.7.3", "description": "GitHub Copilot proxy with OpenAI/Anthropic compatibility, GPT-5 and Gemini support for Claude Code.", "keywords": [ "proxy", From 1508fc30153a809979b9849afa0745011ac1d704 Mon Sep 17 00:00:00 2001 From: betaHi Date: Fri, 17 Apr 2026 09:20:28 +0800 Subject: [PATCH 10/19] support opus 4.7 and adaptive thinking --- README.md | 6 +- package.json | 4 +- src/lib/models.ts | 4 + src/routes/messages/anthropic-types.ts | 2 +- src/routes/messages/non-stream-translation.ts | 105 ++++++++++++ .../copilot/create-chat-completions.ts | 70 ++++++++ tests/anthropic-request.test.ts | 151 ++++++++++++++++++ tests/create-chat-completions.test.ts | 76 +++++++++ 8 files changed, 413 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3995f4f34..c6f774a8f 100644 --- a/README.md +++ b/README.md @@ -210,13 +210,14 @@ 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. | -## **GPT and Gemini Support** +## **GPT, Claude Opus 4.7, and Gemini Support** | Model | Status | Notes | | ----- | ------ | ----- | | `gpt-5.4` | Supported | Uses the standard GPT-5 chat/completions path. | | `gpt-5.3-codex` | Supported | Uses the Responses API bridge internally. | | `gpt-5.4-mini` | Supported | Uses the Responses API bridge internally. | +| `claude-opus-4.7` | Supported | Snapshot aliases such as `claude-opus-4-7-20260417` resolve to this model, with `low`, `medium`, `high`, `xhigh`, and `max` effort support. | | `gemini-3.1-pro` | Supported | Resolves to Copilot's current `gemini-3.1-pro-preview` model. | | `gemini-3-flash` | Supported | Resolves to Copilot's current `gemini-3-flash-preview` model. | @@ -231,8 +232,9 @@ Reasoning effort is model-specific. | `gpt-5.4` | `low`, `medium`, `high`, `xhigh` | `medium` | | `gpt-5.3-codex` | `low`, `medium`, `high`, `xhigh` | `medium` | | `gpt-5.4-mini` | `none`, `low`, `medium` | `medium` | +| `claude-opus-4.7` | `low`, `medium`, `high`, `xhigh`, `max` | `medium` | -If an unsupported effort value is sent for one of these models, the proxy falls back to that model's default `medium` instead of forwarding an invalid upstream request. +If an unsupported effort value is sent for one of these models, the proxy falls back to that model's default behavior instead of forwarding an invalid upstream request. ### `settings.json` diff --git a/package.json b/package.json index 9d5bea3fe..74613e442 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "build": "tsdown", "dev": "bun run --watch ./src/main.ts", "knip": "knip-bun", - "lint": "eslint --cache", - "lint:all": "eslint --cache .", + "lint": "eslint --cache --no-warn-ignored", + "lint:all": "eslint --cache --no-warn-ignored .", "prepack": "bun run build", "prepare": "simple-git-hooks", "release": "bumpp && bun publish --access public", diff --git a/src/lib/models.ts b/src/lib/models.ts index fd2c4efb0..42ff054d1 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -13,6 +13,10 @@ const stripSnapshotSuffix = (modelId: string): string => { return "claude-sonnet-4" } + if (/^claude-opus-4-7-\d{8}$/.test(modelId)) { + return "claude-opus-4.7" + } + if (modelId.startsWith("claude-opus-4-")) { return "claude-opus-4" } diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 2732e7d9d..fefbcdd49 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -19,7 +19,7 @@ export interface AnthropicMessagesPayload { name?: string } thinking?: { - type: "enabled" + type: "enabled" | "adaptive" budget_tokens?: number } reasoning_effort?: "none" | "low" | "medium" | "high" | "max" | "xhigh" diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index f86801e92..1031f2261 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -42,6 +42,8 @@ export function translateToOpenAI( stream: payload.stream, temperature: payload.temperature, top_p: payload.top_p, + thinking: translateThinking(payload), + output_config: translateOutputConfig(payload), reasoning_effort: translateReasoningEffort(payload), user: payload.metadata?.user_id, tools: translateAnthropicToolsToOpenAI(payload.tools), @@ -49,11 +51,114 @@ export function translateToOpenAI( } } +function isClaudeModel(modelId: string): boolean { + return modelId.startsWith("claude-") +} + +function isClaudeOpus47Model(modelId: string): boolean { + return modelId === "claude-opus-4.7" +} + +type ClaudeOpus47Effort = NonNullable< + NonNullable["effort"] +> + +function normalizeClaudeEffort( + value: string | undefined, +): ClaudeOpus47Effort | undefined { + switch (value?.toLowerCase()) { + case "low": { + return "low" + } + case "medium": { + return "medium" + } + case "high": { + return "high" + } + case "xhigh": { + return "xhigh" + } + case "max": { + return "max" + } + default: { + return undefined + } + } +} + +function getClaudeOpus47Effort( + payload: AnthropicMessagesPayload, +): ClaudeOpus47Effort | undefined { + const explicitEffort = normalizeClaudeEffort(payload.reasoning_effort) + if (explicitEffort) { + return explicitEffort + } + + if (payload.thinking?.type !== "enabled") { + return undefined + } + + const budgetTokens = payload.thinking.budget_tokens + if (budgetTokens === undefined) { + return "medium" + } + + if (budgetTokens <= 2_048) { + return "low" + } + + if (budgetTokens <= 8_192) { + return "medium" + } + + if (budgetTokens <= 24_576) { + return "high" + } + + return "xhigh" +} + +function translateThinking( + payload: AnthropicMessagesPayload, +): ChatCompletionsPayload["thinking"] { + const modelId = translateModelName(payload.model) + + if (!isClaudeOpus47Model(modelId)) { + return undefined + } + + if (payload.thinking?.type === "adaptive") { + return { type: "adaptive" } + } + + return payload.thinking?.type === "enabled" ? { type: "adaptive" } : undefined +} + +function translateOutputConfig( + payload: AnthropicMessagesPayload, +): ChatCompletionsPayload["output_config"] { + const modelId = translateModelName(payload.model) + + if (!isClaudeOpus47Model(modelId)) { + return undefined + } + + const effort = getClaudeOpus47Effort(payload) + + return effort ? { effort } : undefined +} + function translateReasoningEffort( payload: AnthropicMessagesPayload, ): ChatCompletionsPayload["reasoning_effort"] { const modelId = translateModelName(payload.model) + if (isClaudeModel(modelId)) { + return undefined + } + if (payload.reasoning_effort) { return sanitizeReasoningEffortForModel( modelId, diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 7bd41d675..0058e9311 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -18,6 +18,13 @@ import { const usesMaxCompletionTokens = (modelId: string): boolean => modelId.startsWith("gpt-5") +const isClaudeOpus47Model = (modelId: string): boolean => + modelId === "claude-opus-4.7" + +type ClaudeOpus47Effort = NonNullable< + NonNullable["effort"] +> + // Copilot rejects user identifiers longer than 64 characters. const MAX_USER_LENGTH = 64 @@ -102,6 +109,47 @@ const normalizeReasoningEffort = ( } } +const normalizeClaudeOpus47Effort = ( + value: string | undefined | null, +): ClaudeOpus47Effort | undefined => { + switch (value?.toLowerCase()) { + case "low": { + return "low" + } + case "medium": { + return "medium" + } + case "high": { + return "high" + } + case "xhigh": { + return "xhigh" + } + case "max": { + return "max" + } + default: { + return undefined + } + } +} + +const getRequestedClaudeOpus47Effort = ( + payload: ChatCompletionsPayload, + claudeSettingsEnv: Record, +): ClaudeOpus47Effort | undefined => { + if (!isClaudeOpus47Model(payload.model)) { + return undefined + } + + return ( + payload.output_config?.effort + ?? normalizeClaudeOpus47Effort(payload.reasoning_effort) + ?? normalizeClaudeOpus47Effort(process.env.COPILOT_REASONING_EFFORT) + ?? normalizeClaudeOpus47Effort(claudeSettingsEnv.COPILOT_REASONING_EFFORT) + ) +} + export const sanitizeUserIdentifier = ( user: string | null | undefined, ): string | undefined => { @@ -120,6 +168,10 @@ const buildRequestPayload = ( payload, claudeSettingsEnv, ) + const requestedClaudeOpus47Effort = getRequestedClaudeOpus47Effort( + payload, + claudeSettingsEnv, + ) const reasoningEffort = ( @@ -138,6 +190,17 @@ const buildRequestPayload = ( ) { const sanitizedPayload = { ...payload, + output_config: + requestedClaudeOpus47Effort ? + { + ...payload.output_config, + effort: requestedClaudeOpus47Effort, + } + : payload.output_config, + reasoning_effort: + isClaudeOpus47Model(payload.model) ? undefined : ( + payload.reasoning_effort + ), user: sanitizeUserIdentifier(payload.user), } @@ -337,6 +400,13 @@ export interface ChatCompletionsPayload { temperature?: number | null top_p?: number | null max_tokens?: number | null + thinking?: { + type: "enabled" | "adaptive" + budget_tokens?: number + } | null + output_config?: { + effort?: "low" | "medium" | "high" | "xhigh" | "max" + } | null reasoning_effort?: "none" | "low" | "medium" | "high" | "max" | "xhigh" | null stop?: string | Array | null n?: number | null diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index bf8f61711..edff6c62b 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -74,10 +74,23 @@ const chatCompletionRequestSchema = z.object({ .optional() .nullable(), stream: z.boolean().optional().nullable(), + thinking: z + .object({ + type: z.enum(["enabled", "adaptive"]), + budget_tokens: z.number().int().optional(), + }) + .optional() + .nullable(), temperature: z.number().min(0).max(2).optional().nullable(), top_p: z.number().min(0).max(1).optional().nullable(), tools: z.array(z.any()).optional(), tool_choice: z.union([z.string(), z.object({})]).optional(), + output_config: z + .object({ + effort: z.enum(["low", "medium", "high", "xhigh", "max"]), + }) + .optional() + .nullable(), user: z.string().optional(), }) @@ -126,6 +139,23 @@ describe("Anthropic model resolution", () => { expect(openAIPayload.model).toBe("claude-opus-4") }) + test("should resolve Claude Opus 4.7 snapshot aliases to the 4.7 Copilot model", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4.6"), createModel("claude-opus-4.7")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4-7-20260417", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.model).toBe("claude-opus-4.7") + }) + test("should resolve Gemini 3.1 Pro alias to the preview Copilot model", () => { state.models = { object: "list", @@ -186,6 +216,125 @@ describe("Anthropic to OpenAI translation logic", () => { expect(openAIPayload.reasoning_effort).toBe("high") }) + test("should pass through Claude Opus 4.7 thinking blocks for enabled thinking", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4.7")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4.7", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + thinking: { + type: "enabled", + budget_tokens: 16_000, + }, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.thinking).toEqual({ type: "adaptive" }) + expect(openAIPayload.output_config).toEqual({ effort: "high" }) + expect(openAIPayload.reasoning_effort).toBeUndefined() + }) + + test("should pass through Claude Opus 4.7 adaptive thinking", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4.7")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4.7", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + thinking: { + type: "adaptive", + }, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.thinking).toEqual({ type: "adaptive" }) + expect(openAIPayload.output_config).toBeUndefined() + expect(openAIPayload.reasoning_effort).toBeUndefined() + }) + + test("should map Claude Opus 4.7 reasoning_effort to output_config.effort", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4.7")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4.7", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + reasoning_effort: "max", + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.thinking).toBeUndefined() + expect(openAIPayload.output_config).toEqual({ effort: "max" }) + expect(openAIPayload.reasoning_effort).toBeUndefined() + }) + + test("should map Claude Opus 4.7 thinking budget tiers to effort buckets", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4.7")], + } + + const cases = [ + { budgetTokens: 1_024, expectedEffort: "low" }, + { budgetTokens: 8_192, expectedEffort: "medium" }, + { budgetTokens: 16_000, expectedEffort: "high" }, + { budgetTokens: 32_000, expectedEffort: "xhigh" }, + ] as const + + for (const { budgetTokens, expectedEffort } of cases) { + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4.7", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + thinking: { + type: "enabled", + budget_tokens: budgetTokens, + }, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.thinking).toEqual({ type: "adaptive" }) + expect(openAIPayload.output_config).toEqual({ effort: expectedEffort }) + expect(openAIPayload.reasoning_effort).toBeUndefined() + } + }) + + test("should not pass through thinking for other Claude models", () => { + state.models = { + object: "list", + data: [createModel("claude-opus-4.6")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "claude-opus-4.6", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + thinking: { + type: "adaptive", + }, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.thinking).toBeUndefined() + expect(openAIPayload.output_config).toBeUndefined() + expect(openAIPayload.reasoning_effort).toBeUndefined() + }) + test("should honor explicit reasoning effort aliases", () => { state.models = originalModels @@ -200,7 +349,9 @@ describe("Anthropic to OpenAI translation logic", () => { expect(openAIPayload.reasoning_effort).toBe("xhigh") }) +}) +describe("Anthropic translation payload validation", () => { test("should translate minimal Anthropic payload to valid OpenAI payload", () => { state.models = originalModels diff --git a/tests/create-chat-completions.test.ts b/tests/create-chat-completions.test.ts index 2bb8f943a..bce700d06 100644 --- a/tests/create-chat-completions.test.ts +++ b/tests/create-chat-completions.test.ts @@ -468,6 +468,46 @@ test("uses COPILOT_REASONING_EFFORT env override for GPT-5 family models", async expect(body.reasoning_effort).toBe("xhigh") }) +test("uses COPILOT_REASONING_EFFORT env override for claude-opus-4.7", async () => { + process.env.COPILOT_REASONING_EFFORT = "xhigh" + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "claude-opus-4.7", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_tokens).toBe(128) + expect(body.output_config).toEqual({ effort: "xhigh" }) + expect(body.reasoning_effort).toBeUndefined() +}) + +test("does not apply Claude Opus 4.7 effort override to other Claude models", async () => { + process.env.COPILOT_REASONING_EFFORT = "xhigh" + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "claude-opus-4.6", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_tokens).toBe(128) + expect(body.output_config).toBeUndefined() + expect(body.reasoning_effort).toBeUndefined() +}) + test("defaults GPT-5 family models to medium reasoning effort", async () => { const payload: ChatCompletionsPayload = { messages: [{ role: "user", content: "hi" }], @@ -519,6 +559,42 @@ test("uses COPILOT_REASONING_EFFORT from Claude settings.json", async () => { } }) +test("uses COPILOT_REASONING_EFFORT from Claude settings.json for claude-opus-4.7", async () => { + const tempHome = await mkdtemp(path.join(os.tmpdir(), "claude-settings-")) + const claudeDirectory = path.join(tempHome, ".claude") + + try { + await mkdir(claudeDirectory, { recursive: true }) + await writeFile( + path.join(claudeDirectory, "settings.json"), + JSON.stringify({ + env: { + COPILOT_REASONING_EFFORT: "max", + }, + }), + ) + + process.env.HOME = tempHome + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "claude-opus-4.7", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.output_config).toEqual({ effort: "max" }) + expect(body.reasoning_effort).toBeUndefined() + } finally { + await rm(tempHome, { recursive: true, force: true }) + } +}) + test("omits reasoning_effort for GPT-5 family tool calls", async () => { const payload: ChatCompletionsPayload = { messages: [{ role: "user", content: "hi" }], From 5ebab3e09a73149517a188eee5204858e136756f Mon Sep 17 00:00:00 2001 From: betaHi Date: Fri, 17 Apr 2026 09:29:02 +0800 Subject: [PATCH 11/19] update version to 0.7.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74613e442..a5659fcc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betahi-copilot-api", - "version": "0.7.3", + "version": "0.7.4", "description": "GitHub Copilot proxy with OpenAI/Anthropic compatibility, GPT-5 and Gemini support for Claude Code.", "keywords": [ "proxy", From e36643061dab84596798434562192820aebf2d49 Mon Sep 17 00:00:00 2001 From: betaHi Date: Sat, 25 Apr 2026 18:06:57 +0800 Subject: [PATCH 12/19] support gpt 5.5 --- README.md | 2 + .../copilot/create-chat-completions.ts | 4 + tests/anthropic-request.test.ts | 17 +++ tests/create-chat-completions.test.ts | 119 +++++++++++++++++- 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6f774a8f..c451123bb 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ New endpoints for monitoring your Copilot usage and quotas. | Model | Status | Notes | | ----- | ------ | ----- | | `gpt-5.4` | Supported | Uses the standard GPT-5 chat/completions path. | +| `gpt-5.5` | Supported | Uses the standard GPT-5 chat/completions path and resolves upstream snapshots such as `gpt-5.5-2026-04-23`. | | `gpt-5.3-codex` | Supported | Uses the Responses API bridge internally. | | `gpt-5.4-mini` | Supported | Uses the Responses API bridge internally. | | `claude-opus-4.7` | Supported | Snapshot aliases such as `claude-opus-4-7-20260417` resolve to this model, with `low`, `medium`, `high`, `xhigh`, and `max` effort support. | @@ -230,6 +231,7 @@ Reasoning effort is model-specific. | Model | Supported values | Default | | ----- | ---------------- | ------- | | `gpt-5.4` | `low`, `medium`, `high`, `xhigh` | `medium` | +| `gpt-5.5` | `none`, `low`, `medium`, `high`, `xhigh` | `medium` | | `gpt-5.3-codex` | `low`, `medium`, `high`, `xhigh` | `medium` | | `gpt-5.4-mini` | `none`, `low`, `medium` | `medium` | | `claude-opus-4.7` | `low`, `medium`, `high`, `xhigh`, `max` | `medium` | diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 0058e9311..7908c782f 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -38,6 +38,10 @@ const getAllowedReasoningEfforts = ( ): Array< Exclude > => { + if (modelId.startsWith("gpt-5.5")) { + return ["none", "low", "medium", "high", "xhigh"] + } + if (modelId.startsWith("gpt-5.4-mini")) { return ["none", "low", "medium"] } diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index edff6c62b..d10c42440 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -122,6 +122,23 @@ describe("Anthropic model resolution", () => { expect(openAIPayload.model).toBe("gpt-5") }) + test("should resolve GPT-5.5 aliases to the GPT-5 Copilot model", () => { + state.models = { + object: "list", + data: [createModel("gpt-5"), createModel("gpt-5-mini")], + } + + const anthropicPayload: AnthropicMessagesPayload = { + model: "gpt-5.5", + messages: [{ role: "user", content: "Hello!" }], + max_tokens: 0, + } + + const openAIPayload = translateToOpenAI(anthropicPayload) + + expect(openAIPayload.model).toBe("gpt-5") + }) + test("should resolve Claude snapshot aliases to the base Copilot model", () => { state.models = { object: "list", diff --git a/tests/create-chat-completions.test.ts b/tests/create-chat-completions.test.ts index bce700d06..90f1aa30a 100644 --- a/tests/create-chat-completions.test.ts +++ b/tests/create-chat-completions.test.ts @@ -13,6 +13,10 @@ state.copilotToken = "test-token" state.vsCodeVersion = "1.0.0" state.accountType = "individual" +const isolatedHome = path.join(os.tmpdir(), "copilot-api-test-home") +await mkdir(isolatedHome, { recursive: true }) +process.env.HOME = isolatedHome + const createMockResponse = (jsonBody: unknown, ok: boolean = true) => ({ ok, status: ok ? 200 : 400, @@ -41,11 +45,9 @@ const fetchMock = mock( afterEach(() => { fetchMock.mockClear() delete process.env.COPILOT_REASONING_EFFORT - process.env.HOME = originalHome + process.env.HOME = isolatedHome }) -const originalHome = process.env.HOME - test("sets X-Initiator to agent if tool/assistant present", async () => { const payload: ChatCompletionsPayload = { messages: [ @@ -96,6 +98,45 @@ test("uses max_completion_tokens for GPT-5 family models", async () => { expect(body.max_tokens).toBeUndefined() }) +test("uses max_completion_tokens for gpt-5.5 with explicit reasoning effort", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.5", + max_tokens: 384, + reasoning_effort: "high", + } + + await createChatCompletions(payload) + + expect(fetchMock).toHaveBeenCalled() + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_completion_tokens).toBe(384) + expect(body.max_tokens).toBeUndefined() + expect(body.reasoning_effort).toBe("high") +}) + +test("passes through reasoning_effort none for gpt-5.5", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.5", + max_tokens: 128, + reasoning_effort: "none", + } + + await createChatCompletions(payload) + + expect(fetchMock).toHaveBeenCalled() + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_completion_tokens).toBe(128) + expect(body.reasoning_effort).toBe("none") +}) + test("uses max_completion_tokens for gpt-5.3-codex", async () => { fetchMock.mockImplementationOnce( (_url: string, opts: { headers: Record; body?: string }) => @@ -271,6 +312,42 @@ test("keeps max_tokens for non GPT-5 models", async () => { expect(body.max_completion_tokens).toBeUndefined() }) +test("keeps max_tokens and omits reasoning_effort for gemini-3.1-pro-preview", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gemini-3.1-pro-preview", + max_tokens: 256, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_tokens).toBe(256) + expect(body.max_completion_tokens).toBeUndefined() + expect(body.reasoning_effort).toBeUndefined() +}) + +test("keeps max_tokens and omits reasoning_effort for gemini-3-flash-preview", async () => { + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gemini-3-flash-preview", + max_tokens: 256, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_tokens).toBe(256) + expect(body.max_completion_tokens).toBeUndefined() + expect(body.reasoning_effort).toBeUndefined() +}) + test("passes through explicit reasoning_effort for GPT-5 family models", async () => { const payload: ChatCompletionsPayload = { messages: [{ role: "user", content: "hi" }], @@ -559,6 +636,42 @@ test("uses COPILOT_REASONING_EFFORT from Claude settings.json", async () => { } }) +test("uses COPILOT_REASONING_EFFORT from Claude settings.json for gpt-5.5", async () => { + const tempHome = await mkdtemp(path.join(os.tmpdir(), "claude-settings-")) + const claudeDirectory = path.join(tempHome, ".claude") + + try { + await mkdir(claudeDirectory, { recursive: true }) + await writeFile( + path.join(claudeDirectory, "settings.json"), + JSON.stringify({ + env: { + COPILOT_REASONING_EFFORT: "none", + }, + }), + ) + + process.env.HOME = tempHome + + const payload: ChatCompletionsPayload = { + messages: [{ role: "user", content: "hi" }], + model: "gpt-5.5", + max_tokens: 128, + } + + await createChatCompletions(payload) + + const body = JSON.parse( + (fetchMock.mock.calls[0][1] as { body: string }).body, + ) as Record + + expect(body.max_completion_tokens).toBe(128) + expect(body.reasoning_effort).toBe("none") + } finally { + await rm(tempHome, { recursive: true, force: true }) + } +}) + test("uses COPILOT_REASONING_EFFORT from Claude settings.json for claude-opus-4.7", async () => { const tempHome = await mkdtemp(path.join(os.tmpdir(), "claude-settings-")) const claudeDirectory = path.join(tempHome, ".claude") From 853078c7d87ec0691b0e58b62c2810e1682c5da8 Mon Sep 17 00:00:00 2001 From: betaHi Date: Sat, 25 Apr 2026 18:09:21 +0800 Subject: [PATCH 13/19] update version to 0.7.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a5659fcc9..343d05720 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betahi-copilot-api", - "version": "0.7.4", + "version": "0.7.5", "description": "GitHub Copilot proxy with OpenAI/Anthropic compatibility, GPT-5 and Gemini support for Claude Code.", "keywords": [ "proxy", From 30cdfdb31b832fec8d7154221c2d740998b1979a Mon Sep 17 00:00:00 2001 From: betaHi Date: Sat, 25 Apr 2026 18:35:45 +0800 Subject: [PATCH 14/19] update readme and more param --- README.md | 20 +++++++++++--------- src/routes/models/route.ts | 6 ++++++ src/services/copilot/get-models.ts | 1 + 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c451123bb..ec18b9661 100644 --- a/README.md +++ b/README.md @@ -212,18 +212,20 @@ New endpoints for monitoring your Copilot usage and quotas. ## **GPT, Claude Opus 4.7, and Gemini Support** -| Model | Status | Notes | -| ----- | ------ | ----- | -| `gpt-5.4` | Supported | Uses the standard GPT-5 chat/completions path. | -| `gpt-5.5` | Supported | Uses the standard GPT-5 chat/completions path and resolves upstream snapshots such as `gpt-5.5-2026-04-23`. | -| `gpt-5.3-codex` | Supported | Uses the Responses API bridge internally. | -| `gpt-5.4-mini` | Supported | Uses the Responses API bridge internally. | -| `claude-opus-4.7` | Supported | Snapshot aliases such as `claude-opus-4-7-20260417` resolve to this model, with `low`, `medium`, `high`, `xhigh`, and `max` effort support. | -| `gemini-3.1-pro` | Supported | Resolves to Copilot's current `gemini-3.1-pro-preview` model. | -| `gemini-3-flash` | Supported | Resolves to Copilot's current `gemini-3-flash-preview` model. | +| Model | Status | Reported context window | Notes | +| ----- | ------ | ----------------------- | ----- | +| `gpt-5.4` | Supported | `400K` | Uses the standard GPT-5 chat/completions path. | +| `gpt-5.5` | Supported | `400K` | Uses the standard GPT-5 chat/completions path and resolves upstream snapshots such as `gpt-5.5-2026-04-23`. | +| `gpt-5.3-codex` | Supported | `400K` | Uses the Responses API bridge internally. | +| `gpt-5.4-mini` | Supported | `400K` | Uses the Responses API bridge internally. | +| `claude-opus-4.7` | Supported | `200K` | Snapshot aliases such as `claude-opus-4-7-20260417` resolve to this model, with `low`, `medium`, `high`, `xhigh`, and `max` effort support. | +| `gemini-3.1-pro` | Supported | `200K` | Resolves to Copilot's current `gemini-3.1-pro-preview` model. | +| `gemini-3-flash` | Supported | `128K` | Resolves to Copilot's current `gemini-3-flash-preview` model. | Gemini preview IDs can also be used directly, for example `gemini-3.1-pro-preview` and `gemini-3-flash-preview`. +Context window values above are the current limits reported by `/v1/models` on this proxy. Treat them as model metadata, not as a guarantee that every client UI will display the same value or that the full window is always usable as prompt tokens in practice. + ### Reasoning Effort Matrix Reasoning effort is model-specific. diff --git a/src/routes/models/route.ts b/src/routes/models/route.ts index 5254e2af7..2eb927653 100644 --- a/src/routes/models/route.ts +++ b/src/routes/models/route.ts @@ -21,6 +21,12 @@ modelRoutes.get("/", async (c) => { created_at: new Date(0).toISOString(), // No date available from source owned_by: model.vendor, display_name: model.name, + capabilities: model.capabilities, + supported_endpoints: model.supported_endpoints, + model_picker_enabled: model.model_picker_enabled, + preview: model.preview, + policy: model.policy, + version: model.version, })) return c.json({ diff --git a/src/services/copilot/get-models.ts b/src/services/copilot/get-models.ts index 3cfa30af0..efd61f276 100644 --- a/src/services/copilot/get-models.ts +++ b/src/services/copilot/get-models.ts @@ -46,6 +46,7 @@ export interface Model { name: string object: string preview: boolean + supported_endpoints?: Array vendor: string version: string policy?: { From 851d26ab6e19da6dca14c2f0e5296a0d9a79c996 Mon Sep 17 00:00:00 2001 From: betaHi Date: Sat, 25 Apr 2026 18:40:55 +0800 Subject: [PATCH 15/19] Revert model metadata response changes --- src/routes/models/route.ts | 6 ------ src/services/copilot/get-models.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/src/routes/models/route.ts b/src/routes/models/route.ts index 2eb927653..5254e2af7 100644 --- a/src/routes/models/route.ts +++ b/src/routes/models/route.ts @@ -21,12 +21,6 @@ modelRoutes.get("/", async (c) => { created_at: new Date(0).toISOString(), // No date available from source owned_by: model.vendor, display_name: model.name, - capabilities: model.capabilities, - supported_endpoints: model.supported_endpoints, - model_picker_enabled: model.model_picker_enabled, - preview: model.preview, - policy: model.policy, - version: model.version, })) return c.json({ diff --git a/src/services/copilot/get-models.ts b/src/services/copilot/get-models.ts index efd61f276..3cfa30af0 100644 --- a/src/services/copilot/get-models.ts +++ b/src/services/copilot/get-models.ts @@ -46,7 +46,6 @@ export interface Model { name: string object: string preview: boolean - supported_endpoints?: Array vendor: string version: string policy?: { From 85898168e04eded800b62202ebbc6b3391ce6613 Mon Sep 17 00:00:00 2001 From: betaHi Date: Mon, 27 Apr 2026 10:43:13 +0800 Subject: [PATCH 16/19] update readme --- .../cd7414bc-8f07-4f80-8a69-3aa17c161add.txt | 1 + README.md | 419 +----------------- README_DEPRECATED.md | 414 +++++++++++++++++ 3 files changed, 437 insertions(+), 397 deletions(-) create mode 100644 .agentguard/reports/cd7414bc-8f07-4f80-8a69-3aa17c161add.txt create mode 100644 README_DEPRECATED.md diff --git a/.agentguard/reports/cd7414bc-8f07-4f80-8a69-3aa17c161add.txt b/.agentguard/reports/cd7414bc-8f07-4f80-8a69-3aa17c161add.txt new file mode 100644 index 000000000..fe9b63531 --- /dev/null +++ b/.agentguard/reports/cd7414bc-8f07-4f80-8a69-3aa17c161add.txt @@ -0,0 +1 @@ +Error: No Claude session messages found for session 'cd7414bc-8f07-4f80-8a69-3aa17c161add'. Retry with --directory if the SDK cannot find this session in its default lookup path diff --git a/README.md b/README.md index ec18b9661..36249d185 100644 --- a/README.md +++ b/README.md @@ -1,411 +1,36 @@ -# Copilot API Proxy +# copilot-api (DEPRECATED — use copilot-bridge) -> [!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. +> [!IMPORTANT] +> **This repository is no longer maintained.** +> Active development has moved to [**`copilot-bridge`**](https://github.com/betaHi/copilot-bridge), +> which supports both **Codex CLI** and **Claude Code** out of the box. -> [!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. -> 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. -> -> Please review: -> -> - [GitHub Acceptable Use Policies](https://docs.github.com/site-policy/acceptable-use-policies/github-acceptable-use-policies#4-spam-and-inauthentic-activity-on-github) -> - [GitHub Copilot Terms](https://docs.github.com/site-policy/github-terms/github-terms-for-additional-products-and-features#github-copilot) -> -> Use this proxy responsibly to avoid account restrictions. +## What's new in copilot-bridge -[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/E1E519XS7W) +- **Codex CLI** support (auto-writes `~/.codex/config.toml`) +- **Claude Code** support (no flags needed) +- [Usage Viewer](https://betahi.github.io/copilot-api?endpoint=http://127.0.0.1:4242/usage), CORS, rate limiting +- End-to-end tested with the real Codex / Claude CLIs ---- +## Migration -**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. - ---- - -## Project Overview - -A reverse-engineered proxy for the GitHub Copilot API that exposes it as an OpenAI and Anthropic compatible service. This allows you to use GitHub Copilot with any tool that supports the OpenAI Chat Completions API or the Anthropic Messages API, including to power [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview). - -## Features - -- **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API. -- **Claude Code Integration**: Easily configure and launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) to use Copilot as its backend with a simple command-line flag (`--claude-code`). -- **GPT-5 Reasoning Effort Support**: Supports GPT-5 family reasoning controls through `reasoning_effort` and the `COPILOT_REASONING_EFFORT` environment variable (`low`, `medium`, `high`, `max`; `xhigh` is accepted as an alias for `max`). -- **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. -- **Manual Request Approval**: Manually approve or deny each API request for fine-grained control over usage (`--manual`). -- **Token Visibility**: Option to display GitHub and Copilot tokens during authentication and refresh for debugging (`--show-token`). -- **Flexible Authentication**: Authenticate interactively or provide a GitHub token directly, suitable for CI/CD environments. -- **Support for Different Account Types**: Works with individual, business, and enterprise GitHub Copilot plans. - -## Demo - -https://github.com/user-attachments/assets/7654b383-669d-4eb9-b23c-06d7aefee8c5 - -## Prerequisites - -- Bun (>= 1.2.x) -- GitHub account with Copilot subscription (individual, business, or enterprise) - -## Installation - -To install dependencies, run: - -```sh -bun install -``` - -## Using with Docker - -Build image - -```sh -docker build -t copilot-api . -``` - -Run the container - -```sh -# Create a directory on your host to persist the GitHub token and related data -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 -``` - -> **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. - -### Docker with Environment Variables - -You can pass the GitHub token directly to the container using environment variables: - -```sh -# Build with GitHub token -docker build --build-arg GH_TOKEN=your_github_token_here -t copilot-api . - -# Run with GitHub token -docker run -p 4141:4141 -e GH_TOKEN=your_github_token_here copilot-api - -# Run with additional options -docker run -p 4141:4141 -e GH_TOKEN=your_token copilot-api start --verbose --port 4141 -``` - -### Docker Compose Example - -```yaml -version: "3.8" -services: - copilot-api: - build: . - ports: - - "4141:4141" - environment: - - GH_TOKEN=your_github_token_here - restart: unless-stopped -``` - -The Docker image includes: - -- Multi-stage build for optimized image size -- Non-root user for enhanced security -- Health check for container monitoring -- Pinned base image version for reproducible builds - -## Using with npx - -You can run the project directly using npx: +Replace any old commands: ```sh +# old npx betahi-copilot-api@latest start -``` -With options: - -```sh -npx betahi-copilot-api@latest start --port 8080 +# new +npx betahi-copilot-bridge@latest start ``` -For authentication only: - -```sh -npx betahi-copilot-api@latest auth -``` - -## Command Structure - -Copilot API now uses a subcommand structure with these main commands: - -- `start`: Start the Copilot API server. This command will also handle authentication if needed. -- `auth`: Run GitHub authentication flow without starting the server. This is typically used if you need to generate a token for use with the `--github-token` option, especially in non-interactive environments. -- `check-usage`: Show your current GitHub Copilot usage and quota information directly in the terminal (no server required). -- `debug`: Display diagnostic information including version, runtime details, file paths, and authentication status. Useful for troubleshooting and support. - -## Command Line Options - -### Start Command Options - -The following command line options are available for the `start` command: - -| Option | Description | Default | Alias | -| -------------- | ----------------------------------------------------------------------------- | ---------- | ----- | -| --port | Port to listen on | 4141 | -p | -| --verbose | Enable verbose logging | false | -v | -| --account-type | Account type to use (individual, business, enterprise) | individual | -a | -| --manual | Enable manual request approval | false | none | -| --rate-limit | Rate limit in seconds between requests | none | -r | -| --wait | Wait instead of error when rate limit is hit | false | -w | -| --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g | -| --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 | - -### Auth Command Options - -| Option | Description | Default | Alias | -| ------------ | ------------------------- | ------- | ----- | -| --verbose | Enable verbose logging | false | -v | -| --show-token | Show GitHub token on auth | false | none | - -### Debug Command Options - -| Option | Description | Default | Alias | -| ------ | ------------------------- | ------- | ----- | -| --json | Output debug info as JSON | false | none | - -## API Endpoints - -The server exposes several endpoints to interact with the Copilot API. It provides OpenAI-compatible endpoints and now also includes support for Anthropic-compatible endpoints, allowing for greater flexibility with different tools and services. - -### OpenAI Compatible Endpoints - -These endpoints mimic the OpenAI API structure. - -| Endpoint | Method | Description | -| --------------------------- | ------ | --------------------------------------------------------- | -| `POST /v1/chat/completions` | `POST` | Creates a model response for the given chat conversation. | -| `GET /v1/models` | `GET` | Lists the currently available models. | -| `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. | - -### Anthropic Compatible Endpoints - -These endpoints are designed to be compatible with the Anthropic Messages API. - -| Endpoint | Method | Description | -| -------------------------------- | ------ | ------------------------------------------------------------ | -| `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. | - -### Usage Monitoring Endpoints - -New endpoints for monitoring your Copilot usage and quotas. +For Claude Code, set `ANTHROPIC_BASE_URL` to the bridge port (default `4142`) +in `~/.claude/settings.json`. For Codex CLI, just run `start` once — the bridge +writes a managed block into `~/.codex/config.toml` for you. -| Endpoint | Method | Description | -| ------------ | ------ | ------------------------------------------------------------ | -| `GET /usage` | `GET` | Get detailed Copilot usage statistics and quota information. | -| `GET /token` | `GET` | Get the current Copilot token being used by the API. | - -## **GPT, Claude Opus 4.7, and Gemini Support** - -| Model | Status | Reported context window | Notes | -| ----- | ------ | ----------------------- | ----- | -| `gpt-5.4` | Supported | `400K` | Uses the standard GPT-5 chat/completions path. | -| `gpt-5.5` | Supported | `400K` | Uses the standard GPT-5 chat/completions path and resolves upstream snapshots such as `gpt-5.5-2026-04-23`. | -| `gpt-5.3-codex` | Supported | `400K` | Uses the Responses API bridge internally. | -| `gpt-5.4-mini` | Supported | `400K` | Uses the Responses API bridge internally. | -| `claude-opus-4.7` | Supported | `200K` | Snapshot aliases such as `claude-opus-4-7-20260417` resolve to this model, with `low`, `medium`, `high`, `xhigh`, and `max` effort support. | -| `gemini-3.1-pro` | Supported | `200K` | Resolves to Copilot's current `gemini-3.1-pro-preview` model. | -| `gemini-3-flash` | Supported | `128K` | Resolves to Copilot's current `gemini-3-flash-preview` model. | - -Gemini preview IDs can also be used directly, for example `gemini-3.1-pro-preview` and `gemini-3-flash-preview`. - -Context window values above are the current limits reported by `/v1/models` on this proxy. Treat them as model metadata, not as a guarantee that every client UI will display the same value or that the full window is always usable as prompt tokens in practice. - -### Reasoning Effort Matrix - -Reasoning effort is model-specific. - -| Model | Supported values | Default | -| ----- | ---------------- | ------- | -| `gpt-5.4` | `low`, `medium`, `high`, `xhigh` | `medium` | -| `gpt-5.5` | `none`, `low`, `medium`, `high`, `xhigh` | `medium` | -| `gpt-5.3-codex` | `low`, `medium`, `high`, `xhigh` | `medium` | -| `gpt-5.4-mini` | `none`, `low`, `medium` | `medium` | -| `claude-opus-4.7` | `low`, `medium`, `high`, `xhigh`, `max` | `medium` | - -If an unsupported effort value is sent for one of these models, the proxy falls back to that model's default behavior instead of forwarding an invalid upstream request. - -### `settings.json` - -Your Claude Code `settings.json` can configure the proxy endpoint and default model selection, for example in `~/.claude/settings.json`. - -Example: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "http://localhost:4141", - "ANTHROPIC_AUTH_TOKEN": "dummy", - "ANTHROPIC_MODEL": "gpt-5.3-codex", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex", - "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini", - "COPILOT_REASONING_EFFORT": "medium", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", - "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1" - }, - "model": "gpt-5.3-codex" -} -``` - -Notes: - -1. `COPILOT_REASONING_EFFORT` is currently a global default, not a per-model setting. -2. Claude Code's top-level `model` field is interpreted by Claude Code itself, not by this proxy. -3. For one-off validation, `claude --model ` is the most reliable way to force the active session model. - -## Example Usage - -Using with npx: - -```sh -# Basic usage with start command -npx betahi-copilot-api@latest start - -# Run on custom port with verbose logging -npx betahi-copilot-api@latest start --port 8080 --verbose - -# Use with a business plan GitHub account -npx betahi-copilot-api@latest start --account-type business - -# Use with an enterprise plan GitHub account -npx betahi-copilot-api@latest start --account-type enterprise - -# Enable manual approval for each request -npx betahi-copilot-api@latest start --manual - -# Set rate limit to 30 seconds between requests -npx betahi-copilot-api@latest start --rate-limit 30 - -# Wait instead of error when rate limit is hit -npx betahi-copilot-api@latest start --rate-limit 30 --wait - -# Provide GitHub token directly -npx betahi-copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE - -# Run only the auth flow -npx betahi-copilot-api@latest auth - -# Run auth flow with verbose logging -npx betahi-copilot-api@latest auth --verbose - -# Show your Copilot usage/quota in the terminal (no server needed) -npx betahi-copilot-api@latest check-usage - -# Display debug information for troubleshooting -npx betahi-copilot-api@latest debug - -# Display debug information in JSON format -npx betahi-copilot-api@latest debug --json - -# Initialize proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, etc.) -npx betahi-copilot-api@latest start --proxy-env -``` - -## Using the Usage Viewer - -After starting the server, a URL to the Copilot Usage Dashboard will be displayed in your console. This dashboard is a web interface for monitoring your API usage. - -1. Start the server. For example, using npx: - ```sh - npx betahi-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://betahi.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: - -- **API Endpoint URL**: The dashboard is pre-configured to fetch data from your local server endpoint via the URL query parameter. You can change this URL to point to any other compatible API endpoint. -- **Fetch Data**: Click the "Fetch" button to load or refresh the usage data. The dashboard will automatically fetch data on load. -- **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://betahi.github.io/copilot-api?endpoint=http://your-api-server/usage` - -## Using with Claude Code - -This proxy can be used to power [Claude Code](https://docs.anthropic.com/en/claude-code), an experimental conversational AI assistant for developers from Anthropic. - -There are two ways to configure Claude Code to use this proxy: - -### Interactive Setup with `--claude-code` flag - -To get started, run the `start` command with the `--claude-code` flag: - -```sh -npx betahi-copilot-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. - -Paste and run this command in a new terminal to launch Claude Code. - -### Manual Configuration with `settings.json` - -Alternatively, you can configure Claude Code by creating a `.claude/settings.json` file in your project's root directory. This file should contain the environment variables needed by Claude Code. This way you don't need to run the interactive setup every time. - -Here is an example `.claude/settings.json` file: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "http://localhost:4141", - "ANTHROPIC_AUTH_TOKEN": "dummy", - "ANTHROPIC_MODEL": "gpt-5.4", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", - "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4", - "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" - }, - "permissions": { - "deny": [ - "WebSearch" - ] - } -} -``` - -You can find more options here: [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables) - -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) - -## Running from Source - -The project can be run from source in several ways: - -### Development Mode - -```sh -bun run dev -``` - -### Production Mode - -```sh -bun run start -``` +See the new README: -## Usage Tips +## Archive -- 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. - - `--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. +The original README has been preserved as +[`README_DEPRECATED.md`](./README_DEPRECATED.md) for reference. diff --git a/README_DEPRECATED.md b/README_DEPRECATED.md new file mode 100644 index 000000000..1ef4eedf7 --- /dev/null +++ b/README_DEPRECATED.md @@ -0,0 +1,414 @@ +# Copilot API Proxy + +> [!TIP] +> **Looking for Codex CLI support?** See the sister project [copilot-bridge](https://github.com/betaHi/copilot-bridge). Claude Code is also supported there. + +> [!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. +> 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. +> +> Please review: +> +> - [GitHub Acceptable Use Policies](https://docs.github.com/site-policy/acceptable-use-policies/github-acceptable-use-policies#4-spam-and-inauthentic-activity-on-github) +> - [GitHub Copilot Terms](https://docs.github.com/site-policy/github-terms/github-terms-for-additional-products-and-features#github-copilot) +> +> 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. + +--- + +## Project Overview + +A reverse-engineered proxy for the GitHub Copilot API that exposes it as an OpenAI and Anthropic compatible service. This allows you to use GitHub Copilot with any tool that supports the OpenAI Chat Completions API or the Anthropic Messages API, including to power [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview). + +## Features + +- **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API. +- **Claude Code Integration**: Easily configure and launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) to use Copilot as its backend with a simple command-line flag (`--claude-code`). +- **GPT-5 Reasoning Effort Support**: Supports GPT-5 family reasoning controls through `reasoning_effort` and the `COPILOT_REASONING_EFFORT` environment variable (`low`, `medium`, `high`, `max`; `xhigh` is accepted as an alias for `max`). +- **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. +- **Manual Request Approval**: Manually approve or deny each API request for fine-grained control over usage (`--manual`). +- **Token Visibility**: Option to display GitHub and Copilot tokens during authentication and refresh for debugging (`--show-token`). +- **Flexible Authentication**: Authenticate interactively or provide a GitHub token directly, suitable for CI/CD environments. +- **Support for Different Account Types**: Works with individual, business, and enterprise GitHub Copilot plans. + +## Demo + +https://github.com/user-attachments/assets/7654b383-669d-4eb9-b23c-06d7aefee8c5 + +## Prerequisites + +- Bun (>= 1.2.x) +- GitHub account with Copilot subscription (individual, business, or enterprise) + +## Installation + +To install dependencies, run: + +```sh +bun install +``` + +## Using with Docker + +Build image + +```sh +docker build -t copilot-api . +``` + +Run the container + +```sh +# Create a directory on your host to persist the GitHub token and related data +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 +``` + +> **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. + +### Docker with Environment Variables + +You can pass the GitHub token directly to the container using environment variables: + +```sh +# Build with GitHub token +docker build --build-arg GH_TOKEN=your_github_token_here -t copilot-api . + +# Run with GitHub token +docker run -p 4141:4141 -e GH_TOKEN=your_github_token_here copilot-api + +# Run with additional options +docker run -p 4141:4141 -e GH_TOKEN=your_token copilot-api start --verbose --port 4141 +``` + +### Docker Compose Example + +```yaml +version: "3.8" +services: + copilot-api: + build: . + ports: + - "4141:4141" + environment: + - GH_TOKEN=your_github_token_here + restart: unless-stopped +``` + +The Docker image includes: + +- Multi-stage build for optimized image size +- Non-root user for enhanced security +- Health check for container monitoring +- Pinned base image version for reproducible builds + +## Using with npx + +You can run the project directly using npx: + +```sh +npx betahi-copilot-api@latest start +``` + +With options: + +```sh +npx betahi-copilot-api@latest start --port 8080 +``` + +For authentication only: + +```sh +npx betahi-copilot-api@latest auth +``` + +## Command Structure + +Copilot API now uses a subcommand structure with these main commands: + +- `start`: Start the Copilot API server. This command will also handle authentication if needed. +- `auth`: Run GitHub authentication flow without starting the server. This is typically used if you need to generate a token for use with the `--github-token` option, especially in non-interactive environments. +- `check-usage`: Show your current GitHub Copilot usage and quota information directly in the terminal (no server required). +- `debug`: Display diagnostic information including version, runtime details, file paths, and authentication status. Useful for troubleshooting and support. + +## Command Line Options + +### Start Command Options + +The following command line options are available for the `start` command: + +| Option | Description | Default | Alias | +| -------------- | ----------------------------------------------------------------------------- | ---------- | ----- | +| --port | Port to listen on | 4141 | -p | +| --verbose | Enable verbose logging | false | -v | +| --account-type | Account type to use (individual, business, enterprise) | individual | -a | +| --manual | Enable manual request approval | false | none | +| --rate-limit | Rate limit in seconds between requests | none | -r | +| --wait | Wait instead of error when rate limit is hit | false | -w | +| --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g | +| --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 | + +### Auth Command Options + +| Option | Description | Default | Alias | +| ------------ | ------------------------- | ------- | ----- | +| --verbose | Enable verbose logging | false | -v | +| --show-token | Show GitHub token on auth | false | none | + +### Debug Command Options + +| Option | Description | Default | Alias | +| ------ | ------------------------- | ------- | ----- | +| --json | Output debug info as JSON | false | none | + +## API Endpoints + +The server exposes several endpoints to interact with the Copilot API. It provides OpenAI-compatible endpoints and now also includes support for Anthropic-compatible endpoints, allowing for greater flexibility with different tools and services. + +### OpenAI Compatible Endpoints + +These endpoints mimic the OpenAI API structure. + +| Endpoint | Method | Description | +| --------------------------- | ------ | --------------------------------------------------------- | +| `POST /v1/chat/completions` | `POST` | Creates a model response for the given chat conversation. | +| `GET /v1/models` | `GET` | Lists the currently available models. | +| `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. | + +### Anthropic Compatible Endpoints + +These endpoints are designed to be compatible with the Anthropic Messages API. + +| Endpoint | Method | Description | +| -------------------------------- | ------ | ------------------------------------------------------------ | +| `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. | + +### Usage Monitoring Endpoints + +New endpoints for monitoring your Copilot usage and quotas. + +| Endpoint | Method | Description | +| ------------ | ------ | ------------------------------------------------------------ | +| `GET /usage` | `GET` | Get detailed Copilot usage statistics and quota information. | +| `GET /token` | `GET` | Get the current Copilot token being used by the API. | + +## **GPT, Claude Opus 4.7, and Gemini Support** + +| Model | Status | Reported context window | Notes | +| ----- | ------ | ----------------------- | ----- | +| `gpt-5.4` | Supported | `400K` | Uses the standard GPT-5 chat/completions path. | +| `gpt-5.5` | Supported | `400K` | Uses the standard GPT-5 chat/completions path and resolves upstream snapshots such as `gpt-5.5-2026-04-23`. | +| `gpt-5.3-codex` | Supported | `400K` | Uses the Responses API bridge internally. | +| `gpt-5.4-mini` | Supported | `400K` | Uses the Responses API bridge internally. | +| `claude-opus-4.7` | Supported | `200K` | Snapshot aliases such as `claude-opus-4-7-20260417` resolve to this model, with `low`, `medium`, `high`, `xhigh`, and `max` effort support. | +| `gemini-3.1-pro` | Supported | `200K` | Resolves to Copilot's current `gemini-3.1-pro-preview` model. | +| `gemini-3-flash` | Supported | `128K` | Resolves to Copilot's current `gemini-3-flash-preview` model. | + +Gemini preview IDs can also be used directly, for example `gemini-3.1-pro-preview` and `gemini-3-flash-preview`. + +Context window values above are the current limits reported by `/v1/models` on this proxy. Treat them as model metadata, not as a guarantee that every client UI will display the same value or that the full window is always usable as prompt tokens in practice. + +### Reasoning Effort Matrix + +Reasoning effort is model-specific. + +| Model | Supported values | Default | +| ----- | ---------------- | ------- | +| `gpt-5.4` | `low`, `medium`, `high`, `xhigh` | `medium` | +| `gpt-5.5` | `none`, `low`, `medium`, `high`, `xhigh` | `medium` | +| `gpt-5.3-codex` | `low`, `medium`, `high`, `xhigh` | `medium` | +| `gpt-5.4-mini` | `none`, `low`, `medium` | `medium` | +| `claude-opus-4.7` | `low`, `medium`, `high`, `xhigh`, `max` | `medium` | + +If an unsupported effort value is sent for one of these models, the proxy falls back to that model's default behavior instead of forwarding an invalid upstream request. + +### `settings.json` + +Your Claude Code `settings.json` can configure the proxy endpoint and default model selection, for example in `~/.claude/settings.json`. + +Example: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_AUTH_TOKEN": "dummy", + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4-mini", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini", + "COPILOT_REASONING_EFFORT": "medium", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", + "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1" + }, + "model": "gpt-5.3-codex" +} +``` + +Notes: + +1. `COPILOT_REASONING_EFFORT` is currently a global default, not a per-model setting. +2. Claude Code's top-level `model` field is interpreted by Claude Code itself, not by this proxy. +3. For one-off validation, `claude --model ` is the most reliable way to force the active session model. + +## Example Usage + +Using with npx: + +```sh +# Basic usage with start command +npx betahi-copilot-api@latest start + +# Run on custom port with verbose logging +npx betahi-copilot-api@latest start --port 8080 --verbose + +# Use with a business plan GitHub account +npx betahi-copilot-api@latest start --account-type business + +# Use with an enterprise plan GitHub account +npx betahi-copilot-api@latest start --account-type enterprise + +# Enable manual approval for each request +npx betahi-copilot-api@latest start --manual + +# Set rate limit to 30 seconds between requests +npx betahi-copilot-api@latest start --rate-limit 30 + +# Wait instead of error when rate limit is hit +npx betahi-copilot-api@latest start --rate-limit 30 --wait + +# Provide GitHub token directly +npx betahi-copilot-api@latest start --github-token ghp_YOUR_TOKEN_HERE + +# Run only the auth flow +npx betahi-copilot-api@latest auth + +# Run auth flow with verbose logging +npx betahi-copilot-api@latest auth --verbose + +# Show your Copilot usage/quota in the terminal (no server needed) +npx betahi-copilot-api@latest check-usage + +# Display debug information for troubleshooting +npx betahi-copilot-api@latest debug + +# Display debug information in JSON format +npx betahi-copilot-api@latest debug --json + +# Initialize proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, etc.) +npx betahi-copilot-api@latest start --proxy-env +``` + +## Using the Usage Viewer + +After starting the server, a URL to the Copilot Usage Dashboard will be displayed in your console. This dashboard is a web interface for monitoring your API usage. + +1. Start the server. For example, using npx: + ```sh + npx betahi-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://betahi.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: + +- **API Endpoint URL**: The dashboard is pre-configured to fetch data from your local server endpoint via the URL query parameter. You can change this URL to point to any other compatible API endpoint. +- **Fetch Data**: Click the "Fetch" button to load or refresh the usage data. The dashboard will automatically fetch data on load. +- **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://betahi.github.io/copilot-api?endpoint=http://your-api-server/usage` + +## Using with Claude Code + +This proxy can be used to power [Claude Code](https://docs.anthropic.com/en/claude-code), an experimental conversational AI assistant for developers from Anthropic. + +There are two ways to configure Claude Code to use this proxy: + +### Interactive Setup with `--claude-code` flag + +To get started, run the `start` command with the `--claude-code` flag: + +```sh +npx betahi-copilot-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. + +Paste and run this command in a new terminal to launch Claude Code. + +### Manual Configuration with `settings.json` + +Alternatively, you can configure Claude Code by creating a `.claude/settings.json` file in your project's root directory. This file should contain the environment variables needed by Claude Code. This way you don't need to run the interactive setup every time. + +Here is an example `.claude/settings.json` file: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_AUTH_TOKEN": "dummy", + "ANTHROPIC_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4", + "DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + }, + "permissions": { + "deny": [ + "WebSearch" + ] + } +} +``` + +You can find more options here: [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables) + +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) + +## Running from Source + +The project can be run from source in several ways: + +### Development Mode + +```sh +bun run dev +``` + +### Production Mode + +```sh +bun run start +``` + +## Usage Tips + +- 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. + - `--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 86417a60994a049521e05d370579a840d3e84281 Mon Sep 17 00:00:00 2001 From: betaHi <97333455+betaHi@users.noreply.github.com> Date: Fri, 8 May 2026 10:59:53 +0800 Subject: [PATCH 17/19] update readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 36249d185..6025c1768 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ - **Codex CLI** support (auto-writes `~/.codex/config.toml`) - **Claude Code** support (no flags needed) -- [Usage Viewer](https://betahi.github.io/copilot-api?endpoint=http://127.0.0.1:4242/usage), CORS, rate limiting +- **Web Search** support +- **End-to-end reasoning** support - End-to-end tested with the real Codex / Claude CLIs ## Migration From 9824ba160753bc494ea8877536741ea793796821 Mon Sep 17 00:00:00 2001 From: betaHi <97333455+betaHi@users.noreply.github.com> Date: Sat, 30 May 2026 12:04:16 +0800 Subject: [PATCH 18/19] update readme --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6025c1768..2d229c70f 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ > [!IMPORTANT] > **This repository is no longer maintained.** > Active development has moved to [**`copilot-bridge`**](https://github.com/betaHi/copilot-bridge), -> which supports both **Codex CLI** and **Claude Code** out of the box. +> which supports both **Codex CLI** , **Codex App** and **Claude Code** out of the box. ## What's new in copilot-bridge - **Codex CLI** support (auto-writes `~/.codex/config.toml`) +- **Codex App** support (same with codex cli) - **Claude Code** support (no flags needed) - **Web Search** support - **End-to-end reasoning** support @@ -26,7 +27,7 @@ npx betahi-copilot-bridge@latest start ``` For Claude Code, set `ANTHROPIC_BASE_URL` to the bridge port (default `4142`) -in `~/.claude/settings.json`. For Codex CLI, just run `start` once — the bridge +in `~/.claude/settings.json`. For Codex CLI or Codex App, just run `start` once — the bridge writes a managed block into `~/.codex/config.toml` for you. See the new README: From 8780acabf34e8f6f571b6fe04afd62654af43ea5 Mon Sep 17 00:00:00 2001 From: betaHi <97333455+betaHi@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:23:15 +0800 Subject: [PATCH 19/19] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2d229c70f..453748f95 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - **Claude Code** support (no flags needed) - **Web Search** support - **End-to-end reasoning** support +- **Tool call compatibility.** Adapts MCP tool names and tool call payloads to each upstream model's constraints - End-to-end tested with the real Codex / Claude CLIs ## Migration