Skip to content

Commit 32ebaa3

Browse files
committed
refactor: share session model resolution helpers
1 parent 67d87ab commit 32ebaa3

17 files changed

Lines changed: 558 additions & 209 deletions

extensions/discord/src/monitor/native-command-ui.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ export async function resolveDiscordNativeChoiceContext(params: {
322322
sessionEntry,
323323
sessionStore,
324324
sessionKey: route.sessionKey,
325+
defaultProvider: fallback.provider,
325326
});
326327
if (!override?.model) {
327328
return {
@@ -357,6 +358,7 @@ function resolveDiscordModelPickerCurrentModel(params: {
357358
sessionEntry,
358359
sessionStore,
359360
sessionKey: params.route.sessionKey,
361+
defaultProvider: params.data.resolvedDefault.provider,
360362
});
361363
if (!override?.model) {
362364
return fallback;

extensions/mattermost/src/mattermost/model-picker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export function resolveMattermostModelPickerCurrentModel(params: {
231231
sessionEntry,
232232
sessionStore,
233233
sessionKey: params.route.sessionKey,
234+
defaultProvider: params.data.resolvedDefault.provider,
234235
});
235236
if (!override?.model) {
236237
return fallback;

extensions/telegram/src/bot-handlers.runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ export const registerTelegramHandlers = ({
347347
sessionEntry: entry,
348348
sessionStore: store,
349349
sessionKey,
350+
defaultProvider: resolveDefaultModelForAgent({
351+
cfg: runtimeCfg,
352+
agentId: route.agentId,
353+
}).provider,
350354
});
351355
if (storedOverride) {
352356
return {

src/agents/live-model-switch.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
66
requestEmbeddedRunModelSwitchMock: vi.fn(),
77
consumeEmbeddedRunModelSwitchMock: vi.fn(),
88
resolveDefaultModelForAgentMock: vi.fn(),
9+
resolvePersistedModelRefMock: vi.fn(),
910
loadSessionStoreMock: vi.fn(),
1011
resolveStorePathMock: vi.fn(),
1112
}));
@@ -24,6 +25,7 @@ vi.mock("./pi-embedded-runner/runs.js", () => ({
2425
vi.mock("./model-selection.js", () => ({
2526
resolveDefaultModelForAgent: (...args: unknown[]) =>
2627
state.resolveDefaultModelForAgentMock(...args),
28+
resolvePersistedModelRef: (...args: unknown[]) => state.resolvePersistedModelRefMock(...args),
2729
}));
2830

2931
vi.mock("../config/sessions.js", () => ({
@@ -46,6 +48,50 @@ describe("live model switch", () => {
4648
state.resolveDefaultModelForAgentMock
4749
.mockReset()
4850
.mockReturnValue({ provider: "anthropic", model: "claude-opus-4-6" });
51+
state.resolvePersistedModelRefMock
52+
.mockReset()
53+
.mockImplementation(
54+
(params: {
55+
defaultProvider: string;
56+
runtimeProvider?: string;
57+
runtimeModel?: string;
58+
overrideProvider?: string;
59+
overrideModel?: string;
60+
}) => {
61+
const defaultProvider = params.defaultProvider.trim();
62+
const runtimeProvider = params.runtimeProvider?.trim();
63+
const runtimeModel = params.runtimeModel?.trim();
64+
if (runtimeModel) {
65+
if (runtimeProvider) {
66+
return { provider: runtimeProvider, model: runtimeModel };
67+
}
68+
const slash = runtimeModel.indexOf("/");
69+
if (slash <= 0 || slash === runtimeModel.length - 1) {
70+
return { provider: defaultProvider, model: runtimeModel };
71+
}
72+
return {
73+
provider: runtimeModel.slice(0, slash),
74+
model: runtimeModel.slice(slash + 1),
75+
};
76+
}
77+
const overrideProvider = params.overrideProvider?.trim();
78+
const overrideModel = params.overrideModel?.trim();
79+
if (!overrideModel) {
80+
return null;
81+
}
82+
if (overrideProvider) {
83+
return { provider: overrideProvider, model: overrideModel };
84+
}
85+
const slash = overrideModel.indexOf("/");
86+
if (slash <= 0 || slash === overrideModel.length - 1) {
87+
return { provider: defaultProvider, model: overrideModel };
88+
}
89+
return {
90+
provider: overrideModel.slice(0, slash),
91+
model: overrideModel.slice(slash + 1),
92+
};
93+
},
94+
);
4995
state.loadSessionStoreMock.mockReset().mockReturnValue({});
5096
state.resolveStorePathMock.mockReset().mockReturnValue("/tmp/session-store.json");
5197
});
@@ -112,6 +158,57 @@ describe("live model switch", () => {
112158
});
113159
});
114160

161+
it("splits legacy combined session overrides when providerOverride is missing", async () => {
162+
state.loadSessionStoreMock.mockReturnValue({
163+
main: {
164+
modelOverride: "ollama-beelink2/qwen2.5-coder:7b",
165+
},
166+
});
167+
168+
const { resolveLiveSessionModelSelection } = await loadModule();
169+
170+
expect(
171+
resolveLiveSessionModelSelection({
172+
cfg: { session: { store: "/tmp/custom-store.json" } },
173+
sessionKey: "main",
174+
agentId: "reply",
175+
defaultProvider: "anthropic",
176+
defaultModel: "claude-opus-4-6",
177+
}),
178+
).toEqual({
179+
provider: "ollama-beelink2",
180+
model: "qwen2.5-coder:7b",
181+
authProfileId: undefined,
182+
authProfileIdSource: undefined,
183+
});
184+
});
185+
186+
it("preserves provider when runtime model is a vendor-prefixed OpenRouter id", async () => {
187+
state.loadSessionStoreMock.mockReturnValue({
188+
main: {
189+
modelProvider: "openrouter",
190+
model: "anthropic/claude-haiku-4.5",
191+
},
192+
});
193+
194+
const { resolveLiveSessionModelSelection } = await loadModule();
195+
196+
expect(
197+
resolveLiveSessionModelSelection({
198+
cfg: { session: { store: "/tmp/custom-store.json" } },
199+
sessionKey: "main",
200+
agentId: "reply",
201+
defaultProvider: "anthropic",
202+
defaultModel: "claude-opus-4-6",
203+
}),
204+
).toEqual({
205+
provider: "openrouter",
206+
model: "anthropic/claude-haiku-4.5",
207+
authProfileId: undefined,
208+
authProfileIdSource: undefined,
209+
});
210+
});
211+
115212
it("queues a live switch only when an active run was aborted", async () => {
116213
state.abortEmbeddedPiRunMock.mockReturnValue(true);
117214

src/agents/live-model-switch.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
2-
import { resolveDefaultModelForAgent } from "./model-selection.js";
2+
import { resolveDefaultModelForAgent, resolvePersistedModelRef } from "./model-selection.js";
33
import {
44
consumeEmbeddedRunModelSwitch,
55
requestEmbeddedRunModelSwitch,
@@ -48,10 +48,16 @@ export function resolveLiveSessionModelSelection(params: {
4848
agentId,
4949
});
5050
const entry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
51-
const runtimeProvider = entry?.modelProvider?.trim();
52-
const runtimeModel = entry?.model?.trim();
53-
const provider = runtimeProvider || entry?.providerOverride?.trim() || defaultModelRef.provider;
54-
const model = runtimeModel || entry?.modelOverride?.trim() || defaultModelRef.model;
51+
const persisted = resolvePersistedModelRef({
52+
defaultProvider: defaultModelRef.provider,
53+
runtimeProvider: entry?.modelProvider,
54+
runtimeModel: entry?.model,
55+
overrideProvider: entry?.providerOverride,
56+
overrideModel: entry?.modelOverride,
57+
});
58+
const provider =
59+
persisted?.provider ?? entry?.providerOverride?.trim() ?? defaultModelRef.provider;
60+
const model = persisted?.model ?? defaultModelRef.model;
5561
const authProfileId = entry?.authProfileOverride?.trim() || undefined;
5662
return {
5763
provider,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
resolveModelDisplayName,
4+
resolveModelDisplayRef,
5+
resolveSessionInfoModelSelection,
6+
} from "./model-selection-display.js";
7+
8+
describe("model-selection-display", () => {
9+
describe("resolveModelDisplayRef", () => {
10+
it("keeps explicit runtime slash-bearing ids unchanged for display", () => {
11+
expect(
12+
resolveModelDisplayRef({
13+
runtimeModel: "anthropic/claude-haiku-4.5",
14+
}),
15+
).toBe("anthropic/claude-haiku-4.5");
16+
});
17+
18+
it("combines separate runtime provider and model ids", () => {
19+
expect(
20+
resolveModelDisplayRef({
21+
runtimeProvider: "openai",
22+
runtimeModel: "gpt-5.2",
23+
}),
24+
).toBe("openai/gpt-5.2");
25+
});
26+
27+
it("falls back to override values when runtime values are absent", () => {
28+
expect(
29+
resolveModelDisplayRef({
30+
overrideProvider: "openrouter",
31+
overrideModel: "anthropic/claude-sonnet-4-5",
32+
}),
33+
).toBe("anthropic/claude-sonnet-4-5");
34+
});
35+
});
36+
37+
describe("resolveModelDisplayName", () => {
38+
it("renders the trailing model segment for compact UI labels", () => {
39+
expect(
40+
resolveModelDisplayName({
41+
runtimeProvider: "openrouter",
42+
runtimeModel: "anthropic/claude-sonnet-4-5",
43+
}),
44+
).toBe("claude-sonnet-4-5");
45+
});
46+
47+
it("returns a stable empty-state label", () => {
48+
expect(resolveModelDisplayName({})).toBe("model n/a");
49+
});
50+
});
51+
52+
describe("resolveSessionInfoModelSelection", () => {
53+
it("keeps partial runtime patches merged with current state", () => {
54+
expect(
55+
resolveSessionInfoModelSelection({
56+
currentProvider: "anthropic",
57+
currentModel: "claude-sonnet-4-6",
58+
entryModel: "claude-opus-4-6",
59+
}),
60+
).toEqual({
61+
modelProvider: "anthropic",
62+
model: "claude-opus-4-6",
63+
});
64+
});
65+
66+
it("keeps override ids attached to the current provider when no override provider is stored", () => {
67+
expect(
68+
resolveSessionInfoModelSelection({
69+
currentProvider: "anthropic",
70+
currentModel: "claude-sonnet-4-6",
71+
overrideModel: "ollama-beelink2/qwen2.5-coder:7b",
72+
}),
73+
).toEqual({
74+
modelProvider: "anthropic",
75+
model: "ollama-beelink2/qwen2.5-coder:7b",
76+
});
77+
});
78+
79+
it("keeps the current provider for slash-bearing override ids when provider is already known", () => {
80+
expect(
81+
resolveSessionInfoModelSelection({
82+
currentProvider: "openrouter",
83+
currentModel: "openrouter/auto",
84+
overrideModel: "anthropic/claude-haiku-4.5",
85+
}),
86+
).toEqual({
87+
modelProvider: "openrouter",
88+
model: "anthropic/claude-haiku-4.5",
89+
});
90+
});
91+
});
92+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
type ModelDisplaySelectionParams = {
2+
runtimeProvider?: string | null;
3+
runtimeModel?: string | null;
4+
overrideProvider?: string | null;
5+
overrideModel?: string | null;
6+
fallbackModel?: string | null;
7+
};
8+
9+
export function resolveModelDisplayRef(params: ModelDisplaySelectionParams): string | undefined {
10+
const runtimeModel = params.runtimeModel?.trim();
11+
const runtimeProvider = params.runtimeProvider?.trim();
12+
if (runtimeModel) {
13+
if (runtimeModel.includes("/")) {
14+
return runtimeModel;
15+
}
16+
if (runtimeProvider) {
17+
return `${runtimeProvider}/${runtimeModel}`;
18+
}
19+
return runtimeModel;
20+
}
21+
if (runtimeProvider) {
22+
return runtimeProvider;
23+
}
24+
25+
const overrideModel = params.overrideModel?.trim();
26+
const overrideProvider = params.overrideProvider?.trim();
27+
if (overrideModel) {
28+
if (overrideModel.includes("/")) {
29+
return overrideModel;
30+
}
31+
if (overrideProvider) {
32+
return `${overrideProvider}/${overrideModel}`;
33+
}
34+
return overrideModel;
35+
}
36+
if (overrideProvider) {
37+
return overrideProvider;
38+
}
39+
40+
const fallbackModel = params.fallbackModel?.trim();
41+
return fallbackModel || undefined;
42+
}
43+
44+
export function resolveModelDisplayName(params: ModelDisplaySelectionParams): string {
45+
const modelRef = resolveModelDisplayRef(params);
46+
if (!modelRef) {
47+
return "model n/a";
48+
}
49+
const slash = modelRef.lastIndexOf("/");
50+
if (slash >= 0 && slash < modelRef.length - 1) {
51+
return modelRef.slice(slash + 1);
52+
}
53+
return modelRef;
54+
}
55+
56+
type SessionInfoModelSelectionParams = {
57+
currentProvider?: string | null;
58+
currentModel?: string | null;
59+
entryProvider?: string | null;
60+
entryModel?: string | null;
61+
overrideProvider?: string | null;
62+
overrideModel?: string | null;
63+
};
64+
65+
export function resolveSessionInfoModelSelection(params: SessionInfoModelSelectionParams): {
66+
modelProvider?: string;
67+
model?: string;
68+
} {
69+
if (params.entryProvider !== undefined || params.entryModel !== undefined) {
70+
return {
71+
modelProvider: params.entryProvider ?? params.currentProvider ?? undefined,
72+
model: params.entryModel ?? params.currentModel ?? undefined,
73+
};
74+
}
75+
76+
const overrideModel = params.overrideModel?.trim();
77+
if (overrideModel) {
78+
const overrideProvider = params.overrideProvider?.trim();
79+
const currentProvider = params.currentProvider ?? undefined;
80+
return {
81+
modelProvider: overrideProvider || currentProvider,
82+
model: overrideModel,
83+
};
84+
}
85+
86+
return {
87+
modelProvider: params.currentProvider ?? undefined,
88+
model: params.currentModel ?? undefined,
89+
};
90+
}

0 commit comments

Comments
 (0)