Skip to content

Commit d2320e4

Browse files
committed
fix(models): keep user model switches strict
1 parent 496a5eb commit d2320e4

18 files changed

Lines changed: 159 additions & 31 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
### Fixes
1818

1919
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
20+
- Models/fallbacks: treat user-selected session models as exact choices, so `/model ollama/...` and model-picker switches fail visibly when the selected provider is unreachable instead of answering from an unrelated configured fallback. Fixes #73023. Thanks @pavelyortho-cyber.
2021
- CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber.
2122
- CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020.
2223
- Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk.

docs/concepts/model-failover.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ For a normal text run, OpenClaw evaluates candidates in this order:
2424
Resolve the active session model and auth-profile preference.
2525
</Step>
2626
<Step title="Build candidate chain">
27-
Build the model candidate chain from the currently selected session model, then `agents.defaults.model.fallbacks` in order, ending with the configured primary when the run started from an override.
27+
Build the model candidate chain from the configured model or an auto-selected fallback model, then `agents.defaults.model.fallbacks` in order. Explicit user model selections are strict and do not silently fall back to a different model.
2828
</Step>
2929
<Step title="Try the current provider">
3030
Try the current provider with auth-profile rotation/cooldown rules.
@@ -207,7 +207,7 @@ If all profiles for a provider fail, OpenClaw moves to the next model in `agents
207207

208208
Overloaded and rate-limit errors are handled more aggressively than billing cooldowns. By default, OpenClaw allows one same-provider auth-profile retry, then switches to the next configured model fallback without waiting. Provider-busy signals such as `ModelNotReadyException` land in that overloaded bucket. Tune this with `auth.cooldowns.overloadedProfileRotations`, `auth.cooldowns.overloadedBackoffMs`, and `auth.cooldowns.rateLimitedProfileRotations`.
209209

210-
When a run starts with a model override (hooks or CLI), fallbacks still end at `agents.defaults.model.primary` after trying any configured fallbacks.
210+
When a run starts from the configured primary or an auto-selected fallback override, OpenClaw can walk the configured fallback chain. Explicit user selections (for example `/model ollama/qwen3.5:27b`, the model picker, or one-off CLI provider/model overrides) are strict: if that provider/model is unreachable or fails before producing a reply, OpenClaw reports the failure instead of answering from an unrelated fallback.
211211

212212
### Candidate chain rules
213213

@@ -264,6 +264,7 @@ That means fallback retries have to coordinate with live model switching:
264264

265265
- Only explicit user-driven model changes mark a pending live switch. That includes `/model`, `session_status(model=...)`, and `sessions.patch`.
266266
- System-driven model changes such as fallback rotation, heartbeat overrides, or compaction never mark a pending live switch on their own.
267+
- User-driven model overrides are treated as exact selections for fallback policy, so an unreachable selected provider surfaces as a failure instead of being masked by `agents.defaults.model.fallbacks`.
267268
- Before a fallback retry starts, the reply runner persists the selected fallback override fields to the session entry.
268269
- Auto fallback overrides remain selected on subsequent turns so OpenClaw does not probe a known-bad primary on every message. `/new`, `/reset`, and `sessions.reset` clear auto-sourced overrides and return the session to the configured default.
269270
- `/status` shows the selected model and, when fallback state differs, the active fallback model and reason.

docs/concepts/models.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ You can switch models for the current session without restarting:
156156
- If the agent is idle, the next run uses the new model right away.
157157
- If a run is already active, OpenClaw marks a live switch as pending and only restarts into the new model at a clean retry point.
158158
- If tool activity or reply output has already started, the pending switch can stay queued until a later retry opportunity or the next user turn.
159+
- A user-selected `/model` ref is strict for that session: if the selected provider/model is unreachable, the reply fails visibly instead of silently answering from `agents.defaults.model.fallbacks`.
159160
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
160161
</Accordion>
161162
<Accordion title="Ref parsing">

docs/providers/ollama.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ transport, but it does not start a chat-agent turn or load MCP/tool context. If
210210
this succeeds while normal agent replies fail, troubleshoot the model's agent
211211
prompt/tool capacity next.
212212

213+
When you switch a conversation with `/model ollama/<model>`, OpenClaw treats
214+
that as an exact user selection. If the configured Ollama `baseUrl` is
215+
unreachable, the next reply fails with the provider error instead of silently
216+
answering from another configured fallback model.
217+
213218
Live-verify the local text path, native stream path, and embeddings against
214219
local Ollama with:
215220

src/agents/agent-command.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,9 @@ async function agentCommandInternal(
696696
const hasStoredOverride = Boolean(
697697
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
698698
);
699+
let storedModelOverrideSource = hasStoredOverride
700+
? sessionEntry?.modelOverrideSource
701+
: undefined;
699702
const explicitProviderOverride =
700703
typeof opts.provider === "string"
701704
? normalizeExplicitOverrideInput(opts.provider, "provider")
@@ -910,7 +913,9 @@ async function agentCommandInternal(
910913
const effectiveFallbacksOverride = resolveEffectiveModelFallbacks({
911914
cfg,
912915
agentId: sessionAgentId,
913-
hasSessionModelOverride: Boolean(storedModelOverride),
916+
hasSessionModelOverride:
917+
hasExplicitRunOverride || Boolean(storedProviderOverride || storedModelOverride),
918+
modelOverrideSource: hasExplicitRunOverride ? "user" : storedModelOverrideSource,
914919
});
915920

916921
let fallbackAttemptIndex = 0;
@@ -1061,6 +1066,7 @@ async function agentCommandInternal(
10611066
err.provider !== previousProvider
10621067
) {
10631068
storedModelOverride = err.model;
1069+
storedModelOverrideSource = "user";
10641070
}
10651071
lifecycleEnded = false;
10661072
log.info(

src/agents/agent-scope.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,24 @@ describe("resolveAgentConfig", () => {
225225
cfg,
226226
agentId: "linus",
227227
hasSessionModelOverride: true,
228+
modelOverrideSource: "auto",
228229
}),
229230
).toEqual(["openai/gpt-5.4"]);
231+
expect(
232+
resolveEffectiveModelFallbacks({
233+
cfg,
234+
agentId: "linus",
235+
hasSessionModelOverride: true,
236+
modelOverrideSource: "user",
237+
}),
238+
).toEqual([]);
239+
expect(
240+
resolveEffectiveModelFallbacks({
241+
cfg,
242+
agentId: "linus",
243+
hasSessionModelOverride: true,
244+
}),
245+
).toEqual([]);
230246
expect(
231247
resolveEffectiveModelFallbacks({
232248
cfg: cfgNoOverride,
@@ -257,13 +273,15 @@ describe("resolveAgentConfig", () => {
257273
cfg: cfgInheritDefaults,
258274
agentId: "linus",
259275
hasSessionModelOverride: true,
276+
modelOverrideSource: "auto",
260277
}),
261278
).toEqual(["openai/gpt-5.4"]);
262279
expect(
263280
resolveEffectiveModelFallbacks({
264281
cfg: cfgDisable,
265282
agentId: "linus",
266283
hasSessionModelOverride: true,
284+
modelOverrideSource: "auto",
267285
}),
268286
).toEqual([]);
269287
});

src/agents/agent-scope.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,15 @@ export function resolveEffectiveModelFallbacks(params: {
205205
cfg: OpenClawConfig;
206206
agentId: string;
207207
hasSessionModelOverride: boolean;
208+
modelOverrideSource?: "auto" | "user";
208209
}): string[] | undefined {
209210
const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId);
210211
if (!params.hasSessionModelOverride) {
211212
return agentFallbacksOverride;
212213
}
214+
if (params.modelOverrideSource !== "auto") {
215+
return [];
216+
}
213217
const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
214218
return agentFallbacksOverride ?? defaultFallbacks;
215219
}

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ export async function runAgentTurnWithFallback(params: {
957957
const onToolResult = params.opts?.onToolResult;
958958
const outcomePlan = buildAgentRuntimeOutcomePlan();
959959
const fallbackResult = await runWithModelFallback<EmbeddedAgentRunResult>({
960-
...resolveModelFallbackOptions(params.followupRun.run),
960+
...resolveModelFallbackOptions(effectiveRun, runtimeConfig),
961961
runId,
962962
classifyResult: async ({ result, provider, model }) => {
963963
const classification = outcomePlan.classifyRunResult({

src/auto-reply/reply/agent-runner-run-params.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js";
1+
import { resolveEffectiveModelFallbacks } from "../../agents/agent-scope.js";
22
import type { resolveProviderScopedAuthProfile } from "./agent-runner-auth-profile.js";
33
import type { FollowupRun } from "./queue.js";
44

@@ -26,17 +26,21 @@ export const resolveEnforceFinalTagWithResolver = (
2626
}) ||
2727
false);
2828

29-
export function resolveModelFallbackOptions(run: FollowupRun["run"]) {
30-
const config = run.config;
29+
export function resolveModelFallbackOptions(
30+
run: FollowupRun["run"],
31+
configOverride: FollowupRun["run"]["config"] = run.config,
32+
) {
33+
const config = configOverride;
3134
return {
3235
cfg: config,
3336
provider: run.provider,
3437
model: run.model,
3538
agentDir: run.agentDir,
36-
fallbacksOverride: resolveRunModelFallbacksOverride({
39+
fallbacksOverride: resolveEffectiveModelFallbacks({
3740
cfg: config,
3841
agentId: run.agentId,
39-
sessionKey: run.sessionKey,
42+
hasSessionModelOverride: run.hasSessionModelOverride === true,
43+
modelOverrideSource: run.modelOverrideSource,
4044
}),
4145
};
4246
}

src/auto-reply/reply/agent-runner-utils.test.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { FollowupRun } from "./queue.js";
33

44
const hoisted = vi.hoisted(() => {
5-
const resolveRunModelFallbacksOverrideMock = vi.fn();
5+
const resolveEffectiveModelFallbacksMock = vi.fn();
66
const getChannelPluginMock = vi.fn();
77
const isReasoningTagProviderMock = vi.fn();
8-
return { resolveRunModelFallbacksOverrideMock, getChannelPluginMock, isReasoningTagProviderMock };
8+
return { resolveEffectiveModelFallbacksMock, getChannelPluginMock, isReasoningTagProviderMock };
99
});
1010

1111
vi.mock("../../agents/agent-scope.js", () => ({
12-
resolveRunModelFallbacksOverride: (...args: unknown[]) =>
13-
hoisted.resolveRunModelFallbacksOverrideMock(...args),
12+
resolveEffectiveModelFallbacks: (...args: unknown[]) =>
13+
hoisted.resolveEffectiveModelFallbacksMock(...args),
1414
}));
1515

1616
vi.mock("../../channels/plugins/index.js", () => ({
@@ -56,22 +56,23 @@ function makeRun(overrides: Partial<FollowupRun["run"]> = {}): FollowupRun["run"
5656

5757
describe("agent-runner-utils", () => {
5858
beforeEach(() => {
59-
hoisted.resolveRunModelFallbacksOverrideMock.mockClear();
59+
hoisted.resolveEffectiveModelFallbacksMock.mockClear();
6060
hoisted.getChannelPluginMock.mockReset();
6161
hoisted.isReasoningTagProviderMock.mockReset();
6262
hoisted.isReasoningTagProviderMock.mockReturnValue(false);
6363
});
6464

6565
it("resolves model fallback options from run context", () => {
66-
hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]);
67-
const run = makeRun();
66+
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]);
67+
const run = makeRun({ hasSessionModelOverride: true, modelOverrideSource: "user" });
6868

6969
const resolved = resolveModelFallbackOptions(run);
7070

71-
expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({
71+
expect(hoisted.resolveEffectiveModelFallbacksMock).toHaveBeenCalledWith({
7272
cfg: run.config,
7373
agentId: run.agentId,
74-
sessionKey: run.sessionKey,
74+
hasSessionModelOverride: true,
75+
modelOverrideSource: "user",
7576
});
7677
expect(resolved).toEqual({
7778
cfg: run.config,
@@ -83,15 +84,16 @@ describe("agent-runner-utils", () => {
8384
});
8485

8586
it("passes through missing agentId for helper-based fallback resolution", () => {
86-
hoisted.resolveRunModelFallbacksOverrideMock.mockReturnValue(["fallback-model"]);
87+
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]);
8788
const run = makeRun({ agentId: undefined });
8889

8990
const resolved = resolveModelFallbackOptions(run);
9091

91-
expect(hoisted.resolveRunModelFallbacksOverrideMock).toHaveBeenCalledWith({
92+
expect(hoisted.resolveEffectiveModelFallbacksMock).toHaveBeenCalledWith({
9293
cfg: run.config,
9394
agentId: undefined,
94-
sessionKey: run.sessionKey,
95+
hasSessionModelOverride: false,
96+
modelOverrideSource: undefined,
9597
});
9698
expect(resolved.fallbacksOverride).toEqual(["fallback-model"]);
9799
});

0 commit comments

Comments
 (0)