From 2f14094ea71311279156904fcdaa07376fdf424a Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:44:06 -0300 Subject: [PATCH 1/2] fix(providers): resolve untagged claude-* ids on GitHub/GHE Copilot to native targetFormat (#14575) --- ...575-copilot-claude-untagged-tool-choice.md | 1 + open-sse/config/providerModels.ts | 11 +++ tests/unit/executor-github.test.ts | 33 +++++---- ...opilot-claude-untagged-model-14575.test.ts | 71 +++++++++++++++++++ ...t27-github-copilot-response-format.test.ts | 24 +++---- 5 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 changelog.d/fixes/14575-copilot-claude-untagged-tool-choice.md create mode 100644 tests/unit/github-copilot-claude-untagged-model-14575.test.ts diff --git a/changelog.d/fixes/14575-copilot-claude-untagged-tool-choice.md b/changelog.d/fixes/14575-copilot-claude-untagged-tool-choice.md new file mode 100644 index 000000000000..549b2318e9d9 --- /dev/null +++ b/changelog.d/fixes/14575-copilot-claude-untagged-tool-choice.md @@ -0,0 +1 @@ +- fix(providers): resolve untagged claude-* ids on GitHub/GHE Copilot to native targetFormat (#14575) diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 41d28e8e20e2..ae61f3c1e2aa 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -238,6 +238,17 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string // and OpenAI-shaped Mistral/Open-MaaS requests. Resource names retain enough publisher data to // route future dynamically-synced models without adding another pinned prefix here. if (alias === "vertex" || alias === "vp") return getVertexModelTargetFormat(bareModelId); + // #14575: GitHub/GHE Copilot's own executors (github.ts, ghe-copilot.ts) route ANY + // claude-named model to Copilot's Anthropic-native /v1/messages endpoint via an + // unconditional /claude/i name match, regardless of curated-catalog coverage. When + // Copilot ships a new Claude id before the catalog is updated (e.g. claude-opus-5.5), + // the curated lookup above misses and previously fell through to the provider's base + // "openai" format, leaving the request body OpenAI-shaped while it is dispatched to the + // Anthropic-native endpoint — which rejects it (tool_choice/tools shape mismatch). + // Mirror the routing heuristic here so body translation stays consistent with where the + // executor actually sends the request (same pattern as the openai "-pro" heuristic above, + // #5842). + if ((alias === "gh" || alias === "ghe-copilot") && /claude/i.test(bareModelId)) return "claude"; // Model-level targetFormat is provider-scoped: a catalog entry declares how THIS // provider's endpoint serves the model — do NOT import another provider's tag. // #9994 scoped this for providers WITH a catalog; #10072 extends it to catalogless diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index 2c1aabc8a3f4..ce1bab74c66e 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -119,7 +119,7 @@ test("GithubExecutor.buildUrl routes gpt-5.6-sol/terra/luna to /responses (regre } }); -test("GithubExecutor.transformRequest injects JSON response instructions for Claude and strips reasoning fields", () => { +test("GithubExecutor.transformRequest strips reasoning fields for Claude, and leaves response_format untouched now that any claude-named id is native (#14575)", () => { const executor = new GithubExecutor(); const body = { response_format: { @@ -136,16 +136,22 @@ test("GithubExecutor.transformRequest injects JSON response instructions for Cla // Trailing user turn: dropTrailingAssistantPrefill (9router#2143) strips a // conversation that ends in "assistant", which would otherwise remove the very // message this test inspects below. Keep the array ending in "user" so this test - // stays focused on response_format injection + reasoning-field stripping. + // stays focused on reasoning-field stripping. { role: "user", content: "thanks" }, ], }; + // #14575: getModelTargetFormat("gh", ...) now resolves "claude" for ANY claude-named + // id (registered or not) — mirroring buildUrl()'s own unconditional /claude/i routing + // to the Anthropic-native /v1/messages endpoint. So "claude-sonnet-4" is native now + // (see github-copilot-claude-native-messages.test.ts) and the response_format-as- + // system-prompt workaround (applyChatCompletionsOnlyQuirks, gated on !isClaudeNative) + // is unreachable for it — response_format passes through untouched. Reasoning-field + // stripping happens unconditionally above that gate, so it still applies. const result = executor.transformRequest("claude-sonnet-4", body, true, {}); - assert.equal(result.response_format, undefined); - assert.equal(result.messages[0].role, "system"); - assert.match(result.messages[0].content, /Respond only with valid JSON/); + assert.deepEqual(result.response_format, { type: "json_object" }); + assert.equal(result.messages[0].role, "user"); assert.equal(result.messages[2].reasoning_text, undefined); assert.equal(result.messages[2].reasoning_content, undefined); }); @@ -182,13 +188,16 @@ test("GithubExecutor.transformRequest sanitizes Anthropic-shape content parts (t ], }; - // 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, {}); + // Use a non-claude-named id so getModelTargetFormat("gh", ...) does NOT resolve + // "claude" and this stays on the /chat/completions path this test targets. #14575 + // made getModelTargetFormat resolve "claude" for ANY claude-named id (registered + // or not, mirroring buildUrl()'s own unconditional /claude/i routing to the + // Anthropic-native /v1/messages endpoint), so a claude-named id — even an + // unregistered one like the former "claude-sonnet-4" here — is now native and + // intentionally skips this /chat/completions-only sanitization (see + // github-copilot-claude-native-messages.test.ts). The sanitization itself still + // matters for any other model whose client sends Anthropic-shape content parts. + const result = executor.transformRequest("gpt-4o", 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-untagged-model-14575.test.ts b/tests/unit/github-copilot-claude-untagged-model-14575.test.ts new file mode 100644 index 000000000000..d89a27756a0d --- /dev/null +++ b/tests/unit/github-copilot-claude-untagged-model-14575.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { GithubExecutor } from "../../open-sse/executors/github.ts"; +import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; +import { githubProvider } from "../../open-sse/config/providers/registry/github/index.ts"; +import { gheCopilotProvider } from "../../open-sse/config/providers/registry/ghe-copilot/index.ts"; +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; + +const UNREGISTERED_CLAUDE_MODEL = "claude-opus-5.5"; + +test("sanity: claude-opus-5.5 is not (yet) in the curated github registry", () => { + const entry = githubProvider.models.find((m) => m.id === UNREGISTERED_CLAUDE_MODEL); + assert.equal(entry, undefined); +}); + +test("BUG #14575: chatCore's resolved targetFormat disagrees with GithubExecutor.buildUrl()'s endpoint for an untagged claude-* model", () => { + const { targetFormat } = resolveChatCoreTargetFormat({ + provider: "github", + resolvedModel: UNREGISTERED_CLAUDE_MODEL, + apiFormat: undefined, + sourceFormat: "openai", + customModelTargetFormat: undefined, + providerSpecificData: null, + }); + + const executor = new GithubExecutor(); + const url = executor.buildUrl(UNREGISTERED_CLAUDE_MODEL, false, 0, { apiKey: "test-token" }); + const routedToAnthropicNative = url === githubProvider.messagesUrl; + + assert.equal(routedToAnthropicNative, true); + assert.equal( + targetFormat, + "claude", + "BUG #14575: chatCore resolved a non-Claude targetFormat " + + JSON.stringify(targetFormat) + + " for a model that GithubExecutor.buildUrl() routes to the Anthropic-native /v1/messages endpoint" + ); +}); + +test("control: a CURATED claude-* model (claude-opus-5) has matching targetFormat + endpoint (no mismatch)", () => { + const { targetFormat } = resolveChatCoreTargetFormat({ + provider: "github", + resolvedModel: "claude-opus-5", + apiFormat: undefined, + sourceFormat: "openai", + customModelTargetFormat: undefined, + providerSpecificData: null, + }); + const executor = new GithubExecutor(); + const url = executor.buildUrl("claude-opus-5", false, 0, { apiKey: "test-token" }); + assert.equal(targetFormat, "claude"); + assert.equal(url, githubProvider.messagesUrl); +}); + +test("BUG #14575 (ghe-copilot parity): an untagged claude-* id resolves targetFormat 'claude' for the ghe-copilot alias too", () => { + const entry = gheCopilotProvider.models.find((m) => m.id === UNREGISTERED_CLAUDE_MODEL); + assert.equal( + entry, + undefined, + "sanity: claude-opus-5.5 must not be in the curated ghe-copilot registry" + ); + + const targetFormat = getModelTargetFormat("ghe-copilot", UNREGISTERED_CLAUDE_MODEL); + assert.equal( + targetFormat, + "claude", + "ghe-copilot executor's own name-regex routes any claude-named model to the Anthropic-native endpoint " + + "(open-sse/executors/ghe-copilot.ts:117); getModelTargetFormat must resolve the same targetFormat" + ); +}); diff --git a/tests/unit/t27-github-copilot-response-format.test.ts b/tests/unit/t27-github-copilot-response-format.test.ts index b7cf49e46863..2de153013482 100644 --- a/tests/unit/t27-github-copilot-response-format.test.ts +++ b/tests/unit/t27-github-copilot-response-format.test.ts @@ -25,27 +25,25 @@ test.after(() => { globalThis.fetch = originalFetch; }); -test("T27: Claude + response_format=json_object injects system instruction and strips response_format field", () => { +test("T27: an untagged claude-* id also skips the response_format-as-system-prompt workaround (post-#14575)", () => { const executor = new GithubExecutor(); const request = { messages: [{ role: "user", content: "return json" }], response_format: { type: "json_object" }, }; - // 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. + // Prior to #14575, an unregistered claude-* id made getModelTargetFormat("gh", ...) + // return null, so this request stayed on the /chat/completions path and hit the + // response_format-as-system-prompt workaround below. #14575 gave getModelTargetFormat + // a claude-name heuristic for the gh/ghe-copilot aliases (mirroring buildUrl()'s own + // unconditional /claude/i routing to the Anthropic-native /v1/messages endpoint), so + // ANY claude-named id — registered or not — now resolves targetFormat "claude" and + // is treated as native, same as "claude-sonnet-4.5" in + // github-copilot-claude-native-messages.test.ts. The workaround below is therefore + // unreachable for the gh alias and response_format passes through untouched. const transformed = executor.transformRequest("claude-sonnet-4", request, false, {}); - assert.equal(transformed.response_format, undefined); - assert.equal(transformed.messages[0].role, "system"); - assert.match( - transformed.messages[0].content, - /Respond only with valid JSON\. Do not include any text/i - ); + assert.deepEqual(transformed.response_format, { type: "json_object" }); }); test("T27: non-Claude models keep response_format untouched", () => { From 4f16e580653ff4213df54ae18edd87358421f8b8 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:05:45 -0300 Subject: [PATCH 2/2] test(providers): move the Copilot prefill-drop test off the Claude id #14575 made native (#14575) Since #14575 every claude-* id on the gh alias resolves targetFormat "claude" (native /v1/messages, which honors assistant prefill), so the unregistered "claude-sonnet-4" this test used no longer reaches the /chat/completions path it covers. The drop still applies to every non-Claude model there, so the test now uses gpt-4.1 (targetFormat null). Same assertions; the GitHub executor + targetFormat suite is 392/392. --- .../executor-github-prefill-sanitize.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/executor-github-prefill-sanitize.test.ts b/tests/unit/executor-github-prefill-sanitize.test.ts index b19ddb1c471b..b619ae83915d 100644 --- a/tests/unit/executor-github-prefill-sanitize.test.ts +++ b/tests/unit/executor-github-prefill-sanitize.test.ts @@ -87,21 +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. + // Use a non-Claude id so getModelTargetFormat("gh", ...) resolves to null and + // this stays on the /chat/completions path this test targets. Since #14575 EVERY + // claude-* id on the gh alias (registered or not) carries targetFormat:"claude" + // (native /v1/messages, which supports prefill — port of decolua/9router#2608, + // see github-copilot-claude-native-messages.test.ts) and intentionally skips + // this drop, so a Claude id can no longer exercise it. const body = { - model: "claude-sonnet-4", + model: "gpt-4.1", messages: [ { role: "user", content: "Hi" }, { role: "assistant", content: "Here is the answer:" }, ], }; - const out = executor.transformRequest("claude-sonnet-4", body, false, {}); + const out = executor.transformRequest("gpt-4.1", body, false, {}); assert.equal(out.messages.length, 1); assert.equal(out.messages[0].role, "user");