Repository navigation
feat: forward Claude messages to Copilot native /v1/messages #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Mizoreww
wants to merge
1
commit into
ericc-ch:master
Choose a base branch
from
Mizoreww:native-anthropic-messages
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: ericc-ch/copilot-api
Length of output: 4071
Script executed:
Repository: ericc-ch/copilot-api
Length of output: 22924
Script executed:
Repository: ericc-ch/copilot-api
Length of output: 22828
Script executed:
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.modelunchanged, so dated model IDs can be rejected upstream.Suggested fix
There was a problem hiding this comment.
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:
/v1/messagesaccepts 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-20260601andclaude-haiku-4-5-20251001.translateModelNamechangesclaude-opus-4-8toclaude-opus-4. Copilot returns HTTP 400 forclaude-opus-4with the message "The requested model is not supported.". Thus the suggested change makes requests fail that work now.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.tsare calls toRegExp.prototype.execwith literal patterns. These calls do not run shell commands.