fix: separate selected session model resolution

This commit is contained in:
Peter Steinberger
2026-04-06 16:07:40 +01:00
parent 0f8480ca0b
commit 1fb44f0aad
9 changed files with 219 additions and 50 deletions

View File

@@ -6,7 +6,7 @@ const state = vi.hoisted(() => ({
requestEmbeddedRunModelSwitchMock: vi.fn(),
consumeEmbeddedRunModelSwitchMock: vi.fn(),
resolveDefaultModelForAgentMock: vi.fn(),
resolvePersistedModelRefMock: vi.fn(),
resolvePersistedSelectedModelRefMock: vi.fn(),
loadSessionStoreMock: vi.fn(),
resolveStorePathMock: vi.fn(),
updateSessionStoreMock: vi.fn(),
@@ -29,7 +29,8 @@ vi.mock("./pi-embedded-runner/runs.js", () => ({
vi.mock("./model-selection.js", () => ({
resolveDefaultModelForAgent: (...args: unknown[]) =>
state.resolveDefaultModelForAgentMock(...args),
resolvePersistedModelRef: (...args: unknown[]) => state.resolvePersistedModelRefMock(...args),
resolvePersistedSelectedModelRef: (...args: unknown[]) =>
state.resolvePersistedSelectedModelRefMock(...args),
}));
vi.mock("../config/sessions/store.js", () => ({
@@ -63,7 +64,7 @@ describe("live model switch", () => {
state.resolveDefaultModelForAgentMock
.mockReset()
.mockReturnValue({ provider: "anthropic", model: "claude-opus-4-6" });
state.resolvePersistedModelRefMock
state.resolvePersistedSelectedModelRefMock
.mockReset()
.mockImplementation(
(params: {
@@ -74,6 +75,21 @@ describe("live model switch", () => {
overrideModel?: string;
}) => {
const defaultProvider = params.defaultProvider.trim();
const overrideProvider = params.overrideProvider?.trim();
const overrideModel = params.overrideModel?.trim();
if (overrideModel) {
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),
};
}
const runtimeProvider = params.runtimeProvider?.trim();
const runtimeModel = params.runtimeModel?.trim();
if (runtimeModel) {
@@ -89,22 +105,7 @@ describe("live model switch", () => {
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),
};
return null;
},
);
state.loadSessionStoreMock.mockReset().mockReturnValue({});

View File

@@ -2,7 +2,10 @@ import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionStore, updateSessionStore } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
import { resolveDefaultModelForAgent, resolvePersistedModelRef } from "./model-selection.js";
import {
resolveDefaultModelForAgent,
resolvePersistedSelectedModelRef,
} from "./model-selection.js";
import {
abortEmbeddedPiRun,
consumeEmbeddedRunModelSwitch,
@@ -35,17 +38,13 @@ export function resolveLiveSessionModelSelection(params: {
agentId,
});
const entry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
const overrideSelection = resolvePersistedModelRef({
defaultProvider: defaultModelRef.provider,
overrideProvider: entry?.providerOverride,
overrideModel: entry?.modelOverride,
});
const runtimeSelection = resolvePersistedModelRef({
const persisted = resolvePersistedSelectedModelRef({
defaultProvider: defaultModelRef.provider,
runtimeProvider: entry?.modelProvider,
runtimeModel: entry?.model,
overrideProvider: entry?.providerOverride,
overrideModel: entry?.modelOverride,
});
const persisted = overrideSelection ?? runtimeSelection;
const provider =
persisted?.provider ?? entry?.providerOverride?.trim() ?? defaultModelRef.provider;
const model = persisted?.model ?? defaultModelRef.model;

View File

@@ -11,7 +11,9 @@ import {
normalizeProviderId,
normalizeProviderIdForAuth,
modelKey,
resolvePersistedOverrideModelRef,
resolvePersistedModelRef,
resolvePersistedSelectedModelRef,
resolveAllowedModelRef,
resolveConfiguredModelRef,
resolveSubagentConfiguredModelSelection,
@@ -328,6 +330,65 @@ describe("model-selection", () => {
});
});
describe("resolvePersistedOverrideModelRef", () => {
it("splits legacy combined override refs when provider is not stored separately", () => {
expect(
resolvePersistedOverrideModelRef({
defaultProvider: "anthropic",
overrideModel: "ollama-beelink2/qwen2.5-coder:7b",
}),
).toEqual({
provider: "ollama-beelink2",
model: "qwen2.5-coder:7b",
});
});
it("normalizes explicit override providers without reparsing away wrapper semantics", () => {
expect(
resolvePersistedOverrideModelRef({
defaultProvider: "anthropic",
overrideProvider: "kimi-coding",
overrideModel: "kimi-code",
}),
).toEqual({
provider: "kimi",
model: "kimi-code",
});
});
});
describe("resolvePersistedSelectedModelRef", () => {
it("prefers explicit overrides ahead of runtime model fields", () => {
expect(
resolvePersistedSelectedModelRef({
defaultProvider: "anthropic",
runtimeProvider: "openai-codex",
runtimeModel: "gpt-5.4",
overrideProvider: "anthropic",
overrideModel: "claude-opus-4-6",
}),
).toEqual({
provider: "anthropic",
model: "claude-opus-4-6",
});
});
it("preserves explicit wrapper providers for vendor-prefixed override models", () => {
expect(
resolvePersistedSelectedModelRef({
defaultProvider: "anthropic",
runtimeProvider: "openrouter",
runtimeModel: "openrouter/free",
overrideProvider: "openrouter",
overrideModel: "anthropic/claude-haiku-4.5",
}),
).toEqual({
provider: "openrouter",
model: "anthropic/claude-haiku-4.5",
});
});
});
describe("inferUniqueProviderFromConfiguredModels", () => {
it("infers provider when configured model match is unique", () => {
const cfg = {

View File

@@ -150,6 +150,30 @@ export function parseModelRef(
return normalizeModelRef(providerRaw, model, options);
}
export function resolvePersistedOverrideModelRef(params: {
defaultProvider: string;
overrideProvider?: string;
overrideModel?: string;
}): ModelRef | null {
const defaultProvider = params.defaultProvider.trim();
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,
}
);
}
/**
* Runtime-first resolver for persisted model metadata.
* Use this when callers intentionally want the last executed model identity.
*/
export function resolvePersistedModelRef(params: {
defaultProvider: string;
runtimeProvider?: string;
@@ -171,19 +195,38 @@ export function resolvePersistedModelRef(params: {
}
);
}
return resolvePersistedOverrideModelRef({
defaultProvider,
overrideProvider: params.overrideProvider,
overrideModel: params.overrideModel,
});
}
const overrideProvider = params.overrideProvider?.trim();
const overrideModel = params.overrideModel?.trim();
if (!overrideModel) {
return null;
/**
* Selected-model resolver for persisted model metadata.
* Use this for control/status/UI surfaces that should honor explicit session
* overrides before falling back to runtime identity.
*/
export function resolvePersistedSelectedModelRef(params: {
defaultProvider: string;
runtimeProvider?: string;
runtimeModel?: string;
overrideProvider?: string;
overrideModel?: string;
}): ModelRef | null {
const override = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: params.overrideProvider,
overrideModel: params.overrideModel,
});
if (override) {
return override;
}
const encodedOverride = overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel;
return (
parseModelRef(encodedOverride, defaultProvider) ?? {
provider: overrideProvider || defaultProvider,
model: overrideModel,
}
);
return resolvePersistedModelRef({
defaultProvider: params.defaultProvider,
runtimeProvider: params.runtimeProvider,
runtimeModel: params.runtimeModel,
});
}
export function inferUniqueProviderFromConfiguredModels(params: {

View File

@@ -11,7 +11,7 @@ import {
normalizeModelRef,
normalizeProviderId,
resolveModelRefFromString,
resolvePersistedModelRef,
resolvePersistedOverrideModelRef,
resolveReasoningDefault,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
@@ -149,7 +149,7 @@ export function resolveStoredModelOverride(params: {
parentSessionKey?: string;
defaultProvider: string;
}): StoredModelOverride | null {
const direct = resolvePersistedModelRef({
const direct = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: params.sessionEntry?.providerOverride,
overrideModel: params.sessionEntry?.modelOverride,
@@ -165,7 +165,7 @@ export function resolveStoredModelOverride(params: {
return null;
}
const parentEntry = params.sessionStore[parentKey];
const parentOverride = resolvePersistedModelRef({
const parentOverride = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: parentEntry?.providerOverride,
overrideModel: parentEntry?.modelOverride,
@@ -336,7 +336,7 @@ 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({
const directStoredOverride = resolvePersistedOverrideModelRef({
defaultProvider,
overrideProvider: sessionEntry?.providerOverride,
overrideModel: sessionEntry?.modelOverride,

View File

@@ -92,4 +92,18 @@ describe("statusSummaryRuntime.resolveSessionModelRef", () => {
model: "gpt-5.4",
});
});
it("prefers explicit overrides ahead of fallback runtime fields", () => {
expect(
statusSummaryRuntime.resolveSessionModelRef(cfg, {
providerOverride: "openai-codex",
modelOverride: "gpt-5.4",
modelProvider: "amazon-bedrock",
model: "minimax.minimax-m2.5",
}),
).toEqual({
provider: "openai-codex",
model: "gpt-5.4",
});
});
});

View File

@@ -1,6 +1,6 @@
import { resolveConfiguredProviderFallback } from "../agents/configured-provider-fallback.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { parseModelRef, resolvePersistedModelRef } from "../agents/model-selection.js";
import { parseModelRef, resolvePersistedSelectedModelRef } 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";
@@ -147,7 +147,7 @@ function resolveSessionModelRef(
agentId,
});
return (
resolvePersistedModelRef({
resolvePersistedSelectedModelRef({
defaultProvider: resolved.provider || DEFAULT_PROVIDER,
runtimeProvider: entry?.modelProvider,
runtimeModel: entry?.model,

View File

@@ -11,6 +11,7 @@ import {
classifySessionKey,
deriveSessionTitle,
listAgentsForGateway,
listSessionsFromStore,
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
parseGroupKey,
@@ -569,7 +570,7 @@ describe("gateway session utils", () => {
});
describe("resolveSessionModelRef", () => {
test("prefers runtime model/provider from session entry", () => {
test("prefers explicit session overrides ahead of runtime model fields", () => {
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
@@ -583,7 +584,7 @@ describe("resolveSessionModelRef", () => {
providerOverride: "anthropic",
});
expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.4" });
expect(resolved).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
});
test("preserves openrouter provider when model contains vendor prefix", () => {
@@ -618,6 +619,26 @@ describe("resolveSessionModelRef", () => {
expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.4" });
});
test("preserves explicit wrapper providers for vendor-prefixed override models", () => {
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s-openrouter-override",
updatedAt: Date.now(),
providerOverride: "openrouter",
modelOverride: "anthropic/claude-haiku-4.5",
modelProvider: "openrouter",
model: "openrouter/free",
});
expect(resolved).toEqual({
provider: "openrouter",
model: "anthropic/claude-haiku-4.5",
});
});
test("falls back to resolved provider for unprefixed legacy runtime model", () => {
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
@@ -652,6 +673,33 @@ describe("resolveSessionModelRef", () => {
});
});
describe("listSessionsFromStore selected model display", () => {
test("shows the selected override model even when a fallback runtime model exists", () => {
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
const result = listSessionsFromStore({
cfg,
storePath: "/tmp/sessions.json",
store: {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
modelProvider: "openai-codex",
model: "gpt-5.4",
} as SessionEntry,
},
opts: {},
});
expect(result.sessions[0]?.modelProvider).toBe("anthropic");
expect(result.sessions[0]?.model).toBe("claude-opus-4-6");
});
});
describe("resolveSessionModelIdentityRef", () => {
const resolveLegacyIdentityRef = (
cfg: OpenClawConfig,

View File

@@ -12,9 +12,9 @@ import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import {
inferUniqueProviderFromConfiguredModels,
parseModelRef,
resolvePersistedModelRef,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolvePersistedSelectedModelRef,
} from "../agents/model-selection.js";
import {
getSessionDisplaySubagentRunByChildSessionKey,
@@ -1033,7 +1033,7 @@ export function resolveSessionModelRef(
defaultModel: DEFAULT_MODEL,
});
const persisted = resolvePersistedModelRef({
const persisted = resolvePersistedSelectedModelRef({
defaultProvider: resolved.provider || DEFAULT_PROVIDER,
runtimeProvider: entry?.modelProvider,
runtimeModel: entry?.model,
@@ -1187,6 +1187,9 @@ export function buildGatewaySessionRow(params: {
const subagentStartedAt = subagentRun ? getSubagentSessionStartedAt(subagentRun) : undefined;
const subagentEndedAt = subagentRun ? subagentRun.endedAt : undefined;
const subagentRuntimeMs = subagentRun ? resolveSessionRuntimeMs(subagentRun, now) : undefined;
const selectedModel = entry?.modelOverride?.trim()
? resolveSessionModelRef(cfg, entry, sessionAgentId)
: null;
const resolvedModel = resolveSessionModelIdentityRef(
cfg,
entry,
@@ -1319,8 +1322,8 @@ export function buildGatewaySessionRow(params: {
parentSessionKey: subagentOwner || entry?.parentSessionKey,
childSessions,
responseUsage: entry?.responseUsage,
modelProvider,
model,
modelProvider: selectedModel?.provider ?? modelProvider,
model: selectedModel?.model ?? model,
contextTokens,
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,