mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 15:50:20 +00:00
fix: separate selected session model resolution
This commit is contained in:
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user