fix: keep provider ranking in startup catalogs (#115425)

* fix(models): rank static registry catalogs

* fix(models): preserve unranked catalog order
This commit is contained in:
Peter Steinberger
2026-07-28 18:37:02 -04:00
committed by GitHub
parent ec56892b74
commit 661efb67a3
3 changed files with 99 additions and 11 deletions
+4
View File
@@ -8,6 +8,7 @@ import type { ModelCatalogEntry } from "./model-catalog.types.js";
export function assignProviderModelOrder(
entries: readonly ModelCatalogEntry[],
existingEntries: readonly ModelCatalogEntry[] = [],
options: { appendUnknown?: boolean } = {},
): ModelCatalogEntry[] {
const orderByModel = new Map<string, number>();
const nextOrderByProvider = new Map<string, number>();
@@ -30,6 +31,9 @@ export function assignProviderModelOrder(
if (existingOrder !== undefined) {
return { ...entry, providerOrder: existingOrder };
}
if (options.appendUnknown === false) {
return entry;
}
const providerOrder = nextOrderByProvider.get(provider) ?? 0;
nextOrderByProvider.set(provider, providerOrder + 1);
orderByModel.set(key, providerOrder);
+78 -1
View File
@@ -112,6 +112,18 @@ describe("prepared model catalog builder", () => {
expect(snapshot.routeVariants).toEqual(snapshot.entries);
});
it("keeps unranked registry rows in deterministic model-id order", async () => {
const snapshot = await build({
entries: [
{ id: "model-b", name: "Model B", provider: "demo" },
{ id: "model-a", name: "Model A", provider: "demo" },
],
});
expect(snapshot.entries.map((entry) => entry.id)).toEqual(["model-a", "model-b"]);
expect(snapshot.entries.every((entry) => entry.providerOrder === undefined)).toBe(true);
});
it("keeps account-denied runtime models out of the prepared catalog", async () => {
const config: OpenClawConfig = { plugins: { enabled: false } };
const runtimeManifest = providerManifestSnapshot({
@@ -153,6 +165,30 @@ describe("prepared model catalog builder", () => {
]);
});
it("ranks runtime registry rows from the manifest without augmentation", async () => {
const snapshot = await build({
entries: [
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", provider: "openai" },
],
metadataSnapshot: providerManifestSnapshot({
provider: "openai",
discovery: "runtime",
modelIds: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4"],
}),
includeProviderPluginAugmentation: false,
});
expect(snapshot.entries.map((entry) => entry.id)).toEqual([
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.4",
]);
});
it("canonicalizes manifest-owned provider aliases in registry rows", async () => {
const snapshot = await build({
entries: [
@@ -230,6 +266,47 @@ describe("prepared model catalog builder", () => {
]);
});
it("keeps manifest rank for configured runtime models absent from the registry", async () => {
mocks.augmentModelCatalogWithProviderPlugins.mockResolvedValueOnce([
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai" },
]);
const snapshot = await build({
config: {
plugins: { enabled: false },
models: {
providers: {
openai: {
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
models: [
{
id: "gpt-5.6-sol",
name: "Configured GPT-5.6 Sol",
contextWindow: 1_050_000,
maxTokens: 128_000,
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
},
entries: [{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }],
metadataSnapshot: providerManifestSnapshot({
provider: "openai",
discovery: "runtime",
modelIds: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4"],
}),
readOnly: false,
});
expect(snapshot.entries.map((entry) => entry.id)).toEqual(["gpt-5.6-sol", "gpt-5.4"]);
});
it("preserves explicitly configured runtime-provider models", async () => {
mocks.augmentModelCatalogWithProviderPlugins.mockResolvedValueOnce([
{
@@ -276,8 +353,8 @@ describe("prepared model catalog builder", () => {
});
expect(snapshot.entries.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
"openai/gpt-5.4",
"openai/gpt-5.5",
"openai/gpt-5.4",
]);
expect(snapshot.routeVariants).toEqual(
expect.arrayContaining([
+17 -10
View File
@@ -471,6 +471,11 @@ export async function buildPreparedModelCatalogSnapshot(
const { buildShouldSuppressBuiltInModel } = await loadModelSuppression();
logStage("catalog-deps-ready");
const entries = params.modelRegistry.getAll() as DiscoveredModel[];
const declaredManifestModels = loadManifestModelCatalog({
config: cfg,
env,
metadataSnapshot: manifestMetadataSnapshot,
});
logStage("registry-read", `entries=${entries.length}`);
const shouldSuppressBuiltInModel = buildShouldSuppressBuiltInModel({ config: cfg });
@@ -525,8 +530,15 @@ export async function buildPreparedModelCatalogSnapshot(
compat,
} satisfies ModelCatalogEntry;
models.push(model);
mergeCatalogRouteVariants(routeVariants, [model]);
}
// Gateway startup may publish registry rows without runtime augmentation.
// Rank them here so both static startup and later live enrichment preserve
// provider-owned order instead of falling back to model-id sorting.
const orderedRegistryModels = assignProviderModelOrder(models, declaredManifestModels, {
appendUnknown: false,
});
models.splice(0, models.length, ...orderedRegistryModels);
mergeCatalogRouteVariants(routeVariants, orderedRegistryModels);
const supplementalManifestPlan = planEffectiveModelCatalogRows({
registry: {
plugins: resolveEligibleManifestCatalogPlugins(manifestMetadataSnapshot, cfg),
@@ -544,11 +556,6 @@ export async function buildPreparedModelCatalogSnapshot(
);
// Runtime declarations describe possible models, not account entitlement.
// Only live registry or refreshed rows may publish those provider models.
const declaredManifestModels = loadManifestModelCatalog({
config: cfg,
env,
metadataSnapshot: manifestMetadataSnapshot,
});
const manifestModels = declaredManifestModels.filter((entry) =>
supplementalManifestKeys.has(catalogEntryDedupeKey(entry.provider, entry.id)),
);
@@ -636,10 +643,10 @@ export async function buildPreparedModelCatalogSnapshot(
}
// Manifest ranks are provider-owned policy. Live discovery enriches
// those rows and appends unknown models without replacing the ranking.
const orderedSupplemental = assignProviderModelOrder(
normalizedSupplemental,
declaredManifestModels,
);
const orderedSupplemental = assignProviderModelOrder(normalizedSupplemental, [
...declaredManifestModels,
...models,
]);
mergeCatalogRouteVariants(routeVariants, orderedSupplemental);
mergeCatalogEntries(models, orderedSupplemental);
}