fix(models): preserve chat-capable image model rows (#7004)

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Xiangzhe
2026-07-17 01:12:25 +08:00
committed by GitHub
parent 994f1c78a0
commit 7724b31c99
2 changed files with 53 additions and 14 deletions

View File

@@ -860,12 +860,17 @@ async function buildUnifiedModelsResponseCore(
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
// `/v1/models`) return image/diffusion models with no modality info,
// so `endpoints` below would default to ["chat"] and misrepresent
// them as chat-capable. Skip any synced model that is already a
// registered image model for this provider — getAllImageModels()
// below adds the correctly-typed `type: "image"` entry instead.
// them as chat-capable. Skip a registered image model only when its
// synced metadata does not explicitly advertise a chat endpoint.
// Multi-capability models may intentionally share an id between the
// chat and image catalogs; getAllImageModels() adds the image entry.
const explicitlySupportsChat = sm.supportedEndpoints?.some(
(endpoint) => endpoint === "chat" || endpoint === "responses"
);
if (
isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id)
!explicitlySupportsChat &&
(isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id))
) {
continue;
}

View File

@@ -11,10 +11,10 @@
// `type: "image"` by the imageRegistry loop — and catalogDedupe.ts keys on
// (id, type, subtype), so the two distinct-`type` entries both survived.
//
// Fix: skip a synced model in the chat-catalog loop when it is already a registered
// image model for that exact provider (open-sse/config/imageRegistry.ts
// isRegisteredImageModel()) — the imageRegistry loop still adds the correctly-typed
// `type: "image"` entry.
// Fix: skip an exact-provider registered image model from the chat-catalog loop only
// when synced metadata does not explicitly advertise `chat` or `responses`. The image
// registry loop still adds the correctly typed image entry, while multi-capability
// models keep both entries.
import test from "node:test";
import assert from "node:assert/strict";
@@ -38,6 +38,7 @@ async function resetStorage() {
}
test.beforeEach(async () => {
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
await resetStorage();
});
@@ -46,19 +47,19 @@ test.after(async () => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
async function seedHuggingFaceConnection() {
async function seedProviderConnection(provider: string) {
return providersDb.createProviderConnection({
provider: "huggingface",
provider,
authType: "apikey",
name: `huggingface-${Math.random().toString(16).slice(2, 8)}`,
apiKey: "hf-key",
name: `${provider}-${Math.random().toString(16).slice(2, 8)}`,
apiKey: `${provider}-key`,
isActive: true,
testStatus: "active",
});
}
test("#6457 image/diffusion model discovered via live sync is NOT listed as a chat model", async () => {
const connection = await seedHuggingFaceConnection();
const connection = await seedProviderConnection("huggingface");
// Simulate what HuggingFace's live `/v1/models` discovery persists for an
// image/diffusion model: no supportedEndpoints/modality info at all — the exact
@@ -100,3 +101,36 @@ test("#6457 image/diffusion model discovered via live sync is NOT listed as a ch
assert.equal(entry.type, undefined, "the real chat model must not carry a non-chat type");
}
});
test("registered image model with explicit chat endpoints keeps both catalog entries", async () => {
const connection = await seedProviderConnection("codex");
await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [
{
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
supportedEndpoints: ["responses"],
},
]);
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models?prefix=alias")
);
assert.equal(response.status, 200);
const body = (await response.json()) as {
data: Array<{ id: string; type?: string; supported_endpoints?: string[] }>;
};
const entries = body.data.filter((model) => model.id.endsWith("/gpt-5.6-sol"));
assert.ok(
entries.some(
(model) => model.type !== "image" && model.supported_endpoints?.includes("responses")
),
"explicit responses support must keep the synced chat entry"
);
assert.ok(
entries.some((model) => model.type === "image"),
"the registered image entry must remain available under the same model id"
);
});