diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 34504d8242..afffd4429b 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -170,14 +170,17 @@ export function findModelName(aliasOrId: string, modelId: string): string { } export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null { - const models = PROVIDER_MODELS[aliasOrId]; + // Accept either the public alias ("cmd") or the raw provider id ("command-code"), + // mirroring getProviderModels (same pattern as #2798/#3870). + const alias = PROVIDER_ID_TO_ALIAS[aliasOrId] || aliasOrId; + const models = PROVIDER_MODELS[alias]; // Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna" - const prefix = aliasOrId + "/"; + const prefix = alias + "/"; const bareModelId = typeof modelId === "string" && modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId; - const found = models?.find((m) => m.id === bareModelId) || getGlobalModel(bareModelId); + const found = models?.find((m) => m.id === bareModelId); if (found?.targetFormat) return found.targetFormat; // #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by // the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported @@ -185,10 +188,17 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string // covers dynamically-synced ids that post-date the catalog (same spirit as the gh // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other // providers shipping *-pro ids keep their own endpoint semantics. - if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; - return null; + if (alias === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; + // Model-level targetFormat is provider-scoped: a catalog entry declares how THIS + // provider's endpoint serves the model. When the provider has its own catalog but + // the model is not in it, do NOT import the global entry's tag — it encodes the + // DECLARING provider's endpoint semantics (e.g. ghe-copilot tags gpt-5.6-* as + // openai-responses, which must not hijack command-code's chat-shaped + // /alpha/generate → 502 "Invalid prompt: messages must not be empty"). Providers + // with no catalog at all keep the global fallback as their only metadata source. + if (models) return null; + return getGlobalModel(bareModelId)?.targetFormat ?? null; } - export function getModelStripTypes(aliasOrId: string, modelId: string): string[] { const models = PROVIDER_MODELS[aliasOrId]; if (!models) diff --git a/tests/unit/provider-models-target-format-scoping.test.ts b/tests/unit/provider-models-target-format-scoping.test.ts new file mode 100644 index 0000000000..b899eb960a --- /dev/null +++ b/tests/unit/provider-models-target-format-scoping.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; +import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; + +// Regression: #8835 tagged gpt-5.6-* with targetFormat "openai-responses" in the +// ghe-copilot catalog. getModelTargetFormat falls back to getGlobalModel() when +// the provider's own catalog lacks the model id, importing the DECLARING +// provider's endpoint semantics into every other provider serving the same id. +// command-code's chat-shaped /alpha/generate executor then received a +// Responses-format body (input, not messages) and shipped `messages: []` +// upstream — upstream rejected with "Invalid prompt: messages must not be empty" +// (502). Model-level targetFormat is provider-scoped: it must not leak. +test("model-level targetFormat does not leak across provider catalogs", () => { + // command-code serves gpt-5.6-luna over its chat-shaped /alpha/generate endpoint + assert.equal(getModelTargetFormat("cmd", "gpt-5.6-luna"), null); + // raw provider id form behaves identically (alias resolution) + assert.equal(getModelTargetFormat("command-code", "gpt-5.6-luna"), null); + // the declaring provider (ghe-copilot) keeps its Responses routing + assert.equal(getModelTargetFormat("gh", "gpt-5.6-luna"), "openai-responses"); + assert.equal(getModelTargetFormat("ghe-copilot", "gpt-5.6-luna"), "openai-responses"); + // unrelated models are unaffected + assert.equal(getModelTargetFormat("cmd", "kimi-k2"), null); +}); + +test("chat completions to command-code gpt-5.6-luna resolve to chat format", () => { + const resolved = resolveChatCoreTargetFormat({ + provider: "command-code", + resolvedModel: "gpt-5.6-luna", + apiFormat: undefined, + sourceFormat: "openai", + customModelTargetFormat: undefined, + providerSpecificData: null, + }); + assert.equal(resolved.alias, "cmd"); + assert.equal(resolved.targetFormat, "openai"); +});