From 231c1fa37a2b6c9584fac4af83b95573bd5069b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 18:54:12 +0000 Subject: [PATCH] fix(models): land #38947 from @davidemanuelDEV Co-authored-by: davidemanuelDEV --- CHANGELOG.md | 1 + src/agents/model-selection.test.ts | 77 ++++++++++++++++++++++++++++++ src/agents/model-selection.ts | 22 +++++++++ 3 files changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1934899a0de..1cd8888ac45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,6 +248,7 @@ Docs: https://docs.openclaw.ai - Outbound/message target normalization: ignore empty legacy `to`/`channelId` fields when explicit `target` is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo. - Models/auth token prompts: guard cancelled manual token prompts so `Symbol(clack:cancel)` values cannot be persisted into auth profiles; adds regression coverage for cancelled `models auth paste-token`. (#38951) Thanks @MumuTW. - Gateway/loopback announce URLs: treat `http://` and `https://` aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo. +- Models/default provider fallback: when the hardcoded default provider is removed from `models.providers`, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV. ## 2026.3.2 diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 49937912310..633c76f4d6f 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -481,6 +481,83 @@ describe("model-selection", () => { }); expect(result).toEqual({ provider: "openai", model: "gpt-4" }); }); + + it("should prefer configured custom provider when default provider is not in models.providers", () => { + const cfg: Partial = { + models: { + providers: { + n1n: { + baseUrl: "https://n1n.example.com", + models: [ + { + id: "gpt-5.4", + name: "GPT 5.4", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }, + }, + }, + }; + const result = resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); + expect(result).toEqual({ provider: "n1n", model: "gpt-5.4" }); + }); + + it("should keep default provider when it is in models.providers", () => { + const cfg: Partial = { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + models: [ + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 4096, + }, + ], + }, + }, + }, + }; + const result = resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); + expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); + + it("should fall back to hardcoded default when no custom providers have models", () => { + const cfg: Partial = { + models: { + providers: { + "empty-provider": { + baseUrl: "https://example.com", + models: [], + }, + }, + }, + }; + const result = resolveConfiguredModelRef({ + cfg: cfg as OpenClawConfig, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }); + expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + }); }); describe("resolveThinkingDefault", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 1489c9ee962..3f5ba1bbf37 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -317,6 +317,28 @@ export function resolveConfiguredModelRef(params: { return resolved.ref; } } + // Before falling back to the hardcoded default, check if the default provider + // is actually available. If it isn't but other providers are configured, prefer + // the first configured provider's first model to avoid reporting a stale default + // from a removed provider. (See #38880) + const configuredProviders = params.cfg.models?.providers; + if (configuredProviders && typeof configuredProviders === "object") { + const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]); + if (!hasDefaultProvider) { + const availableProvider = Object.entries(configuredProviders).find( + ([, providerCfg]) => + providerCfg && + Array.isArray(providerCfg.models) && + providerCfg.models.length > 0 && + providerCfg.models[0]?.id, + ); + if (availableProvider) { + const [providerName, providerCfg] = availableProvider; + const firstModel = providerCfg.models[0]; + return { provider: providerName, model: firstModel.id }; + } + } + } return { provider: params.defaultProvider, model: params.defaultModel }; }