From a08a2e381f2bfd63cf0d1d81b6aeebd23b7741c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 18:16:17 +0100 Subject: [PATCH] fix: resolve bare fallback model providers --- src/agents/model-selection-shared.ts | 53 +++++++++++++++++++- src/agents/model-selection.ts | 4 ++ src/auto-reply/reply/commands-models.test.ts | 38 ++++++++++++++ src/auto-reply/reply/commands-models.ts | 11 +++- 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 5d88733f735..6a05f223bf8 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -114,6 +114,48 @@ export function inferUniqueProviderFromConfiguredModels(params: { return providers.values().next().value; } +export function inferUniqueProviderFromCatalog(params: { + catalog: readonly ModelCatalogEntry[]; + model: string; +}): string | undefined { + const model = params.model.trim(); + if (!model) { + return undefined; + } + const normalized = normalizeLowercaseStringOrEmpty(model); + const providers = new Set(); + for (const entry of params.catalog) { + const entryId = entry.id.trim(); + if (!entryId) { + continue; + } + if (entryId !== model && normalizeLowercaseStringOrEmpty(entryId) !== normalized) { + continue; + } + const provider = normalizeProviderId(entry.provider); + if (provider) { + providers.add(provider); + } + if (providers.size > 1) { + return undefined; + } + } + return providers.size === 1 ? providers.values().next().value : undefined; +} + +export function resolveBareModelDefaultProvider(params: { + cfg: OpenClawConfig; + catalog: readonly ModelCatalogEntry[]; + model: string; + defaultProvider: string; +}): string { + return ( + inferUniqueProviderFromConfiguredModels({ cfg: params.cfg, model: params.model }) ?? + inferUniqueProviderFromCatalog({ catalog: params.catalog, model: params.model }) ?? + params.defaultProvider + ); +} + function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free"); } @@ -496,10 +538,19 @@ export function buildAllowedModelSetWithFallbacks(params: { const allowedKeys = new Set(); const syntheticCatalogEntries = new Map(); const addAllowedModelRef = (raw: string) => { + const trimmed = raw.trim(); + const defaultProvider = !trimmed.includes("/") + ? resolveBareModelDefaultProvider({ + cfg: params.cfg, + catalog, + model: trimmed, + defaultProvider: params.defaultProvider, + }) + : params.defaultProvider; const parsed = parseModelRefWithCompatAlias({ cfg: params.cfg, raw, - defaultProvider: params.defaultProvider, + defaultProvider, }); if (!parsed) { return; diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 7de092b2854..368f58e7427 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -29,8 +29,10 @@ import { buildConfiguredModelCatalog, buildModelAliasIndex, getModelRefStatusWithFallbackModels, + inferUniqueProviderFromCatalog, inferUniqueProviderFromConfiguredModels, normalizeModelSelection, + resolveBareModelDefaultProvider, resolveAllowedModelRefFromAliasIndex, resolveAllowlistModelKey as resolveAllowlistModelKeyFromShared, resolveConfiguredModelRef, @@ -60,6 +62,7 @@ export { findNormalizedProviderKey, findNormalizedProviderValue, inferUniqueProviderFromConfiguredModels, + inferUniqueProviderFromCatalog, legacyModelKey, modelKey, normalizeModelRef, @@ -67,6 +70,7 @@ export { normalizeProviderId, normalizeProviderIdForAuth, parseModelRef, + resolveBareModelDefaultProvider, resolveConfiguredModelRef, resolveHooksGmailModel, resolveModelRefFromString, diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index b36a58507e8..a62c2b784fd 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -286,6 +286,44 @@ describe("handleModelsCommand", () => { expect(result?.reply?.text).toContain("Switch: /model "); }); + it("does not list bare fallback models under the default provider when catalog ownership is unique", async () => { + modelCatalogMocks.loadModelCatalog.mockResolvedValue([ + { provider: "openai-codex", id: "gpt-5.4", name: "GPT-5.4" }, + { provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + ]); + const cfg = { + agents: { + defaults: { + model: { + primary: "openai-codex/gpt-5.4", + fallbacks: ["deepseek-v4-flash", "deepseek-v4-pro"], + }, + models: { + "openai-codex/gpt-5.4": {}, + }, + }, + }, + } satisfies Partial; + + const defaultProviderResult = await handleModelsCommand( + buildParams("/models openai-codex", cfg), + true, + ); + const deepseekResult = await handleModelsCommand(buildParams("/models deepseek", cfg), true); + + expect(defaultProviderResult?.reply?.text).toContain( + "Models (openai-codex) — showing 1-1 of 1 (page 1/1)", + ); + expect(defaultProviderResult?.reply?.text).toContain("- openai-codex/gpt-5.4"); + expect(defaultProviderResult?.reply?.text).not.toContain("openai-codex/deepseek-v4"); + expect(deepseekResult?.reply?.text).toContain( + "Models (deepseek) — showing 1-2 of 2 (page 1/1)", + ); + expect(deepseekResult?.reply?.text).toContain("- deepseek/deepseek-v4-flash"); + expect(deepseekResult?.reply?.text).toContain("- deepseek/deepseek-v4-pro"); + }); + it("keeps /models list as an alias", async () => { const result = await handleModelsCommand(buildParams("/models list anthropic"), true); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 22df0c22591..865a30373b1 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -5,6 +5,7 @@ import { buildAllowedModelSet, buildModelAliasIndex, normalizeProviderId, + resolveBareModelDefaultProvider, resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; @@ -94,9 +95,17 @@ export async function buildModelsProviderData( if (!trimmed) { return; } + const defaultProvider = !trimmed.includes("/") + ? resolveBareModelDefaultProvider({ + cfg, + catalog, + model: trimmed, + defaultProvider: resolvedDefault.provider, + }) + : resolvedDefault.provider; const resolved = resolveModelRefFromString({ raw: trimmed, - defaultProvider: resolvedDefault.provider, + defaultProvider, aliasIndex, }); if (!resolved) {