mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
refactor: share session model resolution helpers
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -231,6 +231,7 @@ export function resolveMattermostModelPickerCurrentModel(params: {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: params.route.sessionKey,
|
||||
defaultProvider: params.data.resolvedDefault.provider,
|
||||
});
|
||||
if (!override?.model) {
|
||||
return fallback;
|
||||
|
||||
@@ -347,6 +347,10 @@ export const registerTelegramHandlers = ({
|
||||
sessionEntry: entry,
|
||||
sessionStore: store,
|
||||
sessionKey,
|
||||
defaultProvider: resolveDefaultModelForAgent({
|
||||
cfg: runtimeCfg,
|
||||
agentId: route.agentId,
|
||||
}).provider,
|
||||
});
|
||||
if (storedOverride) {
|
||||
return {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
92
src/agents/model-selection-display.test.ts
Normal file
92
src/agents/model-selection-display.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
90
src/agents/model-selection-display.ts
Normal file
90
src/agents/model-selection-display.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user