fix(providers): scope model-level targetFormat to declaring provider catalog (#9994)

Model-level targetFormat is provider-scoped endpoint semantics: a catalog entry
declares how the DECLARING provider serves the model. getModelTargetFormat()
fell back to getGlobalModel() when the provider's own catalog lacked the model
id, importing another provider's tag into every provider serving that id.

catalog. command-code serves gpt-5.6-luna over its chat-shaped /alpha/generate
endpoint but inherited that tag, so chatCore translated the request to Responses
format (messages -> input). CommandCodeExecutor.buildCommandCodeBody reads
chat-format input.messages -> undefined -> [] -> upstream 502 "Invalid prompt:
messages must not be empty" (call log 1786341194167-774a5b).

Fix: resolve the provider alias (mirroring getProviderModels), only apply the
provider's OWN catalog entry's targetFormat, and skip the global fallback when
the provider has a catalog. Catalog-less providers keep the global fallback
unchanged; ghe-copilot's Responses routing (#8835) is preserved.

Regression test: tests/unit/provider-models-target-format-scoping.test.ts
(red before the fix, green after).
This commit is contained in:
Chewji
2026-08-11 20:04:24 +07:00
committed by GitHub
parent 4795825513
commit a367bf62f5
2 changed files with 54 additions and 6 deletions

View File

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

View File

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