Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(providers): resolve untagged claude-* ids on GitHub/GHE Copilot to native targetFormat (#14575)
11 changes: 11 additions & 0 deletions open-sse/config/providerModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions tests/unit/executor-github-prefill-sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
33 changes: 21 additions & 12 deletions tests/unit/executor-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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");
Expand Down
71 changes: 71 additions & 0 deletions tests/unit/github-copilot-claude-untagged-model-14575.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
24 changes: 11 additions & 13 deletions tests/unit/t27-github-copilot-response-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading