refactor: share session model resolution helpers

This commit is contained in:
Peter Steinberger
2026-04-03 19:35:32 +09:00
parent 67d87abf7c
commit 32ebaa3757
17 changed files with 558 additions and 209 deletions

View File

@@ -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;

View File

@@ -231,6 +231,7 @@ export function resolveMattermostModelPickerCurrentModel(params: {
sessionEntry,
sessionStore,
sessionKey: params.route.sessionKey,
defaultProvider: params.data.resolvedDefault.provider,
});
if (!override?.model) {
return fallback;

View File

@@ -347,6 +347,10 @@ export const registerTelegramHandlers = ({
sessionEntry: entry,
sessionStore: store,
sessionKey,
defaultProvider: resolveDefaultModelForAgent({
cfg: runtimeCfg,
agentId: route.agentId,
}).provider,
});
if (storedOverride) {
return {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",
});
});
});
});

View File

@@ -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,
};
}

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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 } : {}),

View File

@@ -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) {

View File

@@ -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", () => {

View File

@@ -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<string, SessionEntry>;
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

View File

@@ -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",
});
});
});

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {