From 572dd675d8ff562e16d2189908cbe0e275111467 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 06:39:35 +0100 Subject: [PATCH] fix(models): repair provider-wrapped session overrides --- CHANGELOG.md | 1 + .../agent-command.live-model-switch.test.ts | 1 + src/agents/agent-command.ts | 18 +++++- src/sessions/model-overrides.test.ts | 59 ++++++++++++++++++- src/sessions/model-overrides.ts | 45 ++++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf08ab3d3bb..962a3f9188f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Models/config: explain missing `models.providers..models[]` registration when a model exists only in `agents.defaults.models`, instead of returning a bare unknown-model error. Fixes #80089. - MCP/tools: prefix bundle MCP server/tool fragments that would start with digits, keeping generated tool names valid for Moonshot/Kimi and other strict providers. Fixes #79179. - Models/OpenRouter: treat `403 API key budget limit exceeded` as billing so model fallback advances instead of retrying the exhausted primary. Fixes #60191. Thanks @omgitsgela. +- Models/OpenRouter: repair stale session overrides that lost the outer `openrouter/` provider wrapper, so sessions return to the configured OpenRouter model instead of failing as an unknown direct-provider model. Fixes #78161. Thanks @hjamal7-bit. - Kimi Code: use Kimi's stable `kimi-for-coding` API model id in bundled catalog, onboarding, and docs while normalizing legacy `kimi-code` and `k2p5` refs. Fixes #79965. - Volcengine/Kimi: strip provider-unsupported tool schema length and item constraint keywords for direct and coding-plan models so hosted Kimi runs do not reject message tools with `minLength`. Fixes #38817. - DeepSeek: backfill V4 `reasoning_content` replay fields for unowned OpenAI-compatible proxy providers, preventing follow-up request failures outside the bundled DeepSeek and OpenRouter routes. Fixes #79608. diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 77456f37bc9..b7f5e87275d 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -241,6 +241,7 @@ vi.mock("../sessions/level-overrides.js", () => ({ vi.mock("../sessions/model-overrides.js", () => ({ applyModelOverrideToSessionEntry: () => ({ updated: false }), + repairProviderWrappedModelOverride: () => ({ updated: false }), })); vi.mock("../sessions/send-policy.js", () => ({ diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index d42a32cd6bc..be3cedd2752 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -24,7 +24,10 @@ import { } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; -import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; +import { + applyModelOverrideToSessionEntry, + repairProviderWrappedModelOverride, +} from "../sessions/model-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -778,6 +781,19 @@ async function agentCommandInternal( if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { const entry = sessionEntry; + const repaired = repairProviderWrappedModelOverride({ + entry, + defaultProvider, + defaultModel, + }); + if (repaired.updated) { + await persistSessionEntry({ + sessionStore, + sessionKey, + storePath, + entry, + }); + } const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index 9a0fefdec52..02acfbe5d24 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; -import { applyModelOverrideToSessionEntry } from "./model-overrides.js"; +import { + applyModelOverrideToSessionEntry, + repairProviderWrappedModelOverride, +} from "./model-overrides.js"; function applyOpenAiSelection(entry: SessionEntry) { return applyModelOverrideToSessionEntry({ @@ -173,3 +176,57 @@ describe("applyModelOverrideToSessionEntry", () => { expect(withFlagEntry.liveModelSwitchPending).toBe(true); }); }); + +describe("repairProviderWrappedModelOverride", () => { + it("restores a provider-wrapped override from aligned runtime model fields", () => { + const before = Date.now() - 5_000; + const entry: SessionEntry = { + sessionId: "sess-openrouter-repair-runtime", + updatedAt: before, + providerOverride: "anthropic", + modelOverride: "claude-haiku-4.5", + modelOverrideSource: "user", + modelProvider: "openrouter", + model: "anthropic/claude-haiku-4.5", + contextTokens: 200_000, + }; + + const result = repairProviderWrappedModelOverride({ + entry, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + }); + + expect(result.updated).toBe(true); + expect(entry.providerOverride).toBe("openrouter"); + expect(entry.modelOverride).toBe("anthropic/claude-haiku-4.5"); + expect(entry.modelOverrideSource).toBe("user"); + expect(entry.modelProvider).toBeUndefined(); + expect(entry.model).toBeUndefined(); + expect(entry.contextTokens).toBeUndefined(); + expect((entry.updatedAt ?? 0) > before).toBe(true); + }); + + it("clears a provider-wrapped override that matches the configured default", () => { + const before = Date.now() - 5_000; + const entry: SessionEntry = { + sessionId: "sess-openrouter-repair-default", + updatedAt: before, + providerOverride: "anthropic", + modelOverride: "claude-haiku-4.5", + modelOverrideSource: "user", + }; + + const result = repairProviderWrappedModelOverride({ + entry, + defaultProvider: "openrouter", + defaultModel: "anthropic/claude-haiku-4.5", + }); + + expect(result.updated).toBe(true); + expect(entry.providerOverride).toBeUndefined(); + expect(entry.modelOverride).toBeUndefined(); + expect(entry.modelOverrideSource).toBeUndefined(); + expect((entry.updatedAt ?? 0) > before).toBe(true); + }); +}); diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index 5bd6ccb1d51..9a7a5f4b8f2 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -125,3 +125,48 @@ export function applyModelOverrideToSessionEntry(params: { return { updated }; } + +function wrappedOverrideModel(provider: string, model: string): string { + return `${provider}/${model}`; +} + +export function repairProviderWrappedModelOverride(params: { + entry: SessionEntry; + defaultProvider: string; + defaultModel?: string; +}): { updated: boolean } { + const overrideProvider = normalizeOptionalString(params.entry.providerOverride); + const overrideModel = normalizeOptionalString(params.entry.modelOverride); + if (!overrideProvider || !overrideModel) { + return { updated: false }; + } + + const wrappedModel = wrappedOverrideModel(overrideProvider, overrideModel); + const runtimeProvider = normalizeOptionalString(params.entry.modelProvider); + const runtimeModel = normalizeOptionalString(params.entry.model); + if (runtimeProvider && runtimeModel === wrappedModel && runtimeProvider !== overrideProvider) { + return applyModelOverrideToSessionEntry({ + entry: params.entry, + selection: { + provider: runtimeProvider, + model: runtimeModel, + isDefault: + runtimeProvider === params.defaultProvider && runtimeModel === params.defaultModel, + }, + selectionSource: params.entry.modelOverrideSource === "auto" ? "auto" : "user", + }); + } + + if (params.defaultProvider !== overrideProvider && params.defaultModel === wrappedModel) { + return applyModelOverrideToSessionEntry({ + entry: params.entry, + selection: { + provider: params.defaultProvider, + model: params.defaultModel, + isDefault: true, + }, + }); + } + + return { updated: false }; +}