Skip to content

fix(github): route Claude models to Copilot's native format via chatCore - #2761

Open
yidecode wants to merge 1 commit into
decolua:masterfrom
yidecode:pr/github-claude-native-target-format
Open

yidecode wants to merge 1 commit into
decolua:masterfrom
yidecode:pr/github-claude-native-target-format

Conversation

@yidecode

Copy link
Copy Markdown
Contributor

Follow-up to #2608 (same author). That PR sent Claude models to Copilot's Anthropic-native /v1/messages shim by branching inside the executor: chatCore still resolved github's target format to "openai", and executeWithMessagesEndpoint translated openai → claude on the way out and claude → openai on the way back.

That made targetFormat a lie, and Claude-format clients paid for it.

The bug

A request from Claude Code / Zed (directly, or through a downstream Anthropic proxy) went claude → openai → claude. translator/request/claude-to-openai.js has no case for thinking blocks — its switch handles text, image, tool_use and tool_result only. The assistant turn's thinking block and its signature were therefore dropped on the way in, so on every follow-up turn the model lost its own prior reasoning.

It failed silently: Copilot accepts a history with no thinking block (verified), it only rejects a thinking block whose signature is missing. Same body, two routes:

claude → claude   assistant blocks: ['thinking', 'tool_use']   signature: preserved verbatim
claude → openai   assistant keys:   ['role', 'tool_calls']     thinking: gone

The fix

The provider now declares the routing decision where chatCore can see it:

transport.resolveTargetFormat(model) -> "claude" | null

Resolved at request time from the model name, because Copilot's live catalog ships claude-* variants long before the static models[] list catches up — it currently serves claude-sonnet-5, claude-opus-4.8, claude-opus-4.8-fast and claude-fable-5, none of them listed. A static per-entry targetFormat would miss exactly those, which is why #2608 avoided one; a runtime hook does not have that problem.

Providers without the hook are unaffected — the helper returns null and the existing precedence chain is untouched.

With the target format honest:

  • A claude-source request short-circuits in translateRequest (sourceFormat === targetFormat) and reaches the shim untouched — thinking blocks and real signatures survive verbatim.
  • prepareClaudeRequest still runs — it is gated on targetFormat === CLAUDE, outside the short-circuit — so feat(github): route Claude models through Copilot's native /v1/messages #2608's cache_control injection and its prompt-cache token counts are preserved. There is a test pinning this.
  • OpenAI-format clients still translate once, now in chatCore rather than the executor.

executeWithMessagesEndpoint goes away entirely (-90 lines), along with its hand-rolled SSE transform and its duplicate _toolNameMap threading: the generic streaming handler already does both, keyed off the same target format. Net -72 lines.

Verification

  • Live end-to-end against Copilot: the exact body 9router now produces for a claude-source multi-turn request — thinking block with a real 400-char signature, tool_use, tool_result — returns 200.
  • Deployed smoke test through the running gateway: multi-turn thinking replay, single-turn thinking text, and OpenAI-format clients all pass.
  • Full suite: failure set identical to the master baseline, zero regression.
  • 13 new tests, including a reverse guard that explicitly asserts the claude → openai hop drops thinking blocks, so a future refactor cannot quietly route back through it.

Scope

Deliberately does not touch request/claude-to-openai.js or openai-to-claude.js — patching those would affect every provider that pivots through OpenAI, for a benefit this routing fix already delivers. Note that tests/translator/bugs-toClaude-context.test.js already has a red test (assistant reasoning_content becomes a thinking block) documenting that separate gap.

OpenAI-format clients still cannot round-trip thinking — that format has nowhere to carry a signature. Inherent, not addressed here.

本 PR 由 Claude Code 辅助完成

decolua#2608 sent Claude models to Copilot's Anthropic-native /v1/messages shim by
branching inside the executor: chatCore still resolved github's target format
to "openai", and executeWithMessagesEndpoint translated openai -> claude on
the way out and claude -> openai on the way back.

That made targetFormat a lie, and a Claude-format client paid for it. A request
from Claude Code / Zed (directly, or through a downstream Anthropic proxy) went
claude -> openai -> claude, and request/claude-to-openai.js has no case for
thinking blocks — its switch handles text, image, tool_use and tool_result only.
The assistant turn's thinking block and signature were therefore dropped on the
way in, so on every follow-up turn the model lost its own prior reasoning. It
failed silently: Copilot accepts a history with no thinking block (verified), it
only rejects a thinking block whose signature is missing.

The provider now declares the routing decision where chatCore can see it:

  transport.resolveTargetFormat(model) -> "claude" | null

resolved at request time from the model NAME, because Copilot's live catalog
ships claude-* variants long before the static models[] list catches up (it
currently serves claude-sonnet-5, claude-opus-4.8, claude-opus-4.8-fast and
claude-fable-5, none of them listed). A static per-entry targetFormat would miss
exactly those. Providers without the hook are unaffected — the helper returns
null and the existing precedence chain is untouched.

With the target format honest, a claude-source request short-circuits in
translateRequest (sourceFormat === targetFormat) and reaches the shim untouched:
thinking blocks and real signatures survive verbatim. prepareClaudeRequest still
runs — it is gated on targetFormat === CLAUDE, outside the short-circuit — so
decolua#2608's cache_control injection and its prompt-cache token counts are preserved.
OpenAI-format clients still translate once, now in chatCore rather than the
executor.

executeWithMessagesEndpoint goes away entirely (-90 lines), along with its
hand-rolled SSE transform and its duplicate _toolNameMap threading: the generic
streaming handler already does both, keyed off the same target format.

由 Claude Code 辅助生成
golamrabbi696 added a commit to golamrabbi696/EzRouter that referenced this pull request Aug 12, 2026
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