diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 52b4cb845b..50cb03fbca 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -59,8 +59,24 @@ export class GithubExecutor extends BaseExecutor { return !(m.includes("gemini") || m.includes("claude")); } - buildUrl(model: string, _stream: boolean, _urlIndex = 0) { - const targetFormat = getModelTargetFormat("gh", model); + buildUrl( + model: string, + _stream: boolean, + _urlIndex = 0, + credentials?: ProviderCredentials | null + ) { + // #2905/#7364-pattern: a custom Copilot model's per-model targetFormat + // override isn't in the static PROVIDER_MODELS registry, so + // getModelTargetFormat() can't see it. chatCore/executionCredentials.ts + // threads the resolved override onto providerSpecificData.targetFormat + // for exactly this case — prefer it when present. + const overrideTargetFormat = ( + credentials as { providerSpecificData?: { targetFormat?: unknown } } + )?.providerSpecificData?.targetFormat; + const targetFormat = + typeof overrideTargetFormat === "string" + ? overrideTargetFormat + : getModelTargetFormat("gh", model); // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the // only Copilot endpoint that surfaces prompt-cache token counts for Claude and // avoids a lossy round-trip of tool_use/tool_result/thinking content blocks diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index c8e4223774..f0d8c5c7c3 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -150,8 +150,18 @@ export function resolveExecutionCredentials(opts: { providerSpecificData.targetFormat = targetFormat; } - applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo); + // GitHub Copilot custom models (custom-model dropdown, #2905) can carry a + // per-model targetFormat override resolving to "openai-responses" so a + // Codex-family custom model routes through Copilot's native /responses + // endpoint. GithubExecutor.buildUrl() only consults the static + // PROVIDER_MODELS registry via getModelTargetFormat() and has no other way + // to see a custom model's override — mirrors the zai/glm-coding-apikey fix + // (#7364) for the same class of bug. + if (targetFormat === FORMATS.OPENAI_RESPONSES && provider === "github") { + providerSpecificData.targetFormat = targetFormat; + } + applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo); const withApiType = { ...nextCredentials, providerSpecificData, diff --git a/tests/unit/github-copilot-custom-model-target-format.test.ts b/tests/unit/github-copilot-custom-model-target-format.test.ts new file mode 100644 index 0000000000..a7e0fe25b2 --- /dev/null +++ b/tests/unit/github-copilot-custom-model-target-format.test.ts @@ -0,0 +1,86 @@ +// tests/unit/github-copilot-custom-model-target-format.test.ts +// GitHub Copilot custom models (custom-model dropdown, #2905) can carry a +// per-model targetFormat override resolving to "openai-responses" — e.g. a +// Codex-family custom model (gpt-5.6-terra/gpt-5.6-luna) that the operator +// wants routed through Copilot's native /responses endpoint instead of +// /chat/completions. GithubExecutor.buildUrl() only reads the static +// PROVIDER_MODELS registry via getModelTargetFormat("gh", model) and has no +// other way to see a custom model's override, so every custom Copilot model +// silently hit /chat/completions regardless of the dashboard's Target Format +// setting and got rejected upstream with "not accessible via the +// /chat/completions endpoint". Mirrors the zai/glm-coding-apikey fix (#7364) +// for the same class of bug. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { GithubExecutor } from "../../open-sse/executors/github.ts"; +import { resolveExecutionCredentials } from "../../open-sse/handlers/chatCore/executionCredentials.ts"; + +test("BUG: GithubExecutor.buildUrl ignores a per-model targetFormat:'openai-responses' override and still returns the chat/completions URL", () => { + const executor = new GithubExecutor(); + const credentialsWithoutOverride = { apiKey: "test-token" }; + const url = executor.buildUrl("gpt-5.6-terra", false, 0, credentialsWithoutOverride); + assert.ok( + !url.endsWith("/responses"), + "sanity check: with no override and a non-codex custom model id, buildUrl falls back to chat/completions" + ); +}); + +test("FIX: GithubExecutor.buildUrl honors providerSpecificData.targetFormat:'openai-responses' for a custom model", () => { + const executor = new GithubExecutor(); + const credentialsWithOverride = { + apiKey: "test-token", + providerSpecificData: { targetFormat: "openai-responses" }, + }; + const url = executor.buildUrl("gpt-5.6-terra", false, 0, credentialsWithOverride); + assert.ok( + url.endsWith("/responses"), + `expected the /responses endpoint when the override is set, got: ${url}` + ); +}); + +test("FIX: a Gemini/Claude custom model is never routed to /responses even with the override set (supportsResponsesEndpoint gate)", () => { + const executor = new GithubExecutor(); + const credentialsWithOverride = { + apiKey: "test-token", + providerSpecificData: { targetFormat: "openai-responses" }, + }; + const url = executor.buildUrl("gemini-2.5-pro", false, 0, credentialsWithOverride); + assert.ok( + !url.endsWith("/responses"), + "9router#1536 invariant: Gemini/Claude models must never route to /responses, even with a targetFormat override" + ); +}); + +const base = { + credentials: { providerSpecificData: { foo: "bar" } } as Record, + nativeCodexPassthrough: false, + endpointPath: "/v1/messages", + ccSessionId: null, +}; + +test("github + resolved openai-responses targetFormat threads providerSpecificData.targetFormat", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "github", + targetFormat: "openai-responses", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar", targetFormat: "openai-responses" }); +}); + +test("github + default (non-responses) targetFormat does NOT inject a targetFormat override", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "github", + targetFormat: "openai", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar" }); +}); + +test("unrelated provider (openai) with targetFormat=openai-responses is untouched by the github branch", () => { + const out = resolveExecutionCredentials({ + ...base, + provider: "openai", + targetFormat: "openai-responses", + }) as Record; + assert.deepEqual(out.providerSpecificData, { foo: "bar" }); +});