From fea1d54e6de8e73c00229ebe4fa4643d411cade8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:40:09 -0300 Subject: [PATCH] feat(sse): route GitHub Copilot Claude models through native /v1/messages (#7223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sse): route GitHub Copilot Claude models through native /v1/messages GitHub Copilot's /chat/completions and /responses endpoints never surface prompt-cache token counts (cached_tokens) for Claude models, and round-tripping Claude tool_use/tool_result/thinking content blocks through the OpenAI shape is lossy. Copilot also exposes an Anthropic-native /v1/messages shim that reports cached_tokens correctly and accepts native content blocks as-is. Tag each github registry claude-* model with targetFormat: "claude" so chatCore.ts translates the request to Anthropic-native shape before the executor ever sees it (the same mechanism opencode/zen's Qwen entries and opencode/go already use), and teach the github executor's buildUrl() / buildHeaders() to dispatch those models at the new messagesUrl (api.githubcopilot.com/v1/messages) with the required anthropic-version header. transformRequest() now skips its /chat/completions-only quirks (content-part flattening, trailing-assistant-prefill drop, the response_format-as-system-prompt workaround) for the native path — the first would destroy native tool_use/tool_result blocks, the prefill drop is unnecessary because the real Anthropic API supports assistant prefill, and the response_format workaround is superseded by the generic openai-to-claude translator's own JSON-mode handling. Co-authored-by: luoyide Inspired-by: https://github.com/decolua/9router/pull/2608 * chore(changelog): fragment for #7223 * fix(sse): green PR #7223 CI — complexity ratchet + stale test expectations - Extract applyChatCompletionsOnlyQuirks() and resolveInitiatorHeader() out of GithubExecutor.transformRequest()/buildHeaders() so the two methods drop back under the complexity/cognitive-complexity ratchets (2058/891 -> 2056/890, matching the frozen baseline). No behavior change — same guards, just relocated. - Update 4 pre-existing unit tests that hard-coded now-native claude-* Copilot ids (claude-sonnet-4.5/4.6) to exercise the /chat/completions legacy path via an unregistered id (claude-sonnet-4), matching the sibling test already using that pattern. These ids now intentionally route to the native /v1/messages shim added by this PR, which correctly skips the /chat/completions-only workarounds these tests were built to verify — the native path's own coverage lives in github-copilot-claude-native-messages.test.ts. - Split the routing invariant test (copilot-gemini-claude-route-no-responses.test.ts) into a Claude case (expects /v1/messages) and a Gemini case (still expects /chat/completions), reflecting the intentional routing change. --------- Co-authored-by: luoyide --- ...3-github-copilot-claude-native-messages.md | 1 + open-sse/config/providerRegistry.ts | 3 + .../config/providers/registry/github/index.ts | 20 +++ open-sse/config/providers/shared.ts | 5 + open-sse/executors/base.ts | 1 + open-sse/executors/github.ts | 144 ++++++++++++------ ...t-gemini-claude-route-no-responses.test.ts | 13 +- .../executor-github-prefill-sanitize.test.ts | 10 +- tests/unit/executor-github.test.ts | 8 +- ...hub-copilot-claude-native-messages.test.ts | 131 ++++++++++++++++ tests/unit/provider-models-config.test.ts | 9 +- ...t27-github-copilot-response-format.test.ts | 8 +- 12 files changed, 296 insertions(+), 57 deletions(-) create mode 100644 changelog.d/features/7223-github-copilot-claude-native-messages.md create mode 100644 tests/unit/github-copilot-claude-native-messages.test.ts diff --git a/changelog.d/features/7223-github-copilot-claude-native-messages.md b/changelog.d/features/7223-github-copilot-claude-native-messages.md new file mode 100644 index 0000000000..7885ba5596 --- /dev/null +++ b/changelog.d/features/7223-github-copilot-claude-native-messages.md @@ -0,0 +1 @@ +- **feat(sse):** GitHub Copilot Claude models now route through Copilot's native `/v1/messages` endpoint (prompt-cache token counts, no more lossy tool-call round-trip). (thanks @yidecode) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 307afe2a18..051783f6c9 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -39,6 +39,9 @@ export function generateLegacyProviders(): Record { if (entry.responsesBaseUrl) { p.responsesBaseUrl = entry.responsesBaseUrl; } + if (entry.messagesUrl) { + p.messagesUrl = entry.messagesUrl; + } if (entry.requestDefaults) { p.requestDefaults = entry.requestDefaults; } diff --git a/open-sse/config/providers/registry/github/index.ts b/open-sse/config/providers/registry/github/index.ts index 41673dcf7d..e4f6f7e563 100644 --- a/open-sse/config/providers/registry/github/index.ts +++ b/open-sse/config/providers/registry/github/index.ts @@ -12,6 +12,12 @@ export const githubProvider: RegistryEntry = { executor: "github", baseUrl: "https://api.githubcopilot.com/chat/completions", responsesBaseUrl: "https://api.githubcopilot.com/responses", + // Anthropic-native shim: the only Copilot endpoint that surfaces prompt-cache + // token counts (cached_tokens) for Claude models, and avoids round-tripping + // tool_use/tool_result/thinking content blocks through the OpenAI shape. + // Routed via each claude-* model's targetFormat: "claude" below (see + // executors/github.ts buildUrl/buildHeaders). Port of decolua/9router#2608. + messagesUrl: "https://api.githubcopilot.com/v1/messages", authType: "oauth", authHeader: "bearer", // GitHub Copilot is a public device-flow OAuth client: it has a public client_id but @@ -24,16 +30,23 @@ export const githubProvider: RegistryEntry = { }, defaultContextLength: 128000, headers: getGitHubCopilotChatHeaders(), + // All claude-* entries below carry targetFormat: "claude" so chatCore.ts + // translates the request to Anthropic-native shape before the executor ever + // sees it, and the github executor's buildUrl()/buildHeaders() route them at + // messagesUrl (/v1/messages) instead of /chat/completions. Port of + // decolua/9router#2608 (author: yidecode) — see executors/github.ts. models: [ { id: "claude-fable-5", name: "Claude Fable 5", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-opus-4.8-fast", name: "Claude Opus 4.8 (fast mode)", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, unsupportedParams: ["temperature", "top_p", "top_k"], @@ -41,6 +54,7 @@ export const githubProvider: RegistryEntry = { { id: "claude-opus-4.8", name: "Claude Opus 4.8", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, unsupportedParams: ["temperature", "top_p", "top_k"], @@ -48,36 +62,42 @@ export const githubProvider: RegistryEntry = { { id: "claude-opus-4.7", name: "Claude Opus 4.7", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-opus-4.5", name: "Claude Opus 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index acf510ecf9..c6f32b03ec 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -107,6 +107,10 @@ export interface RegistryEntry { /** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */ testKeyBaseUrl?: string; responsesBaseUrl?: string; + /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used + * for models tagged `targetFormat: "claude"` on an otherwise openai-format + * provider — see registry/github/index.ts. */ + messagesUrl?: string; urlSuffix?: string; urlBuilder?: (base: string, model: string, stream: boolean) => string; authType: string; @@ -174,6 +178,7 @@ export interface LegacyProvider { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + messagesUrl?: string; headers?: Record; requestDefaults?: ProviderRequestDefaults; clientId?: string; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 0058affe32..3d82de7680 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -117,6 +117,7 @@ export type ProviderConfig = { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + messagesUrl?: string; chatPath?: string; clientVersion?: string; clientId?: string; diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 5271500d31..bbb6bb3641 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -41,6 +41,16 @@ export class GithubExecutor extends BaseExecutor { buildUrl(model: string, _stream: boolean, _urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); + // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the + // only Copilot endpoint that surfaces prompt-cache token counts for Claude and + // avoids a lossy round-trip of tool_use/tool_result/thinking content blocks + // through the OpenAI shape. Driven by the registry's per-model targetFormat + // (see registry/github/index.ts), which chatCore.ts also uses to translate the + // request to Claude shape before the executor ever sees it. + // Port of decolua/9router#2608 (author: yidecode). + if (targetFormat === "claude" && this.config.messagesUrl) { + return this.config.messagesUrl; + } // 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"] // and 400 on /chat/completions. Route any *-codex id to /responses even when it // isn't in the curated registry, so newly-shipped Codex models work out of the box. @@ -93,6 +103,15 @@ export class GithubExecutor extends BaseExecutor { const sourceBody = body && typeof body === "object" ? body : {}; const modifiedBody = { ...sourceBody }; + // Claude models arrive here already translated to Anthropic-native shape by + // chatCore.ts (registry targetFormat: "claude" — see registry/github/index.ts) + // and are dispatched at /v1/messages (buildUrl above), which behaves like the + // real Anthropic API. None of the /chat/completions-only quirks below apply — + // content-part flattening would destroy native tool_use/tool_result/thinking + // blocks, and the native endpoint (unlike Copilot's /chat/completions) honors + // assistant-message prefill. Port of decolua/9router#2608 (author: yidecode). + const isClaudeNative = getModelTargetFormat("gh", model) === "claude"; + if (Array.isArray(sourceBody.input)) { modifiedBody.input = sanitizeResponsesInputItems(sourceBody.input, false); } @@ -110,14 +129,6 @@ export class GithubExecutor extends BaseExecutor { }); } - if (modifiedBody.response_format && model.toLowerCase().includes("claude")) { - modifiedBody.messages = this.injectResponseFormat( - Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [], - modifiedBody.response_format - ); - delete modifiedBody.response_format; - } - if (Array.isArray(modifiedBody.tools) && modifiedBody.tools.length > 128) { modifiedBody.tools = modifiedBody.tools.slice(0, 128); } @@ -136,29 +147,13 @@ export class GithubExecutor extends BaseExecutor { delete modifiedBody.temperature; } - // GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} - // content parts. Clients like Cursor IDE pass through Anthropic-shape parts - // (tool_use, tool_result, thinking) untouched when using Claude models, which makes - // the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400). - // Serialize unknown part types as text, drop empty parts, and collapse to null when - // every part is stripped (assistant messages whose only content was tool_calls). - // Port from 9router#220 (fixes 9router#219). - if (Array.isArray(modifiedBody.messages)) { - modifiedBody.messages = modifiedBody.messages.map((msg: any) => - this.sanitizeChatCompletionsMessage(msg) - ); - } - - // GitHub Copilot's /chat/completions endpoint rejects a conversation that ends - // with an assistant message: "This model does not support assistant message - // prefill. The conversation must end with a user message." (HTTP 400). Anthropic - // clients such as newest Claude Desktop send a trailing assistant turn as a - // prefill seed — the Anthropic API honors it, but Copilot does not. Drop it here, - // scoped to the GitHub executor only (the shared translator/contextManager and - // other providers that DO honor prefill are untouched). - // Port of 9router#2143 (author: Manuel ). - if (Array.isArray(modifiedBody.messages)) { - modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages); + // The quirks below (response_format-as-system-prompt, content-part flattening, + // trailing-assistant-prefill drop) are all workarounds for /chat/completions-only + // limitations. They either don't apply to Claude-shape bodies or actively corrupt + // them, so they are skipped entirely for the native /v1/messages path. Port of + // decolua/9router#2608 (author: yidecode) — see class doc comment above. + if (!isClaudeNative) { + this.applyChatCompletionsOnlyQuirks(model, modifiedBody); } // Config-driven strip of params unsupported by the target provider/model. @@ -171,6 +166,46 @@ export class GithubExecutor extends BaseExecutor { return modifiedBody; } + // GitHub Copilot's /chat/completions endpoint has several quirks that the native + // /v1/messages shim doesn't share — extracted from transformRequest so the native + // path (the common case for Claude models going forward) doesn't pay their branch + // cost. Mutates modifiedBody in place. + private applyChatCompletionsOnlyQuirks(model: string, modifiedBody): void { + // Claude models on /chat/completions don't support response_format — inject the + // instruction as a system message instead. Port from 9router (see + // injectResponseFormat above). + if (modifiedBody.response_format && model.toLowerCase().includes("claude")) { + modifiedBody.messages = this.injectResponseFormat( + Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [], + modifiedBody.response_format + ); + delete modifiedBody.response_format; + } + + if (!Array.isArray(modifiedBody.messages)) return; + + // GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} + // content parts. Clients like Cursor IDE pass through Anthropic-shape parts + // (tool_use, tool_result, thinking) untouched when using Claude models, which makes + // the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400). + // Serialize unknown part types as text, drop empty parts, and collapse to null when + // every part is stripped (assistant messages whose only content was tool_calls). + // Port from 9router#220 (fixes 9router#219). + modifiedBody.messages = modifiedBody.messages.map((msg: any) => + this.sanitizeChatCompletionsMessage(msg) + ); + + // GitHub Copilot's /chat/completions endpoint rejects a conversation that ends + // with an assistant message: "This model does not support assistant message + // prefill. The conversation must end with a user message." (HTTP 400). Anthropic + // clients such as newest Claude Desktop send a trailing assistant turn as a + // prefill seed — the Anthropic API honors it, but Copilot does not. Drop it here, + // scoped to the GitHub executor only (the shared translator/contextManager and + // other providers that DO honor prefill are untouched). + // Port of 9router#2143 (author: Manuel ). + modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages); + } + private sanitizeChatCompletionsMessage(msg: any): any { if (!msg || typeof msg !== "object") return msg; // String content and missing content (e.g. assistant w/ only tool_calls) pass through. @@ -235,17 +270,38 @@ export class GithubExecutor extends BaseExecutor { buildHeaders( credentials: ProviderCredentials, stream = true, - clientHeaders?: Record | null + clientHeaders?: Record | null, + model?: string ): Record { const token = this.getCopilotToken(credentials) || credentials.accessToken; + const initiator = this.resolveInitiatorHeader(clientHeaders); - // Forward the client's x-initiator header when present. OpenCode and other - // Copilot-aware clients use this to distinguish user-initiated turns - // (x-initiator: user) from autonomous tool-call continuations - // (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as - // free, so forwarding the value avoids burning a premium request on every - // tool-call round-trip. Fall back to "user" when the header is absent to - // preserve the existing default behaviour. + const headers: Record = { + ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), + Authorization: `Bearer ${token}`, + "x-request-id": + crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, + }; + + // Claude models routed to the Anthropic-native /v1/messages shim require the + // anthropic-version header (harmless no-op on /chat/completions and /responses, + // but /v1/messages rejects the request without it). Port of decolua/9router#2608. + if (model && getModelTargetFormat("gh", model) === "claude") { + headers["anthropic-version"] = "2023-06-01"; + } + + return headers; + } + + // Forward the client's x-initiator header when present. OpenCode and other + // Copilot-aware clients use this to distinguish user-initiated turns + // (x-initiator: user) from autonomous tool-call continuations + // (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as + // free, so forwarding the value avoids burning a premium request on every + // tool-call round-trip. Falls back to "user" when the header is absent to + // preserve the existing default behaviour. Extracted from buildHeaders so + // header assembly stays the one place that reads it. + private resolveInitiatorHeader(clientHeaders?: Record | null): string { let clientInitiator = clientHeaders?.["x-initiator"] || clientHeaders?.["X-Initiator"]; if (!clientInitiator && clientHeaders) { for (const key in clientHeaders) { @@ -255,15 +311,7 @@ export class GithubExecutor extends BaseExecutor { } } } - const initiator = - clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; - - return { - ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), - Authorization: `Bearer ${token}`, - "x-request-id": - crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, - }; + return clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; } async refreshCopilotToken(githubAccessToken, log) { diff --git a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts index 751cf98713..0ee255c518 100644 --- a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts +++ b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts @@ -27,6 +27,7 @@ import type { RegistryModel } from "../../open-sse/config/providerRegistry.ts"; const CHAT_URL = "https://api.githubcopilot.com/chat/completions"; const RESPONSES_URL = "https://api.githubcopilot.com/responses"; +const MESSAGES_URL = "https://api.githubcopilot.com/v1/messages"; function getGithubModel(modelId: string): RegistryModel { const model = PROVIDER_MODELS["gh"]?.find((entry) => entry.id === modelId); @@ -35,7 +36,7 @@ function getGithubModel(modelId: string): RegistryModel { } describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => { - it("routes registered Claude/Gemini Copilot models to chat/completions", () => { + it("routes registered Claude Copilot models to the native /v1/messages shim (port decolua/9router#2608)", () => { const exec = new GithubExecutor(); for (const id of [ "claude-haiku-4.5", @@ -43,14 +44,18 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout "claude-sonnet-4.6", "claude-sonnet-5", "claude-fable-5", - "claude-opus-4.6", "claude-opus-4.7", "claude-opus-4.8", "claude-opus-4.8-fast", "claude-opus-4.5", - "gemini-3.1-pro-preview", - "gemini-3.5-flash", ]) { + assert.equal(exec.buildUrl(id, false), MESSAGES_URL, `${id} must route to /v1/messages`); + } + }); + + it("routes registered Gemini Copilot models to chat/completions", () => { + const exec = new GithubExecutor(); + for (const id of ["gemini-3.1-pro-preview", "gemini-3.5-flash"]) { assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`); } }); diff --git a/tests/unit/executor-github-prefill-sanitize.test.ts b/tests/unit/executor-github-prefill-sanitize.test.ts index 346882951b..b19ddb1c47 100644 --- a/tests/unit/executor-github-prefill-sanitize.test.ts +++ b/tests/unit/executor-github-prefill-sanitize.test.ts @@ -87,15 +87,21 @@ test("dropTrailingAssistantPrefill is null/empty safe", () => { test("GithubExecutor.transformRequest drops the trailing assistant prefill end-to-end", () => { const executor = new GithubExecutor(); + // Use an unregistered claude-* id so getModelTargetFormat("gh", ...) resolves + // to null and this stays on the /chat/completions path this test targets. + // Registered claude-* ids (e.g. "claude-sonnet-4.6") now carry + // targetFormat:"claude" (native /v1/messages, which supports prefill — port + // of decolua/9router#2608, see github-copilot-claude-native-messages.test.ts) + // and intentionally skip this drop. const body = { - model: "claude-sonnet-4.6", + model: "claude-sonnet-4", messages: [ { role: "user", content: "Hi" }, { role: "assistant", content: "Here is the answer:" }, ], }; - const out = executor.transformRequest("claude-sonnet-4.6", body, false, {}); + const out = executor.transformRequest("claude-sonnet-4", body, false, {}); assert.equal(out.messages.length, 1); assert.equal(out.messages[0].role, "user"); diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index cf4efb0006..6e136e842c 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -161,7 +161,13 @@ test("GithubExecutor.transformRequest sanitizes Anthropic-shape content parts (t ], }; - const result = executor.transformRequest("claude-sonnet-4.6", body, true, {}); + // Use an unregistered claude-* id (not "claude-sonnet-4.6"/etc.) so + // getModelTargetFormat("gh", ...) resolves to null and this stays on the + // /chat/completions path this test targets. Registered claude-* ids now + // carry targetFormat:"claude" (native /v1/messages — port of + // decolua/9router#2608, see github-copilot-claude-native-messages.test.ts) + // and intentionally skip this sanitization. + const result = executor.transformRequest("claude-sonnet-4", body, true, {}); // user message keeps text + image_url parts untouched assert.equal(result.messages[0].content[0].type, "text"); diff --git a/tests/unit/github-copilot-claude-native-messages.test.ts b/tests/unit/github-copilot-claude-native-messages.test.ts new file mode 100644 index 0000000000..abe754139a --- /dev/null +++ b/tests/unit/github-copilot-claude-native-messages.test.ts @@ -0,0 +1,131 @@ +// GitHub Copilot exposes an Anthropic-native `/v1/messages` shim alongside its +// OpenAI-shape `/chat/completions` and `/responses` endpoints. Only the native +// shim surfaces prompt-cache token counts (`cached_tokens`) for Claude models — +// /chat/completions silently drops them, and round-tripping Claude tool_use / +// tool_result / thinking content blocks through the OpenAI shape is lossy. +// +// Port of upstream decolua/9router#2608 (author: yidecode), adapted to +// OmniRoute's architecture: instead of the executor doing its own +// translateRequest/translateResponse + manual SSE TransformStream (9router has +// no generic per-model targetFormat mechanism), OmniRoute already has a +// registry-driven `targetFormat` field (see opencode/zen's Qwen entries, +// opencode/go) that makes chatCore.ts translate the request to Claude shape +// *before* the executor ever sees it, and translate the response back +// generically afterwards. So the actual port is: (1) tag the github registry's +// claude-* models with targetFormat:"claude", (2) teach the github executor's +// buildUrl()/buildHeaders() to route those models at the new messagesUrl with +// an anthropic-version header, and (3) gate the executor's /chat/completions-only +// request transforms (content-part flattening, trailing-assistant-prefill drop, +// response_format-as-system-prompt workaround) off for the native path, since +// they either don't apply to Claude-shape bodies or actively corrupt them. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GithubExecutor } = await import("../../open-sse/executors/github.ts"); +const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); + +test("registry: claude-* github models resolve targetFormat 'claude'", () => { + for (const model of ["claude-opus-4.8", "claude-sonnet-4.6", "claude-haiku-4.5"]) { + assert.equal( + getModelTargetFormat("gh", model), + "claude", + `${model} must resolve to the claude target format so chatCore translates natively` + ); + } +}); + +test("registry: non-claude github models keep their existing targetFormat", () => { + assert.equal(getModelTargetFormat("gh", "gpt-5.4"), "openai-responses"); + assert.equal(getModelTargetFormat("gh", "gpt-4o-mini"), null); +}); + +test("buildUrl: claude models route to the native /v1/messages endpoint", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("claude-opus-4.8", true); + assert.equal(url, "https://api.githubcopilot.com/v1/messages"); +}); + +test("buildUrl: gpt codex/responses models still route to /responses", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("gpt-5.4", true); + assert.match(url, /\/responses$/); +}); + +test("buildUrl: plain gpt models still route to /chat/completions", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("gpt-4o-mini", true); + assert.equal(url, executor.config.baseUrl); + assert.match(url, /\/chat\/completions$/); +}); + +test("buildHeaders: claude-native requests carry anthropic-version", () => { + const executor = new GithubExecutor(); + const headers = executor.buildHeaders({ accessToken: "tok" }, true, null, "claude-opus-4.8"); + assert.equal(headers["anthropic-version"], "2023-06-01"); +}); + +test("buildHeaders: non-claude requests do not carry anthropic-version", () => { + const executor = new GithubExecutor(); + const headers = executor.buildHeaders({ accessToken: "tok" }, true, null, "gpt-4o-mini"); + assert.equal(headers["anthropic-version"], undefined); +}); + +test("transformRequest: claude-native path preserves native tool_use/tool_result content blocks", () => { + const executor = new GithubExecutor(); + const body = { + model: "claude-opus-4.8", + system: "you are a helpful assistant", + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "search", input: { q: "hi" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }], + }, + ], + }; + const result = executor.transformRequest("claude-opus-4.8", body, true, {}); + // Pre-port: sanitizeChatCompletionsMessage flattened every non-text/image_url + // part to {type:"text", text: ...}, destroying the tool_use/tool_result blocks + // Anthropic's native /v1/messages endpoint actually needs. + assert.equal((result.messages[0].content[0] as { type: string }).type, "tool_use"); + assert.equal((result.messages[1].content[0] as { type: string }).type, "tool_result"); +}); + +test("transformRequest: claude-native path keeps a trailing assistant message (prefill)", () => { + const executor = new GithubExecutor(); + const body = { + model: "claude-opus-4.8", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Sure, here is" }, + ], + }; + const result = executor.transformRequest("claude-opus-4.8", body, true, {}); + // Pre-port: dropTrailingAssistantPrefill removed the trailing assistant turn + // because Copilot's /chat/completions rejects prefill — but the native + // /v1/messages endpoint is real Anthropic-compatible and supports it. + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].role, "assistant"); +}); + +test("transformRequest: non-claude (chat/completions) path still flattens tool_use content and drops prefill", () => { + const executor = new GithubExecutor(); + const body = { + model: "gpt-4o-mini", + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "search", input: {} }], + }, + { role: "user", content: "hi" }, + { role: "assistant", content: "trailing prefill" }, + ], + }; + const result = executor.transformRequest("gpt-4o-mini", body, true, {}); + assert.equal((result.messages[0].content[0] as { type: string }).type, "text"); + assert.equal(result.messages.length, 2, "trailing assistant message must still be dropped"); +}); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index a565e1a3e6..1e2a307a24 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -89,8 +89,15 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = assert.deepEqual(ids, [...GITHUB_COPILOT_MODEL_ALLOWLIST]); assert.equal(getModelTargetFormat("gh", "gpt-5.3-codex"), "openai-responses"); + // "claude-opus-4.6" is not a real Copilot model id (unlike claude-sonnet-4.6); + // it never appears in the registry, so its target format stays null. assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), null); - assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), null); + // Claude models route through Copilot's Anthropic-native /v1/messages shim + // (executors/github.ts) — the only endpoint that surfaces prompt-cache token + // counts for Claude and avoids a lossy tool_use/tool_result round-trip through + // the OpenAI shape. Port of decolua/9router#2608. + assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), "claude"); + assert.equal(getModelTargetFormat("gh", "claude-sonnet-4.6"), "claude"); assert.equal(getModelTargetFormat("gh", "gemini-3.5-flash"), null); assert.equal(getModelTargetFormat("gh", "kimi-k2.7-code"), null); assert.equal(ids.includes("gpt-4"), false); diff --git a/tests/unit/t27-github-copilot-response-format.test.ts b/tests/unit/t27-github-copilot-response-format.test.ts index 6b1d09548e..b7cf49e468 100644 --- a/tests/unit/t27-github-copilot-response-format.test.ts +++ b/tests/unit/t27-github-copilot-response-format.test.ts @@ -32,7 +32,13 @@ test("T27: Claude + response_format=json_object injects system instruction and s response_format: { type: "json_object" }, }; - const transformed = executor.transformRequest("claude-sonnet-4.5", request, false, {}); + // Use an unregistered claude-* id so getModelTargetFormat("gh", ...) resolves + // to null and this stays on the /chat/completions path this test targets. + // Registered claude-* ids (e.g. "claude-sonnet-4.5") now carry + // targetFormat:"claude" (native /v1/messages, which doesn't need this + // response_format-as-system-prompt workaround — port of decolua/9router#2608, + // see github-copilot-claude-native-messages.test.ts) and intentionally skip it. + const transformed = executor.transformRequest("claude-sonnet-4", request, false, {}); assert.equal(transformed.response_format, undefined); assert.equal(transformed.messages[0].role, "system");