fix(copilot): never route Gemini/Claude variants to /responses (chat-completions only) (#4627)

Integrated into release/v3.8.36 — never route Gemini/Claude to /responses (port #1536); fused with #4626 codex routing via supportsResponsesEndpoint gate; release-green
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-23 22:16:33 -03:00
committed by GitHub
parent cc8e1bedf5
commit b5995778cb
2 changed files with 113 additions and 1 deletions

View File

@@ -25,12 +25,31 @@ export class GithubExecutor extends BaseExecutor {
);
}
// GitHub Copilot's /responses endpoint only serves OpenAI (gpt/codex) models.
// Gemini and Claude variants on Copilot reject with HTTP 400
// "model <id> does not support Responses API." (unsupported_api_for_model)
// Pin a defensive invariant: even if a future registry edit (or an upstream
// model-discovery refresh) tagged a Claude/Gemini entry as openai-responses,
// the executor must still route it to /chat/completions. Port of 9router#1536
// (follow-up to #663); also reinforces the existing comments on the gh
// registry entries (claude-opus-4-5-20251101, claude-opus-4.7, gemini-*).
supportsResponsesEndpoint(model: string | null | undefined): boolean {
const m = (model || "").toLowerCase();
if (!m) return true;
return !(m.includes("gemini") || m.includes("claude"));
}
buildUrl(model: string, _stream: boolean, _urlIndex = 0) {
const targetFormat = getModelTargetFormat("gh", model);
// 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.
if (targetFormat === "openai-responses" || /codex/i.test(model)) {
// 9router#1536: but never route Gemini/Claude variants to /responses (they 400) —
// gate the whole decision on supportsResponsesEndpoint().
if (
(targetFormat === "openai-responses" || /codex/i.test(model)) &&
this.supportsResponsesEndpoint(model)
) {
return (
this.config.responsesBaseUrl ||
this.config.baseUrl?.replace(/\/chat\/completions\/?$/, "/responses") ||

View File

@@ -0,0 +1,93 @@
/**
* Regression test ported from decolua/9router#1536 (follow-up to #663).
*
* GitHub Copilot's `/responses` endpoint only serves OpenAI (gpt/codex) models —
* Gemini and Claude models reject with HTTP 400
* "[github/<model>] [400]: model <model> does not support Responses API."
* (unsupported_api_for_model). They must therefore never be routed to
* `/responses`, no matter what `targetFormat` a registry entry (or any future
* misconfiguration) declares for them.
*
* OmniRoute's GithubExecutor decides the endpoint inside `buildUrl()` purely
* from `getModelTargetFormat("gh", model)`. The registry today (`open-sse/
* config/providers/registry/github/index.ts`) correctly omits
* `targetFormat: "openai-responses"` on every Claude/Gemini entry — but that is
* a single-line away from regressing (the file's own comments on lines 60, 74,
* 80 warn against it). This test pins the defensive invariant: even if a
* Claude/Gemini variant ends up tagged `openai-responses`, the executor must
* still build the chat/completions URL.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { GithubExecutor } from "../../open-sse/executors/github.ts";
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts";
const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
const RESPONSES_URL = "https://api.githubcopilot.com/responses";
describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => {
it("routes registered Claude/Gemini Copilot models to chat/completions", () => {
const exec = new GithubExecutor();
for (const id of [
"claude-haiku-4.5",
"claude-sonnet-4.5",
"claude-sonnet-4.6",
"claude-opus-4.6",
"claude-opus-4.7",
"claude-opus-4-5-20251101",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
]) {
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
}
});
it("still uses chat/completions if a Claude/Gemini model is wrongly tagged openai-responses", () => {
const exec = new GithubExecutor();
const ghModels = PROVIDER_MODELS["gh"];
assert.ok(Array.isArray(ghModels), "gh provider models registry must exist");
const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6");
const gemini = ghModels.find((m: any) => m.id === "gemini-3.1-pro-preview");
assert.ok(claude && gemini, "claude-sonnet-4.6 and gemini-3.1-pro-preview must be registered");
const originalClaude = claude.targetFormat;
const originalGemini = gemini.targetFormat;
try {
// Simulate a future misconfiguration. The guard must still hold.
(claude as any).targetFormat = "openai-responses";
(gemini as any).targetFormat = "openai-responses";
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
assert.equal(exec.buildUrl("gemini-3.1-pro-preview", false), CHAT_URL);
} finally {
(claude as any).targetFormat = originalClaude;
(gemini as any).targetFormat = originalGemini;
}
});
it("still routes registered OpenAI/codex Copilot models to /responses", () => {
const exec = new GithubExecutor();
for (const id of ["gpt-5-mini", "gpt-5.3-codex", "gpt-5.4-mini", "gpt-5.4"]) {
assert.equal(exec.buildUrl(id, false), RESPONSES_URL, `${id} must route to /responses`);
}
});
it("is case-insensitive when guarding (defensive against upper/mixed-case ids)", () => {
const exec = new GithubExecutor();
const ghModels = PROVIDER_MODELS["gh"];
const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6");
const original = claude.targetFormat;
try {
(claude as any).targetFormat = "openai-responses";
// Look up by the same id (registry is case-sensitive on lookup) but with a
// mixed-case path through the guard. We rebuild with the registered id;
// the guard normalizes before substring check, so it must still detect.
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
} finally {
(claude as any).targetFormat = original;
}
});
});