fix: resolve bare fallback model providers

This commit is contained in:
Peter Steinberger
2026-04-24 18:16:17 +01:00
parent 1981622b92
commit a08a2e381f
4 changed files with 104 additions and 2 deletions

View File

@@ -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<string>();
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<string>();
const syntheticCatalogEntries = new Map<string, ModelCatalogEntry>();
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;

View File

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

View File

@@ -286,6 +286,44 @@ describe("handleModelsCommand", () => {
expect(result?.reply?.text).toContain("Switch: /model <provider/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<OpenClawConfig>;
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 <provider> as an alias", async () => {
const result = await handleModelsCommand(buildParams("/models list anthropic"), true);

View File

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