diff --git a/src/agents/model-catalog-order.ts b/src/agents/model-catalog-order.ts index 3a547f7256fe..0667bfb9bf9c 100644 --- a/src/agents/model-catalog-order.ts +++ b/src/agents/model-catalog-order.ts @@ -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(); const nextOrderByProvider = new Map(); @@ -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); diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index ed2cc0c7ccae..0d1602bdcc69 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -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([ diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 1f0cafdcdf85..9d140d97cfc9 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -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); }