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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,19 @@ Copilot API now uses a subcommand structure with these main commands:

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 |
| 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 |
| --no-native-messages | Translate Claude models via /chat/completions instead of native /v1/messages | false | none |

### Auth Command Options

Expand Down
2 changes: 2 additions & 0 deletions src/lib/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface State {
manualApprove: boolean
rateLimitWait: boolean
showToken: boolean
nativeMessages: boolean

// Rate limiting configuration
rateLimitSeconds?: number
Expand All @@ -22,4 +23,5 @@ export const state: State = {
manualApprove: false,
rateLimitWait: false,
showToken: false,
nativeMessages: true,
}
7 changes: 6 additions & 1 deletion src/routes/messages/anthropic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ export interface AnthropicMessagesPayload {
name?: string
}
thinking?: {
type: "enabled"
// "enabled", "adaptive", "disabled", "between_tools", depending on model
type: string
budget_tokens?: number
display?: string
}
output_config?: {
effort?: string
}
service_tier?: "auto" | "standard_only"
}
Expand Down
16 changes: 12 additions & 4 deletions src/routes/messages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
type AnthropicMessagesPayload,
type AnthropicStreamState,
} from "./anthropic-types"
import {
handleNativeCompletion,
shouldUseNativeMessages,
} from "./native-handler"
import {
translateToAnthropic,
translateToOpenAI,
Expand All @@ -28,16 +32,20 @@ export async function handleCompletion(c: Context) {
const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))

if (state.manualApprove) {
await awaitApproval()
}

if (shouldUseNativeMessages(anthropicPayload)) {
return handleNativeCompletion(anthropicPayload, c.req.raw.headers)
}
Comment on lines +39 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Quick win

Supported by static analysis

Script executed:

#!/bin/bash
# Description: Check whether model-name normalization exists and is bypassed by the native path.
rg -n -C6 'translateModelName' src
echo "--- native path model usage ---"
rg -n -C2 'payload\.model|model:' src/routes/messages/native-handler.ts src/services/copilot/create-native-messages.ts

Repository: ericc-ch/copilot-api

Length of output: 4071


Script executed:

#!/bin/bash
printf '%s\n' '--- native handler ---'
nl -ba src/routes/messages/native-handler.ts
printf '%s\n' '--- native request service ---'
nl -ba src/services/copilot/create-native-messages.ts
printf '%s\n' '--- translation and completion caller ---'
nl -ba src/routes/messages/non-stream-translation.ts
nl -ba src/routes/messages/handler.ts

Repository: ericc-ch/copilot-api

Length of output: 22924


Script executed:

nl -ba src/routes/messages/native-handler.ts
nl -ba src/services/copilot/create-native-messages.ts
nl -ba src/routes/messages/non-stream-translation.ts
nl -ba src/routes/messages/handler.ts

Repository: ericc-ch/copilot-api

Length of output: 22828


Script executed:

set -o pipefail
for f in src/routes/messages/native-handler.ts src/services/copilot/create-native-messages.ts; do
  printf '%s\n' "--- $f ---"
  nl -ba "$f"
done

Repository: ericc-ch/copilot-api

Length of output: 7397


Normalize model IDs before native forwarding.

The translation path maps dated Claude IDs to Copilot-supported IDs. The native path passes payload.model unchanged, so dated model IDs can be rejected upstream.

Suggested fix
--- a/src/routes/messages/non-stream-translation.ts
+++ b/src/routes/messages/non-stream-translation.ts
@@
-function translateModelName(model: string): string {
+export function translateModelName(model: string): string {
--- a/src/routes/messages/native-handler.ts
+++ b/src/routes/messages/native-handler.ts
@@
 import {
   createNativeMessages,
   type NativeMessagesPayload,
 } from "~/services/copilot/create-native-messages"
 
+import { translateModelName } from "./non-stream-translation"
+
@@
     const upstream = await createNativeMessages(
-      payload,
+      { ...payload, model: translateModelName(payload.model) },
       pickHeaders(clientHeaders, FORWARDED_REQUEST_HEADERS),
     )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. I tested this suggestion against Copilot Business on 2026-10-27. The native path keeps the model ID without changes for these reasons:

  1. Copilot /v1/messages accepts dashed and dated IDs of the models that it offers. These requests returned HTTP 200: claude-opus-4-8, claude-opus-4-8-20260115, claude-sonnet-5-20260601 and claude-haiku-4-5-20251001.
  2. translateModelName changes claude-opus-4-8 to claude-opus-4. Copilot returns HTTP 400 for claude-opus-4 with the message "The requested model is not supported.". Thus the suggested change makes requests fail that work now.
  3. Copilot does not offer some models, for example claude-sonnet-4. Requests for these models fail on both paths. This change does not make them worse.

The OpenGrep findings in src/services/copilot/thinking-type.ts are calls to RegExp.prototype.exec with literal patterns. These calls do not run shell commands.


const openAIPayload = translateToOpenAI(anthropicPayload)
consola.debug(
"Translated OpenAI request payload:",
JSON.stringify(openAIPayload),
)

if (state.manualApprove) {
await awaitApproval()
}

const response = await createChatCompletions(openAIPayload)

if (isNonStreaming(response)) {
Expand Down
64 changes: 64 additions & 0 deletions src/routes/messages/native-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { HTTPError } from "~/lib/error"
import { state } from "~/lib/state"
import {
createNativeMessages,
type NativeMessagesPayload,
} from "~/services/copilot/create-native-messages"

const FORWARDED_REQUEST_HEADERS = ["anthropic-version", "anthropic-beta"]
// retry-after and x-should-retry are the retry signals that Anthropic SDKs read.
const FORWARDED_RESPONSE_HEADERS = [
"content-type",
"cache-control",
"retry-after",
"x-should-retry",
]

// Copilot's /v1/messages rejects a trailing assistant message (prefill) for
// current Claude models, so these requests keep the translation path.
export function shouldUseNativeMessages(
payload: NativeMessagesPayload,
): boolean {
return (
state.nativeMessages
&& payload.model.startsWith("claude")
&& payload.messages.at(-1)?.role !== "assistant"
)
}

function pickHeaders(
headers: Headers,
names: Array<string>,
): Record<string, string> {
const picked: Record<string, string> = {}
for (const name of names) {
const value = headers.get(name)
if (value) picked[name] = value
}
return picked
}

function toClientResponse(upstream: Response): Response {
return new Response(upstream.body, {
status: upstream.status,
headers: pickHeaders(upstream.headers, FORWARDED_RESPONSE_HEADERS),
})
}

export async function handleNativeCompletion(
payload: NativeMessagesPayload,
clientHeaders: Headers,
): Promise<Response> {
try {
const upstream = await createNativeMessages(
payload,
pickHeaders(clientHeaders, FORWARDED_REQUEST_HEADERS),
)
return toClientResponse(upstream)
} catch (error) {
// Upstream errors keep their status, body and retry-after so that the
// client can decide whether to retry.
if (error instanceof HTTPError) return toClientResponse(error.response)
throw error
}
}
124 changes: 124 additions & 0 deletions src/services/copilot/create-native-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import consola from "consola"

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

import { thinkingTypeReplacements } from "./thinking-type"

// Sends Anthropic Messages requests to Copilot's native /v1/messages endpoint
// without translation, because the /chat/completions translation drops
// `thinking` and `output_config.effort`. Only a thinking type that Copilot
// rejects is changed, in a copy of the payload.

export interface NativeMessagesPayload {
model: string
messages: Array<{ role: string; content: unknown }>
thinking?: { type: string; display?: string }
output_config?: { effort?: string }
}

// Also looks inside nested content, e.g. images in tool_result blocks.
function contentHasBlock(content: unknown, type: string): boolean {
return (
Array.isArray(content)
&& content.some(
(block: unknown) =>
typeof block === "object"
&& block !== null
&& "type" in block
&& (block.type === type
|| ("content" in block && contentHasBlock(block.content, type))),
)
)
}

function payloadHasBlock(
payload: NativeMessagesPayload,
type: string,
): boolean {
return payload.messages.some((message) =>
contentHasBlock(message.content, type),
)
}

function isAgentCall(payload: NativeMessagesPayload): boolean {
return (
payload.messages.some((message) => message.role === "assistant")
|| payloadHasBlock(payload, "tool_result")
)
}

function withThinkingType(
payload: NativeMessagesPayload,
type: string,
): NativeMessagesPayload {
if (payload.thinking?.type === type) return payload
const display = payload.thinking?.display
return { ...payload, thinking: { type, ...(display ? { display } : {}) } }
}

function requestSummary(payload: NativeMessagesPayload): string {
return (
`model=${payload.model}`
+ ` thinking=${JSON.stringify(payload.thinking ?? null)}`
+ ` effort=${JSON.stringify(payload.output_config?.effort ?? null)}`
)
}

function postMessages(
payload: NativeMessagesPayload,
anthropicHeaders: Record<string, string>,
): Promise<Response> {
const headers: Record<string, string> = {
...copilotHeaders(state, payloadHasBlock(payload, "image")),
"X-Initiator": isAgentCall(payload) ? "agent" : "user",
"anthropic-version": "2023-06-01",
...anthropicHeaders,
}
return fetch(`${copilotBaseUrl(state)}/v1/messages`, {
method: "POST",
headers,
body: JSON.stringify(payload),
})
}

// `anthropicHeaders` holds the client's anthropic-version and anthropic-beta.
export async function createNativeMessages(
payload: NativeMessagesPayload,
anthropicHeaders: Record<string, string>,
): Promise<Response> {
if (!state.copilotToken) throw new Error("Copilot token not found")

const requested = payload.thinking?.type
const request = requested ? { model: payload.model, requested } : undefined
const sent =
request ? thinkingTypeReplacements.typeToSend(request) : undefined
let body = sent ? withThinkingType(payload, sent) : payload
consola.info(`Native /v1/messages: ${requestSummary(body)}`)
let response = await postMessages(body, anthropicHeaders)

const retryType =
response.status === 400 && request && sent ?
thinkingTypeReplacements.retryType(
request,
sent,
await response.clone().text(),
)
: undefined
if (request && retryType) {
body = withThinkingType(payload, retryType)
consola.info(`Native /v1/messages: retry: ${requestSummary(body)}`)
response = await postMessages(body, anthropicHeaders)
if (response.ok) thinkingTypeReplacements.remember(request, retryType)
}

if (!response.ok) {
consola.error(
`Failed to create native messages: HTTP ${response.status}`,
await response.clone().text(),
)
throw new HTTPError("Failed to create native messages", response)
}
return response
}
81 changes: 81 additions & 0 deletions src/services/copilot/thinking-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copilot rejects some thinking types per model with HTTP 400 and names the
// type that it accepts, in one of two message shapes:
// "thinking.type.disabled" is not supported ... Use "thinking.type.adaptive" ...
// send "thinking": {"type": "between_tools"} instead of {"type": "disabled"}
const REPLACEMENT_PATTERN =
/send "thinking": \{"type": "(?<accepted>[a-z_]+)"\} instead of \{"type": "(?<rejected>[a-z_]+)"\}/
const REJECTED_PATTERN =
/"thinking\.type\.(?<rejected>[a-z_]+)" is not supported/
const ACCEPTED_PATTERN = /Use "thinking\.type\.(?<accepted>[a-z_]+)"/

export interface ThinkingRejection {
rejected: string
accepted: string | undefined
}

function readErrorMessage(errorBody: string): string {
try {
const parsed = JSON.parse(errorBody) as { error?: { message?: unknown } }
const message = parsed.error?.message
return typeof message === "string" ? message : errorBody
} catch {
return errorBody
}
}

export function parseThinkingRejection(
errorBody: string,
): ThinkingRejection | undefined {
const message = readErrorMessage(errorBody)
const replacement = REPLACEMENT_PATTERN.exec(message)?.groups
const rejected =
replacement?.rejected ?? REJECTED_PATTERN.exec(message)?.groups?.rejected
if (!rejected) return undefined
const accepted =
replacement?.accepted ?? ACCEPTED_PATTERN.exec(message)?.groups?.accepted
return { rejected, accepted }
}

// A client picks the thinking type from the model ID that it knows. A router
// can map that ID to a Copilot model with other rules. This keeps, per
// upstream model, the type to send instead of a requested type.
export interface ThinkingRequest {
model: string
requested: string
}

class ThinkingTypeReplacements {
readonly #byModel = new Map<string, Map<string, string>>()

typeToSend({ model, requested }: ThinkingRequest): string {
return this.#byModel.get(model)?.get(requested) ?? requested
}

// Call after HTTP 400. Forgets a replacement that Copilot now rejects and
// returns the type that the error names, if there is one.
retryType(
{ model, requested }: ThinkingRequest,
sent: string,
errorBody: string,
): string | undefined {
const rejection = parseThinkingRejection(errorBody)
if (rejection?.rejected !== sent) return undefined
this.#byModel.get(model)?.delete(requested)
return rejection.accepted === sent ? undefined : rejection.accepted
}

// Call after a retry with `accepted` succeeds.
remember({ model, requested }: ThinkingRequest, accepted: string): void {
if (accepted === requested) return
const replacements = this.#byModel.get(model) ?? new Map<string, string>()
replacements.set(requested, accepted)
this.#byModel.set(model, replacements)
}

// The cache lives for the process. Tests reset it between cases.
clear(): void {
this.#byModel.clear()
}
}

export const thinkingTypeReplacements = new ThinkingTypeReplacements()
Loading