diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 64f4bcee6e..bbb6bb3641 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -129,14 +129,6 @@ export class GithubExecutor extends BaseExecutor { }); } - if (!isClaudeNative && 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); } @@ -155,31 +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). Skipped for the native /v1/messages - // path — those content parts ARE the native Claude shape and must survive intact. - if (!isClaudeNative && 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). Skipped for the native - // /v1/messages path, which — like the real Anthropic API — supports prefill. - // Port of 9router#2143 (author: Manuel ). - if (!isClaudeNative && 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. @@ -192,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. @@ -260,25 +274,7 @@ export class GithubExecutor extends BaseExecutor { model?: string ): Record { const token = this.getCopilotToken(credentials) || credentials.accessToken; - - // 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. - let clientInitiator = clientHeaders?.["x-initiator"] || clientHeaders?.["X-Initiator"]; - if (!clientInitiator && clientHeaders) { - for (const key in clientHeaders) { - if (key.toLowerCase() === "x-initiator") { - clientInitiator = clientHeaders[key]; - break; - } - } - } - const initiator = - clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; + const initiator = this.resolveInitiatorHeader(clientHeaders); const headers: Record = { ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), @@ -297,6 +293,27 @@ export class GithubExecutor extends BaseExecutor { 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) { + if (key.toLowerCase() === "x-initiator") { + clientInitiator = clientHeaders[key]; + break; + } + } + } + return clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; + } + async refreshCopilotToken(githubAccessToken, log) { try { const response = await fetch("https://api.github.com/copilot_internal/v2/token", { 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/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");