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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-15 11:31:04 -03:00
parent 4524bbe3ed
commit 2bd77f37cb
5 changed files with 100 additions and 60 deletions

View File

@@ -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 <baslr@users.noreply.github.com>).
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 <baslr@users.noreply.github.com>).
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<string, string> {
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<string, string> = {
...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<string, string> | 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", {

View File

@@ -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`);
}
});

View File

@@ -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");

View File

@@ -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");

View File

@@ -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");