Skip to content

feat: forward Claude messages to Copilot native /v1/messages - #280

Open
Mizoreww wants to merge 1 commit into
ericc-ch:masterfrom
Mizoreww:native-anthropic-messages
Open

Mizoreww wants to merge 1 commit into
ericc-ch:masterfrom
Mizoreww:native-anthropic-messages

Conversation

@Mizoreww

Copy link
Copy Markdown

Summary

This PR sends /v1/messages requests for claude* models to the Copilot endpoint /v1/messages. The proxy does not translate these requests to the OpenAI format. The model name, thinking and output_config.effort get to Copilot without changes.

Problem

  1. The proxy translates Anthropic requests to OpenAI /chat/completions. The translation removes thinking and output_config.effort.
  2. Copilot /chat/completions ignores reasoning_effort. A test with low, high and no value gave 81, 82 and 81 completion tokens.
  3. Thus the effort setting of Claude Code has no effect through this proxy.

Changes

  1. src/routes/messages/native-handler.ts: The route sends a request to the native path when the model name starts with claude. The route returns the upstream response body without changes. This includes streaming responses. The route asks for manual approval one time, before it selects the path.
  2. src/services/copilot/create-native-messages.ts: The service sends the request body to {copilotBaseUrl}/v1/messages without changes. It forwards the anthropic-version and anthropic-beta headers of the client. It sets X-Initiator with the same rule as the translation path. It sets copilot-vision-request when the request contains an image, also inside a tool_result block.
  3. src/services/copilot/thinking-type.ts: Copilot can reject a thinking.type with HTTP 400. The error message names a type that the model accepts. The service changes only thinking.type in a copy of the request and sends it one more time. The effort value does not change. After a successful retry, the service keeps the accepted type in memory for each upstream model. Later requests get the change before the first send. If Copilot rejects a type from memory, the service removes it and uses the type that the new error names.
  4. Errors: The service throws HTTPError. The route returns the upstream status and body to the client. The route forwards the retry-after and x-should-retry headers for client retries, and cache-control for streaming responses.
  5. src/routes/messages/anthropic-types.ts: The thinking type accepts all types that clients send. The payload type also declares output_config.
  6. Option: --no-native-messages sends Claude models through the translation path. The README lists the option.
  7. These requests use the translation path as before:
    • Requests for models that are not Claude models.
    • /v1/messages/count_tokens.
    • Requests that end with an assistant message (prefill). Copilot /v1/messages rejects a prefill for Opus 5.5 and Sonnet 5.5 with HTTP 400.
  8. Tests: tests/thinking-type.test.ts checks how the service reads the accepted type from error messages. tests/native-messages.test.ts checks the retry, the cache, the error path, the headers and the route selection.

Why the retry reads the error message

Claude Code selects the thinking type from the model ID that it knows. Some routers change the model ID before the request gets to this proxy. For example, cc-switch maps claude-haiku-4-5 to claude-opus-5-5. Then the client can send a type that the upstream model rejects.

The accepted types are different for each model. These results are from 2026-10-27:

Upstream model Accepted thinking.type Rejected thinking.type
claude-opus-5-5 not set, adaptive enabled, disabled, between_tools
claude-sonnet-5-5 not set, adaptive, between_tools enabled, disabled
claude-haiku-5-5 not set, adaptive, disabled enabled, between_tools

A fixed table needs a change for each new model. The error message already names the accepted type. Thus the retry uses the error message.

Test results

Account type: Copilot Business. Model: claude-opus-5.5. Date: 2026-10-27.

  1. Effort gets to the model. Direct requests to Copilot /v1/messages used 12, 79 and 241 thinking tokens for low, high and max. Through this proxy, Claude Code used 0, 0, 52, 55 and 136 thinking tokens for low, medium, high, xhigh and max.
  2. Claude Code 2.1.308 sent requests through cc-switch with four model IDs: claude-fable-5, claude-opus-5, claude-sonnet-5 and claude-haiku-4-5. All requests completed without an error.
  3. A Claude Code session with one tool call completed two turns.
  4. A request with an assistant prefill used the translation path, as before this change.
  5. With --no-native-messages, a Claude request used the translation path and completed.
  6. Model names: Copilot /v1/messages accepted dotted, dashed and dated IDs of the models that it offers, for example claude-opus-4-8, claude-opus-4.8 and claude-haiku-4-5-20251001. Thus the native path does not change model names.
  7. Beta header: Copilot /v1/messages ignored an unknown anthropic-beta value and returned HTTP 200.
  8. Billing: Both endpoints return a copilot_usage object with the same price table for claude-opus-5.5. For the same prompt of approximately 94k tokens, /chat/completions reported 46.94 AI credits. Copilot billed that prompt as a cache write. /v1/messages reported 37.53 AI credits for the same prompt as input tokens.
  9. bun test: all tests pass.

Limits

  1. For each model ID and thinking type, the first request gets one HTTP 400 before the accepted type is in memory. A restart clears the memory.
  2. Claude Code sends no effort value for claude-haiku-4-5. Thus the effort setting has no effect for this model ID.
  3. Requests with an assistant prefill keep the translation path. Thus thinking and effort of these requests do not get to Copilot.

Related PR

#246 also sends Claude requests to /v1/messages. It uses a fixed sanitizer. The sanitizer sets the effort of Opus models to medium. This PR forwards the effort value without changes. This PR changes only the thinking types that Copilot rejects.

🤖 Generated with Claude Code

Requests for claude* models skip the OpenAI translation, so the model
name, thinking, output_config.effort and anthropic-beta reach Copilot.

- When Copilot rejects the thinking type with HTTP 400, retry once with
  the type that the error names and remember it per upstream model.
- Return upstream errors with their status and body, and forward
  retry-after, x-should-retry and cache-control.
- Keep the translation path for other models, count_tokens and requests
  that end with an assistant prefill.
- Add the --no-native-messages option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Walkthrough

The start command enables native Messages routing by default and provides an option to disable it. Eligible Claude requests go to Copilot’s native /v1/messages endpoint; other requests continue through the translated completion path. Native requests forward selected Anthropic headers and can retry recognized thinking-type rejections, with successful replacements cached per model.

Priority: Normal

Merge Risk: Minimal · up to 6d124

Native model-ID forwarding remains worth checking against Copilot, but no blocking failure is established by the available evidence.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surv… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: forwarding Claude messages to Copilot's native /v1/messages endpoint.
Description check Passed The description directly explains the native request path, retry behavior, headers, fallback cases, tests, and known limits described by the changeset.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 22 functions across 10 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/lib/state.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

src/routes/messages/anthropic-types.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

src/routes/messages/handler.ts

ESLint skipped: the matched ESLint configuration already failed (missing-dependency).

  • 7 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1


Review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: d7562d33-2c96-42ca-ab7f-37d6e5db8678
Commits

Reviewing files that changed from the base of the PR and between 0ea08fe and 6d124a7.

Files selected for processing: 11
  • README.md
  • src/lib/state.ts
  • src/routes/messages/anthropic-types.ts
  • src/routes/messages/handler.ts
  • src/routes/messages/native-handler.ts
  • src/services/copilot/create-native-messages.ts
  • src/services/copilot/thinking-type.ts
  • src/start.ts
  • tests/fixtures/thinking-errors.ts
  • tests/native-messages.test.ts
  • tests/thinking-type.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Review details
Context from checks skipped due to timeout: 1
  • GitHub Check: CodeRabbit
Additional context used
Code guidelines: 1
AGENTS.md — auto-discovered
Path-based instructions: 1
Source excerpt: **Testing:** Use Bun's built-in test runner.

CodeRabbit inference engine (AGENTS.md)

Files:

  • tests/thinking-type.test.ts
  • tests/native-messages.test.ts
OpenGrep (1.30.1)
src/services/copilot/thinking-type.ts

[ERROR] 30-30: A nonliteral argument is passed to exec/execSync. Identify the receiver: Node child_process methods execute shell commands, while regular-expression and database methods have different contracts. For shell execution, prefer execFile or spawn with an argument array.

(coderabbit.command-injection.exec-js)


[ERROR] 32-32: A nonliteral argument is passed to exec/execSync. Identify the receiver: Node child_process methods execute shell commands, while regular-expression and database methods have different contracts. For shell execution, prefer execFile or spawn with an argument array.

(coderabbit.command-injection.exec-js)


[ERROR] 35-35: A nonliteral argument is passed to exec/execSync. Identify the receiver: Node child_process methods execute shell commands, while regular-expression and database methods have different contracts. For shell execution, prefer execFile or spawn with an argument array.

(coderabbit.command-injection.exec-js)

Additional comments: 11
src/services/copilot/thinking-type.ts: 1

1-81: LGTM!

src/services/copilot/create-native-messages.ts: 1

1-124: LGTM!

tests/fixtures/thinking-errors.ts: 1

1-17: LGTM!

tests/thinking-type.test.ts: 1

1-108: LGTM!

tests/native-messages.test.ts: 1

1-304: LGTM!

src/lib/state.ts: 1

14-14: LGTM!

Also applies to: 26-26

src/start.ts: 1

28-28: LGTM!

Also applies to: 50-50, 189-194, 213-213

src/routes/messages/anthropic-types.ts: 1

22-28: LGTM!

src/routes/messages/handler.ts: 1

19-22: LGTM!

Also applies to: 35-37

src/routes/messages/native-handler.ts: 1

1-64: LGTM!

README.md: 1

154-166: LGTM!

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

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant