From 32ebaa37574ec097c1e65b823ddac169521bc244 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 19:35:32 +0900 Subject: [PATCH] refactor: share session model resolution helpers --- .../discord/src/monitor/native-command-ui.ts | 2 + .../mattermost/src/mattermost/model-picker.ts | 1 + .../telegram/src/bot-handlers.runtime.ts | 4 + src/agents/live-model-switch.test.ts | 97 +++++++++++++++++++ src/agents/live-model-switch.ts | 16 ++- src/agents/model-selection-display.test.ts | 92 ++++++++++++++++++ src/agents/model-selection-display.ts | 90 +++++++++++++++++ src/agents/model-selection.test.ts | 41 ++++++++ src/agents/model-selection.ts | 36 +++++++ src/agents/subagent-control.ts | 55 ++++------- .../reply/commands-subagents/shared.ts | 54 ++++------- src/auto-reply/reply/model-selection.test.ts | 49 ++++++++++ src/auto-reply/reply/model-selection.ts | 80 ++++++++------- src/commands/status.summary.runtime.test.ts | 33 +++++++ src/commands/status.summary.runtime.ts | 42 ++------ src/gateway/session-utils.ts | 51 +++------- src/tui/tui-session-actions.ts | 24 ++--- 17 files changed, 558 insertions(+), 209 deletions(-) create mode 100644 src/agents/model-selection-display.test.ts create mode 100644 src/agents/model-selection-display.ts diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index c8310a594d7..bd22e82895b 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -322,6 +322,7 @@ export async function resolveDiscordNativeChoiceContext(params: { sessionEntry, sessionStore, sessionKey: route.sessionKey, + defaultProvider: fallback.provider, }); if (!override?.model) { return { @@ -357,6 +358,7 @@ function resolveDiscordModelPickerCurrentModel(params: { sessionEntry, sessionStore, sessionKey: params.route.sessionKey, + defaultProvider: params.data.resolvedDefault.provider, }); if (!override?.model) { return fallback; diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 46838f7e6ad..e5e651f3310 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -231,6 +231,7 @@ export function resolveMattermostModelPickerCurrentModel(params: { sessionEntry, sessionStore, sessionKey: params.route.sessionKey, + defaultProvider: params.data.resolvedDefault.provider, }); if (!override?.model) { return fallback; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 252e8e1e620..b480a6a7d52 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -347,6 +347,10 @@ export const registerTelegramHandlers = ({ sessionEntry: entry, sessionStore: store, sessionKey, + defaultProvider: resolveDefaultModelForAgent({ + cfg: runtimeCfg, + agentId: route.agentId, + }).provider, }); if (storedOverride) { return { diff --git a/src/agents/live-model-switch.test.ts b/src/agents/live-model-switch.test.ts index dbedb9a8d6e..a4f544e86e3 100644 --- a/src/agents/live-model-switch.test.ts +++ b/src/agents/live-model-switch.test.ts @@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({ requestEmbeddedRunModelSwitchMock: vi.fn(), consumeEmbeddedRunModelSwitchMock: vi.fn(), resolveDefaultModelForAgentMock: vi.fn(), + resolvePersistedModelRefMock: vi.fn(), loadSessionStoreMock: vi.fn(), resolveStorePathMock: vi.fn(), })); @@ -24,6 +25,7 @@ vi.mock("./pi-embedded-runner/runs.js", () => ({ vi.mock("./model-selection.js", () => ({ resolveDefaultModelForAgent: (...args: unknown[]) => state.resolveDefaultModelForAgentMock(...args), + resolvePersistedModelRef: (...args: unknown[]) => state.resolvePersistedModelRefMock(...args), })); vi.mock("../config/sessions.js", () => ({ @@ -46,6 +48,50 @@ describe("live model switch", () => { state.resolveDefaultModelForAgentMock .mockReset() .mockReturnValue({ provider: "anthropic", model: "claude-opus-4-6" }); + state.resolvePersistedModelRefMock + .mockReset() + .mockImplementation( + (params: { + defaultProvider: string; + runtimeProvider?: string; + runtimeModel?: string; + overrideProvider?: string; + overrideModel?: string; + }) => { + const defaultProvider = params.defaultProvider.trim(); + const runtimeProvider = params.runtimeProvider?.trim(); + const runtimeModel = params.runtimeModel?.trim(); + if (runtimeModel) { + if (runtimeProvider) { + return { provider: runtimeProvider, model: runtimeModel }; + } + const slash = runtimeModel.indexOf("/"); + if (slash <= 0 || slash === runtimeModel.length - 1) { + return { provider: defaultProvider, model: runtimeModel }; + } + return { + provider: runtimeModel.slice(0, slash), + model: runtimeModel.slice(slash + 1), + }; + } + const overrideProvider = params.overrideProvider?.trim(); + const overrideModel = params.overrideModel?.trim(); + if (!overrideModel) { + return null; + } + if (overrideProvider) { + return { provider: overrideProvider, model: overrideModel }; + } + const slash = overrideModel.indexOf("/"); + if (slash <= 0 || slash === overrideModel.length - 1) { + return { provider: defaultProvider, model: overrideModel }; + } + return { + provider: overrideModel.slice(0, slash), + model: overrideModel.slice(slash + 1), + }; + }, + ); state.loadSessionStoreMock.mockReset().mockReturnValue({}); state.resolveStorePathMock.mockReset().mockReturnValue("/tmp/session-store.json"); }); @@ -112,6 +158,57 @@ describe("live model switch", () => { }); }); + it("splits legacy combined session overrides when providerOverride is missing", async () => { + state.loadSessionStoreMock.mockReturnValue({ + main: { + modelOverride: "ollama-beelink2/qwen2.5-coder:7b", + }, + }); + + const { resolveLiveSessionModelSelection } = await loadModule(); + + expect( + resolveLiveSessionModelSelection({ + cfg: { session: { store: "/tmp/custom-store.json" } }, + sessionKey: "main", + agentId: "reply", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }), + ).toEqual({ + provider: "ollama-beelink2", + model: "qwen2.5-coder:7b", + authProfileId: undefined, + authProfileIdSource: undefined, + }); + }); + + it("preserves provider when runtime model is a vendor-prefixed OpenRouter id", async () => { + state.loadSessionStoreMock.mockReturnValue({ + main: { + modelProvider: "openrouter", + model: "anthropic/claude-haiku-4.5", + }, + }); + + const { resolveLiveSessionModelSelection } = await loadModule(); + + expect( + resolveLiveSessionModelSelection({ + cfg: { session: { store: "/tmp/custom-store.json" } }, + sessionKey: "main", + agentId: "reply", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + }), + ).toEqual({ + provider: "openrouter", + model: "anthropic/claude-haiku-4.5", + authProfileId: undefined, + authProfileIdSource: undefined, + }); + }); + it("queues a live switch only when an active run was aborted", async () => { state.abortEmbeddedPiRunMock.mockReturnValue(true); diff --git a/src/agents/live-model-switch.ts b/src/agents/live-model-switch.ts index 1da62c090c0..0f65a93a87e 100644 --- a/src/agents/live-model-switch.ts +++ b/src/agents/live-model-switch.ts @@ -1,5 +1,5 @@ import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js"; -import { resolveDefaultModelForAgent } from "./model-selection.js"; +import { resolveDefaultModelForAgent, resolvePersistedModelRef } from "./model-selection.js"; import { consumeEmbeddedRunModelSwitch, requestEmbeddedRunModelSwitch, @@ -48,10 +48,16 @@ export function resolveLiveSessionModelSelection(params: { agentId, }); const entry = loadSessionStore(storePath, { skipCache: true })[sessionKey]; - const runtimeProvider = entry?.modelProvider?.trim(); - const runtimeModel = entry?.model?.trim(); - const provider = runtimeProvider || entry?.providerOverride?.trim() || defaultModelRef.provider; - const model = runtimeModel || entry?.modelOverride?.trim() || defaultModelRef.model; + const persisted = resolvePersistedModelRef({ + defaultProvider: defaultModelRef.provider, + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + }); + const provider = + persisted?.provider ?? entry?.providerOverride?.trim() ?? defaultModelRef.provider; + const model = persisted?.model ?? defaultModelRef.model; const authProfileId = entry?.authProfileOverride?.trim() || undefined; return { provider, diff --git a/src/agents/model-selection-display.test.ts b/src/agents/model-selection-display.test.ts new file mode 100644 index 00000000000..dccff77fa98 --- /dev/null +++ b/src/agents/model-selection-display.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + resolveModelDisplayName, + resolveModelDisplayRef, + resolveSessionInfoModelSelection, +} from "./model-selection-display.js"; + +describe("model-selection-display", () => { + describe("resolveModelDisplayRef", () => { + it("keeps explicit runtime slash-bearing ids unchanged for display", () => { + expect( + resolveModelDisplayRef({ + runtimeModel: "anthropic/claude-haiku-4.5", + }), + ).toBe("anthropic/claude-haiku-4.5"); + }); + + it("combines separate runtime provider and model ids", () => { + expect( + resolveModelDisplayRef({ + runtimeProvider: "openai", + runtimeModel: "gpt-5.2", + }), + ).toBe("openai/gpt-5.2"); + }); + + it("falls back to override values when runtime values are absent", () => { + expect( + resolveModelDisplayRef({ + overrideProvider: "openrouter", + overrideModel: "anthropic/claude-sonnet-4-5", + }), + ).toBe("anthropic/claude-sonnet-4-5"); + }); + }); + + describe("resolveModelDisplayName", () => { + it("renders the trailing model segment for compact UI labels", () => { + expect( + resolveModelDisplayName({ + runtimeProvider: "openrouter", + runtimeModel: "anthropic/claude-sonnet-4-5", + }), + ).toBe("claude-sonnet-4-5"); + }); + + it("returns a stable empty-state label", () => { + expect(resolveModelDisplayName({})).toBe("model n/a"); + }); + }); + + describe("resolveSessionInfoModelSelection", () => { + it("keeps partial runtime patches merged with current state", () => { + expect( + resolveSessionInfoModelSelection({ + currentProvider: "anthropic", + currentModel: "claude-sonnet-4-6", + entryModel: "claude-opus-4-6", + }), + ).toEqual({ + modelProvider: "anthropic", + model: "claude-opus-4-6", + }); + }); + + it("keeps override ids attached to the current provider when no override provider is stored", () => { + expect( + resolveSessionInfoModelSelection({ + currentProvider: "anthropic", + currentModel: "claude-sonnet-4-6", + overrideModel: "ollama-beelink2/qwen2.5-coder:7b", + }), + ).toEqual({ + modelProvider: "anthropic", + model: "ollama-beelink2/qwen2.5-coder:7b", + }); + }); + + it("keeps the current provider for slash-bearing override ids when provider is already known", () => { + expect( + resolveSessionInfoModelSelection({ + currentProvider: "openrouter", + currentModel: "openrouter/auto", + overrideModel: "anthropic/claude-haiku-4.5", + }), + ).toEqual({ + modelProvider: "openrouter", + model: "anthropic/claude-haiku-4.5", + }); + }); + }); +}); diff --git a/src/agents/model-selection-display.ts b/src/agents/model-selection-display.ts new file mode 100644 index 00000000000..0e2d508d0a9 --- /dev/null +++ b/src/agents/model-selection-display.ts @@ -0,0 +1,90 @@ +type ModelDisplaySelectionParams = { + runtimeProvider?: string | null; + runtimeModel?: string | null; + overrideProvider?: string | null; + overrideModel?: string | null; + fallbackModel?: string | null; +}; + +export function resolveModelDisplayRef(params: ModelDisplaySelectionParams): string | undefined { + const runtimeModel = params.runtimeModel?.trim(); + const runtimeProvider = params.runtimeProvider?.trim(); + if (runtimeModel) { + if (runtimeModel.includes("/")) { + return runtimeModel; + } + if (runtimeProvider) { + return `${runtimeProvider}/${runtimeModel}`; + } + return runtimeModel; + } + if (runtimeProvider) { + return runtimeProvider; + } + + const overrideModel = params.overrideModel?.trim(); + const overrideProvider = params.overrideProvider?.trim(); + if (overrideModel) { + if (overrideModel.includes("/")) { + return overrideModel; + } + if (overrideProvider) { + return `${overrideProvider}/${overrideModel}`; + } + return overrideModel; + } + if (overrideProvider) { + return overrideProvider; + } + + const fallbackModel = params.fallbackModel?.trim(); + return fallbackModel || undefined; +} + +export function resolveModelDisplayName(params: ModelDisplaySelectionParams): string { + const modelRef = resolveModelDisplayRef(params); + if (!modelRef) { + return "model n/a"; + } + const slash = modelRef.lastIndexOf("/"); + if (slash >= 0 && slash < modelRef.length - 1) { + return modelRef.slice(slash + 1); + } + return modelRef; +} + +type SessionInfoModelSelectionParams = { + currentProvider?: string | null; + currentModel?: string | null; + entryProvider?: string | null; + entryModel?: string | null; + overrideProvider?: string | null; + overrideModel?: string | null; +}; + +export function resolveSessionInfoModelSelection(params: SessionInfoModelSelectionParams): { + modelProvider?: string; + model?: string; +} { + if (params.entryProvider !== undefined || params.entryModel !== undefined) { + return { + modelProvider: params.entryProvider ?? params.currentProvider ?? undefined, + model: params.entryModel ?? params.currentModel ?? undefined, + }; + } + + const overrideModel = params.overrideModel?.trim(); + if (overrideModel) { + const overrideProvider = params.overrideProvider?.trim(); + const currentProvider = params.currentProvider ?? undefined; + return { + modelProvider: overrideProvider || currentProvider, + model: overrideModel, + }; + } + + return { + modelProvider: params.currentProvider ?? undefined, + model: params.currentModel ?? undefined, + }; +} diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 24e6e802f47..bd4f268ec2b 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -10,6 +10,7 @@ import { normalizeProviderId, normalizeProviderIdForAuth, modelKey, + resolvePersistedModelRef, resolveAllowedModelRef, resolveConfiguredModelRef, resolveSubagentConfiguredModelSelection, @@ -274,6 +275,46 @@ describe("model-selection", () => { }); }); + describe("resolvePersistedModelRef", () => { + it("splits legacy combined refs when provider is not stored separately", () => { + expect( + resolvePersistedModelRef({ + defaultProvider: "anthropic", + overrideModel: "ollama-beelink2/qwen2.5-coder:7b", + }), + ).toEqual({ + provider: "ollama-beelink2", + model: "qwen2.5-coder:7b", + }); + }); + + it("preserves explicit runtime provider for vendor-prefixed model ids", () => { + expect( + resolvePersistedModelRef({ + defaultProvider: "anthropic", + runtimeProvider: "openrouter", + runtimeModel: "anthropic/claude-haiku-4.5", + }), + ).toEqual({ + provider: "openrouter", + model: "anthropic/claude-haiku-4.5", + }); + }); + + it("normalizes explicit override providers without reparsing runtime semantics", () => { + expect( + resolvePersistedModelRef({ + defaultProvider: "anthropic", + overrideProvider: "kimi-coding", + overrideModel: "kimi-code", + }), + ).toEqual({ + provider: "kimi", + model: "kimi-code", + }); + }); + }); + describe("inferUniqueProviderFromConfiguredModels", () => { it("infers provider when configured model match is unique", () => { const cfg = { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index ae2f56d07e6..5b72575238c 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -211,6 +211,42 @@ export function parseModelRef( return normalizeModelRef(providerRaw, model, options); } +export function resolvePersistedModelRef(params: { + defaultProvider: string; + runtimeProvider?: string; + runtimeModel?: string; + overrideProvider?: string; + overrideModel?: string; +}): ModelRef | null { + const defaultProvider = params.defaultProvider.trim(); + const runtimeProvider = params.runtimeProvider?.trim(); + const runtimeModel = params.runtimeModel?.trim(); + if (runtimeModel) { + if (runtimeProvider) { + return { provider: runtimeProvider, model: runtimeModel }; + } + return ( + parseModelRef(runtimeModel, defaultProvider) ?? { + provider: defaultProvider, + model: runtimeModel, + } + ); + } + + const overrideProvider = params.overrideProvider?.trim(); + const overrideModel = params.overrideModel?.trim(); + if (!overrideModel) { + return null; + } + const encodedOverride = overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel; + return ( + parseModelRef(encodedOverride, defaultProvider) ?? { + provider: overrideProvider || defaultProvider, + model: overrideModel, + } + ); +} + export function inferUniqueProviderFromConfiguredModels(params: { cfg: OpenClawConfig; model: string; diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index fcc73f59ca1..5f120043941 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -24,6 +24,7 @@ import { } from "../shared/subagents-format.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; +import { resolveModelDisplayName, resolveModelDisplayRef } from "./model-selection-display.js"; import { abortEmbeddedPiRun } from "./pi-embedded.js"; import { resolveStoredSubagentCapabilities } from "./subagent-capabilities.js"; import { @@ -236,46 +237,24 @@ function resolveRunStatus(entry: SubagentRunRecord, options?: { pendingDescendan return status; } -function resolveModelRef(entry?: SessionEntry) { - const model = typeof entry?.model === "string" ? entry.model.trim() : ""; - const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; - if (model.includes("/")) { - return model; - } - if (model && provider) { - return `${provider}/${model}`; - } - if (model) { - return model; - } - if (provider) { - return provider; - } - const overrideModel = typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; - const overrideProvider = - typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; - if (overrideModel.includes("/")) { - return overrideModel; - } - if (overrideModel && overrideProvider) { - return `${overrideProvider}/${overrideModel}`; - } - if (overrideModel) { - return overrideModel; - } - return overrideProvider || undefined; +function resolveModelRef(entry?: SessionEntry, fallbackModel?: string) { + return resolveModelDisplayRef({ + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + fallbackModel, + }); } function resolveModelDisplay(entry?: SessionEntry, fallbackModel?: string) { - const modelRef = resolveModelRef(entry) || fallbackModel || undefined; - if (!modelRef) { - return "model n/a"; - } - const slash = modelRef.lastIndexOf("/"); - if (slash >= 0 && slash < modelRef.length - 1) { - return modelRef.slice(slash + 1); - } - return modelRef; + return resolveModelDisplayName({ + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + fallbackModel, + }); } function buildListText(params: { @@ -361,7 +340,7 @@ export function buildSubagentList(params: { runtime, runtimeMs, ...(childSessions.length > 0 ? { childSessions } : {}), - model: resolveModelRef(sessionEntry) || entry.model, + model: resolveModelRef(sessionEntry, entry.model), totalTokens, startedAt: getSubagentSessionStartedAt(entry), ...(entry.endedAt ? { endedAt: entry.endedAt } : {}), diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 7da9ca3a69d..649431d3797 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -1,3 +1,4 @@ +import { resolveModelDisplayName } from "../../../agents/model-selection-display.js"; import { resolveStoredSubagentCapabilities } from "../../../agents/subagent-capabilities.js"; import type { ResolvedSubagentController } from "../../../agents/subagent-control.js"; import { @@ -89,42 +90,6 @@ function formatTaskPreview(value: string) { return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX); } -function resolveModelDisplay( - entry?: { - model?: unknown; - modelProvider?: unknown; - modelOverride?: unknown; - providerOverride?: unknown; - }, - fallbackModel?: string, -) { - const model = typeof entry?.model === "string" ? entry.model.trim() : ""; - const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : ""; - let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model; - if (!combined) { - const overrideModel = - typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : ""; - const overrideProvider = - typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : ""; - combined = overrideModel.includes("/") - ? overrideModel - : overrideModel && overrideProvider - ? `${overrideProvider}/${overrideModel}` - : overrideModel; - } - if (!combined) { - combined = fallbackModel?.trim() || ""; - } - if (!combined) { - return "model n/a"; - } - const slash = combined.lastIndexOf("/"); - if (slash >= 0 && slash < combined.length - 1) { - return combined.slice(slash + 1); - } - return combined; -} - export function resolveDisplayStatus( entry: SubagentRunRecord, options?: { pendingDescendants?: number }, @@ -152,7 +117,22 @@ export function formatSubagentListLine(params: { const status = resolveDisplayStatus(params.entry, { pendingDescendants: params.pendingDescendants, }); - return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; + return `${params.index}. ${label} (${resolveModelDisplayName({ + runtimeProvider: + typeof params.sessionEntry?.modelProvider === "string" + ? params.sessionEntry.modelProvider + : null, + runtimeModel: typeof params.sessionEntry?.model === "string" ? params.sessionEntry.model : null, + overrideProvider: + typeof params.sessionEntry?.providerOverride === "string" + ? params.sessionEntry.providerOverride + : null, + overrideModel: + typeof params.sessionEntry?.modelOverride === "string" + ? params.sessionEntry.modelOverride + : null, + fallbackModel: params.entry.model, + })}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`; } function formatTimestamp(valueMs?: number) { diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 21e3e54ee63..ee950b65807 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -405,6 +405,17 @@ describe("createModelSelectionState respects session model override", () => { expect(state.model).toBe("deepseek-v3-4bit-mlx"); }); + it("splits legacy combined modelOverride when providerOverride is missing", async () => { + const state = await resolveState( + makeEntry({ + modelOverride: "ollama-beelink2/qwen2.5-coder:7b", + }), + ); + + expect(state.provider).toBe("ollama-beelink2"); + expect(state.model).toBe("qwen2.5-coder:7b"); + }); + it("normalizes deprecated xai beta session overrides before allowlist checks", async () => { const cfg = { agents: { @@ -479,6 +490,44 @@ describe("createModelSelectionState respects session model override", () => { expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined(); expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); }); + + it("keeps allowed legacy combined session overrides after normalization", async () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "ollama-beelink2/qwen2.5-coder:7b": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:direct:2"; + const sessionEntry = makeEntry({ + modelOverride: "ollama-beelink2/qwen2.5-coder:7b", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + provider: "anthropic", + model: "claude-opus-4-5", + hasModelDirective: false, + }); + + expect(state.provider).toBe("ollama-beelink2"); + expect(state.model).toBe("qwen2.5-coder:7b"); + expect(state.resetModelOverride).toBe(false); + expect(sessionStore[sessionKey]?.modelOverride).toBe("ollama-beelink2/qwen2.5-coder:7b"); + expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); + }); }); describe("createModelSelectionState resolveDefaultReasoningLevel", () => { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 7765d925d3d..cc1a585ebf3 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -11,6 +11,7 @@ import { normalizeModelRef, normalizeProviderId, resolveModelRefFromString, + resolvePersistedModelRef, resolveReasoningDefault, resolveThinkingDefault, } from "../../agents/model-selection.js"; @@ -126,18 +127,6 @@ export type StoredModelOverride = { source: "session" | "parent"; }; -function resolveModelOverrideFromEntry(entry?: SessionEntry): { - provider?: string; - model: string; -} | null { - const model = entry?.modelOverride?.trim(); - if (!model) { - return null; - } - const provider = entry?.providerOverride?.trim() || undefined; - return { provider, model }; -} - function resolveParentSessionKeyCandidate(params: { sessionKey?: string; parentSessionKey?: string; @@ -158,8 +147,13 @@ export function resolveStoredModelOverride(params: { sessionStore?: Record; sessionKey?: string; parentSessionKey?: string; + defaultProvider: string; }): StoredModelOverride | null { - const direct = resolveModelOverrideFromEntry(params.sessionEntry); + const direct = resolvePersistedModelRef({ + defaultProvider: params.defaultProvider, + overrideProvider: params.sessionEntry?.providerOverride, + overrideModel: params.sessionEntry?.modelOverride, + }); if (direct) { return { ...direct, source: "session" }; } @@ -171,7 +165,11 @@ export function resolveStoredModelOverride(params: { return null; } const parentEntry = params.sessionStore[parentKey]; - const parentOverride = resolveModelOverrideFromEntry(parentEntry); + const parentOverride = resolvePersistedModelRef({ + defaultProvider: params.defaultProvider, + overrideProvider: parentEntry?.providerOverride, + overrideModel: parentEntry?.modelOverride, + }); if (!parentOverride) { return null; } @@ -330,13 +328,6 @@ export async function createModelSelectionState(params: { let model = params.model; const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0; - const initialStoredOverride = resolveStoredModelOverride({ - sessionEntry, - sessionStore, - sessionKey, - parentSessionKey, - }); - const hasStoredOverride = Boolean(initialStoredOverride); const configuredModelCatalog = buildConfiguredModelCatalog({ cfg }); const needsModelCatalog = params.hasModelDirective; @@ -345,6 +336,11 @@ export async function createModelSelectionState(params: { let modelCatalog: ModelCatalog | null = null; let resetModelOverride = false; const agentEntry = params.agentId ? resolveAgentConfig(cfg, params.agentId) : undefined; + const directStoredOverride = resolvePersistedModelRef({ + defaultProvider, + overrideProvider: sessionEntry?.providerOverride, + overrideModel: sessionEntry?.modelOverride, + }); if (needsModelCatalog) { modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); @@ -380,29 +376,28 @@ export async function createModelSelectionState(params: { logStage("configured-catalog-ready", `entries=${configuredModelCatalog.length}`); } - if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { - const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; - const overrideModel = sessionEntry.modelOverride?.trim(); - if (overrideModel) { - const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); - const key = modelKey(normalizedOverride.provider, normalizedOverride.model); - if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { - const { updated } = applyModelOverrideToSessionEntry({ - entry: sessionEntry, - selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, - }); - if (updated) { - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - await ( - await loadSessionStoreRuntime() - ).updateSessionStore(storePath, (store) => { - store[sessionKey] = sessionEntry; - }); - } + if (sessionEntry && sessionStore && sessionKey && directStoredOverride) { + const normalizedOverride = normalizeModelRef( + directStoredOverride.provider, + directStoredOverride.model, + ); + const key = modelKey(normalizedOverride.provider, normalizedOverride.model); + if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { + const { updated } = applyModelOverrideToSessionEntry({ + entry: sessionEntry, + selection: { provider: defaultProvider, model: defaultModel, isDefault: true }, + }); + if (updated) { + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await ( + await loadSessionStoreRuntime() + ).updateSessionStore(storePath, (store) => { + store[sessionKey] = sessionEntry; + }); } - resetModelOverride = updated; } + resetModelOverride = updated; } } @@ -411,6 +406,7 @@ export async function createModelSelectionState(params: { sessionStore, sessionKey, parentSessionKey, + defaultProvider, }); // Skip stored session model override only when an explicit heartbeat.model // was resolved. Heartbeat runs without heartbeat.model should still inherit diff --git a/src/commands/status.summary.runtime.test.ts b/src/commands/status.summary.runtime.test.ts index 62183c2804e..05fd8034767 100644 --- a/src/commands/status.summary.runtime.test.ts +++ b/src/commands/status.summary.runtime.test.ts @@ -21,3 +21,36 @@ describe("statusSummaryRuntime.resolveContextTokensForModel", () => { expect(contextTokens).toBe(123_456); }); }); + +describe("statusSummaryRuntime.resolveSessionModelRef", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + }, + } as never; + + it("preserves explicit runtime providers for vendor-prefixed model ids", () => { + expect( + statusSummaryRuntime.resolveSessionModelRef(cfg, { + modelProvider: "openrouter", + model: "anthropic/claude-haiku-4.5", + }), + ).toEqual({ + provider: "openrouter", + model: "anthropic/claude-haiku-4.5", + }); + }); + + it("splits legacy combined overrides when provider is missing", () => { + expect( + statusSummaryRuntime.resolveSessionModelRef(cfg, { + modelOverride: "ollama-beelink2/qwen2.5-coder:7b", + }), + ).toEqual({ + provider: "ollama-beelink2", + model: "qwen2.5-coder:7b", + }); + }); +}); diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index f3e9ea862e1..f92a875912f 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -1,5 +1,6 @@ import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolvePersistedModelRef } from "../agents/model-selection.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -155,38 +156,15 @@ function resolveSessionModelRef( defaultModel: DEFAULT_MODEL, agentId, }); - - let provider = resolved.provider; - let model = resolved.model; - const runtimeModel = entry?.model?.trim(); - const runtimeProvider = entry?.modelProvider?.trim(); - if (runtimeModel) { - if (runtimeProvider) { - return { provider: runtimeProvider, model: runtimeModel }; - } - const parsedRuntime = parseStatusModelRef(runtimeModel, provider || DEFAULT_PROVIDER); - if (parsedRuntime) { - provider = parsedRuntime.provider; - model = parsedRuntime.model; - } else { - model = runtimeModel; - } - return { provider, model }; - } - - const storedModelOverride = entry?.modelOverride?.trim(); - if (storedModelOverride) { - const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER; - const parsedOverride = parseStatusModelRef(storedModelOverride, overrideProvider); - if (parsedOverride) { - provider = parsedOverride.provider; - model = parsedOverride.model; - } else { - provider = overrideProvider; - model = storedModelOverride; - } - } - return { provider, model }; + return ( + resolvePersistedModelRef({ + defaultProvider: resolved.provider || DEFAULT_PROVIDER, + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + }) ?? resolved + ); } function resolveContextTokensForModel(params: { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index f401724debe..f43399a9182 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -12,6 +12,7 @@ import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { inferUniqueProviderFromConfiguredModels, parseModelRef, + resolvePersistedModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, } from "../agents/model-selection.js"; @@ -1032,47 +1033,17 @@ export function resolveSessionModelRef( defaultModel: DEFAULT_MODEL, }); - // Prefer the last runtime model recorded on the session entry. - // This is the actual model used by the latest run and must win over defaults. - let provider = resolved.provider; - let model = resolved.model; - const runtimeModel = entry?.model?.trim(); - const runtimeProvider = entry?.modelProvider?.trim(); - if (runtimeModel) { - if (runtimeProvider) { - // Provider is explicitly recorded — use it directly. Re-parsing the - // model string through parseModelRef would incorrectly split OpenRouter - // vendor-prefixed model names (e.g. model="anthropic/claude-haiku-4.5" - // with provider="openrouter") into { provider: "anthropic" }, discarding - // the stored OpenRouter provider and causing direct API calls to a - // provider the user has no credentials for. - return { provider: runtimeProvider, model: runtimeModel }; - } - const parsedRuntime = parseModelRef(runtimeModel, provider || DEFAULT_PROVIDER); - if (parsedRuntime) { - provider = parsedRuntime.provider; - model = parsedRuntime.model; - } else { - model = runtimeModel; - } - return { provider, model }; + const persisted = resolvePersistedModelRef({ + defaultProvider: resolved.provider || DEFAULT_PROVIDER, + runtimeProvider: entry?.modelProvider, + runtimeModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + }); + if (persisted) { + return persisted; } - - // Fall back to explicit per-session override (set at spawn/model-patch time), - // then finally to configured defaults. - const storedModelOverride = entry?.modelOverride?.trim(); - if (storedModelOverride) { - const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER; - const parsedOverride = parseModelRef(storedModelOverride, overrideProvider); - if (parsedOverride) { - provider = parsedOverride.provider; - model = parsedOverride.model; - } else { - provider = overrideProvider; - model = storedModelOverride; - } - } - return { provider, model }; + return resolved; } export async function resolveGatewayModelSupportsImages(params: { diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 99f2b8ab2ee..5cb7e39ecbe 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -1,4 +1,5 @@ import type { TUI } from "@mariozechner/pi-tui"; +import { resolveSessionInfoModelSelection } from "../agents/model-selection-display.js"; import type { SessionsPatchResult } from "../gateway/protocol/index.js"; import { normalizeAgentId, @@ -121,21 +122,14 @@ export function createSessionActions(context: SessionActionContext) { }; const resolveModelSelection = (entry?: SessionInfoEntry) => { - if (entry?.modelProvider || entry?.model) { - return { - modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider, - model: entry.model ?? state.sessionInfo.model, - }; - } - const overrideModel = entry?.modelOverride?.trim(); - if (overrideModel) { - const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider; - return { modelProvider: overrideProvider, model: overrideModel }; - } - return { - modelProvider: state.sessionInfo.modelProvider, - model: state.sessionInfo.model, - }; + return resolveSessionInfoModelSelection({ + currentProvider: state.sessionInfo.modelProvider, + currentModel: state.sessionInfo.model, + entryProvider: entry?.modelProvider, + entryModel: entry?.model, + overrideProvider: entry?.providerOverride, + overrideModel: entry?.modelOverride, + }); }; const applySessionInfo = (params: {