diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index f657764f313..9fc62ba8e2c 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -285,6 +285,104 @@ describe("model-selection", () => { ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, }); }); + + it("rejects non-alias direct model ids when strict mode is enabled", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ]; + + const result = resolveAllowedModelRef({ + cfg, + catalog, + raw: "claude-sonnet-4-6", + defaultProvider: "openai", + defaultModel: "gpt-5.2", + requireProviderOrAlias: true, + }); + + expect(result).toEqual({ + error: "invalid model: claude-sonnet-4-6 (use provider/model or alias)", + }); + }); + + it("accepts aliases when strict mode is enabled", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ]; + + const result = resolveAllowedModelRef({ + cfg, + catalog, + raw: "sonnet", + defaultProvider: "openai", + defaultModel: "gpt-5.2", + requireProviderOrAlias: true, + }); + + expect(result).toEqual({ + key: "anthropic/claude-sonnet-4-6", + ref: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }); + }); + + it("accepts provider-prefixed refs whose model id contains slashes in strict mode", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "vercel-ai-gateway/anthropic/claude-opus-4.6": { alias: "gateway-opus" }, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { + provider: "vercel-ai-gateway", + id: "anthropic/claude-opus-4.6", + name: "Gateway Claude Opus 4.6", + }, + ]; + + const result = resolveAllowedModelRef({ + cfg, + catalog, + raw: "vercel-ai-gateway/anthropic/claude-opus-4.6", + defaultProvider: "openai", + defaultModel: "gpt-5.2", + requireProviderOrAlias: true, + }); + + expect(result).toEqual({ + key: "vercel-ai-gateway/anthropic/claude-opus-4.6", + ref: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }); + }); }); describe("resolveModelRefFromString", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index cf66d1d1c68..c1b7a254fb3 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -514,6 +514,7 @@ export function resolveAllowedModelRef(params: { raw: string; defaultProvider: string; defaultModel?: string; + requireProviderOrAlias?: boolean; }): | { ref: ModelRef; key: string } | { @@ -536,6 +537,9 @@ export function resolveAllowedModelRef(params: { if (!resolved) { return { error: `invalid model: ${trimmed}` }; } + if (params.requireProviderOrAlias === true && !resolved.alias && !trimmed.includes("/")) { + return { error: `invalid model: ${trimmed} (use provider/model or alias)` }; + } const status = getModelRefStatus({ cfg: params.cfg, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 357d1f4e563..5dcd5b127f0 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js"; +import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js"; +import { resolveSelectedAndActiveModel } from "../../auto-reply/model-runtime.js"; import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue.js"; import { loadConfig } from "../../config/config.js"; @@ -41,7 +43,6 @@ import { pruneLegacyStoreKeys, readSessionPreviewItemsFromTranscript, resolveGatewaySessionStoreTarget, - resolveSessionModelRef, resolveSessionTranscriptCandidates, type SessionsPatchResult, type SessionsPreviewEntry, @@ -324,15 +325,26 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const parsed = parseAgentSessionKey(target.canonicalKey ?? key); const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); - const resolved = resolveSessionModelRef(cfg, applied.entry, agentId); + const agentDefault = resolveDefaultModelForAgent({ cfg, agentId }); + const selectedProvider = applied.entry.providerOverride?.trim() || agentDefault.provider; + const selectedModel = applied.entry.modelOverride?.trim() || agentDefault.model; + const selectedActive = resolveSelectedAndActiveModel({ + selectedProvider, + selectedModel, + sessionEntry: applied.entry, + }); const result: SessionsPatchResult = { ok: true, path: storePath, key: target.canonicalKey, entry: applied.entry, resolved: { - modelProvider: resolved.provider, - model: resolved.model, + modelProvider: selectedActive.active.provider, + model: selectedActive.active.model, + selectedModelProvider: selectedActive.selected.provider, + selectedModel: selectedActive.selected.model, + activeModelProvider: selectedActive.active.provider, + activeModel: selectedActive.active.model, }, }; respond(true, result, undefined); diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index b05cf2220ed..52cc013d06c 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -419,6 +419,14 @@ describe("gateway server sessions", () => { const modelPatched = await rpcReq<{ ok: true; entry: { modelOverride?: string; providerOverride?: string }; + resolved?: { + modelProvider?: string; + model?: string; + selectedModelProvider?: string; + selectedModel?: string; + activeModelProvider?: string; + activeModel?: string; + }; }>(ws, "sessions.patch", { key: "agent:main:main", model: "openai/gpt-test-a", @@ -426,6 +434,10 @@ describe("gateway server sessions", () => { expect(modelPatched.ok).toBe(true); expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); + expect(modelPatched.payload?.resolved?.selectedModelProvider).toBe("openai"); + expect(modelPatched.payload?.resolved?.selectedModel).toBe("gpt-test-a"); + expect(modelPatched.payload?.resolved?.activeModelProvider).toBe("anthropic"); + expect(modelPatched.payload?.resolved?.activeModel).toBe("claude-sonnet-4-6"); const compacted = await rpcReq<{ ok: true; compacted: boolean }>(ws, "sessions.compact", { key: "agent:main:main", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c1505642742..4a7e91c9a4e 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -846,6 +846,8 @@ export function listSessionsFromStore(params: { responseUsage: entry?.responseUsage, modelProvider, model, + providerOverride: entry?.providerOverride, + modelOverride: entry?.modelOverride, contextTokens: entry?.contextTokens, deliveryContext: deliveryFields.deliveryContext, lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 233a3d7c782..605e89cbf28 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -37,6 +37,8 @@ export type GatewaySessionRow = { responseUsage?: "on" | "off" | "tokens" | "full"; modelProvider?: string; model?: string; + providerOverride?: string; + modelOverride?: string; contextTokens?: number; deliveryContext?: DeliveryContext; lastChannel?: SessionEntry["lastChannel"]; @@ -88,5 +90,9 @@ export type SessionsPatchResult = { resolved?: { modelProvider?: string; model?: string; + selectedModelProvider?: string; + selectedModel?: string; + activeModelProvider?: string; + activeModel?: string; }; }; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 6bf20d32641..2f2f1f8725e 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -243,6 +243,104 @@ describe("gateway sessions patch", () => { expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); }); + test("rejects non-alias direct model ids from sessions.patch", async () => { + const store: Record = {}; + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", model: "claude-sonnet-4-6" }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ], + }); + + expect(res.ok).toBe(false); + if (res.ok) { + return; + } + expect(res.error.message).toContain("invalid model: claude-sonnet-4-6"); + }); + + test("accepts alias refs in strict sessions.patch parsing", async () => { + const store: Record = {}; + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", model: "sonnet" }, + loadGatewayModelCatalog: async () => [ + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.providerOverride).toBe("anthropic"); + expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); + }); + + test("accepts provider-prefixed refs with slash-containing model ids in strict sessions.patch parsing", async () => { + const store: Record = {}; + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.2" }, + models: { + "vercel-ai-gateway/anthropic/claude-opus-4.6": { alias: "gateway-opus" }, + }, + }, + }, + } as OpenClawConfig; + + const res = await applySessionsPatchToStore({ + cfg, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", model: "vercel-ai-gateway/anthropic/claude-opus-4.6" }, + loadGatewayModelCatalog: async () => [ + { + provider: "vercel-ai-gateway", + id: "anthropic/claude-opus-4.6", + name: "Gateway Claude Opus 4.6", + }, + ], + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.providerOverride).toBe("vercel-ai-gateway"); + expect(res.entry.modelOverride).toBe("anthropic/claude-opus-4.6"); + }); + test("accepts explicit allowlisted refs absent from bundled catalog", async () => { const store: Record = {}; const cfg = { diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index d55cf2cf1a4..d6b5cd8848b 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -304,6 +304,7 @@ export async function applySessionsPatchToStore(params: { raw: trimmed, defaultProvider: resolvedDefault.provider, defaultModel: subagentModelHint ?? resolvedDefault.model, + requireProviderOrAlias: true, }); if ("error" in resolved) { return invalid(resolved.error); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index f55bbf5f354..c6777a699ee 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -67,6 +67,8 @@ export type GatewaySessionList = { updatedAt?: number | null; sendPolicy?: string; responseUsage?: ResponseUsageMode; + providerOverride?: string; + modelOverride?: string; label?: string; provider?: string; groupChannel?: string; diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 067222811be..2bed6c9e92e 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -111,4 +111,92 @@ describe("tui session actions", () => { expect(updateFooter).toHaveBeenCalledTimes(2); expect(requestRender).toHaveBeenCalledTimes(2); }); + + it("keeps selected model visible across refresh when runtime model still lags", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: "anthropic", + model: "claude-opus-4-6", + }, + sessions: [ + { + key: "agent:main:main", + updatedAt: 10, + modelProvider: "anthropic", + model: "claude-opus-4-6", + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-5", + }, + ], + }); + + const state: TuiStateAccess = { + agentDefaultId: "main", + sessionMainKey: "agent:main:main", + sessionScope: "global", + agents: [], + currentAgentId: "main", + currentSessionKey: "agent:main:main", + currentSessionId: null, + activeChatRunId: null, + historyLoaded: false, + sessionInfo: {}, + initialSessionApplied: true, + isConnected: true, + autoMessageSent: false, + toolsExpanded: false, + showThinking: false, + connectionStatus: "connected", + activityStatus: "idle", + statusTimeout: null, + lastCtrlCAt: 0, + }; + + const { applySessionInfoFromPatch, refreshSessionInfo } = createSessionActions({ + client: { listSessions } as unknown as GatewayChatClient, + chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog, + tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI, + opts: {}, + state, + agentNames: new Map(), + initialSessionInput: "", + initialSessionAgentId: null, + resolveSessionKey: vi.fn(), + updateHeader: vi.fn(), + updateFooter: vi.fn(), + updateAutocompleteProvider: vi.fn(), + setActivityStatus: vi.fn(), + }); + + applySessionInfoFromPatch({ + ok: true, + path: "/tmp/sessions.json", + key: "agent:main:main", + entry: { + sessionId: "sess-main", + updatedAt: 10, + modelProvider: "anthropic", + model: "claude-opus-4-6", + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-5", + }, + resolved: { + modelProvider: "anthropic", + model: "claude-opus-4-6", + selectedModelProvider: "anthropic", + selectedModel: "claude-sonnet-4-5", + activeModelProvider: "anthropic", + activeModel: "claude-opus-4-6", + }, + }); + expect(state.sessionInfo.modelProvider).toBe("anthropic"); + expect(state.sessionInfo.model).toBe("claude-sonnet-4-5"); + + await refreshSessionInfo(); + expect(state.sessionInfo.modelProvider).toBe("anthropic"); + expect(state.sessionInfo.model).toBe("claude-sonnet-4-5"); + }); }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 7255b712936..c04bc1ba5bc 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -115,17 +115,18 @@ export function createSessionActions(context: SessionActionContext) { }; const resolveModelSelection = (entry?: SessionInfoEntry) => { + const overrideModel = entry?.modelOverride?.trim(); + if (overrideModel) { + const overrideProvider = + entry?.providerOverride?.trim() || entry?.modelProvider || state.sessionInfo.modelProvider; + return { modelProvider: overrideProvider, model: overrideModel }; + } 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, @@ -270,14 +271,21 @@ export function createSessionActions(context: SessionActionContext) { updateHeader(); } const resolved = result.resolved; - const entry = - resolved && (resolved.modelProvider || resolved.model) + const hasSelected = Boolean(resolved?.selectedModelProvider || resolved?.selectedModel); + const entry: SessionInfoEntry = { + ...result.entry, + ...(hasSelected ? { - ...result.entry, - modelProvider: resolved.modelProvider ?? result.entry.modelProvider, - model: resolved.model ?? result.entry.model, + modelProvider: + resolved?.selectedModelProvider ?? + result.entry.providerOverride ?? + result.entry.modelProvider, + model: resolved?.selectedModel ?? result.entry.modelOverride ?? result.entry.model, + providerOverride: resolved?.selectedModelProvider ?? result.entry.providerOverride, + modelOverride: resolved?.selectedModel ?? result.entry.modelOverride, } - : result.entry; + : {}), + }; applySessionInfo({ entry, force: true }); };