diff --git a/CHANGELOG.md b/CHANGELOG.md index f79ed0e3578..95d9e5eefa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro. - MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13. +- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq. - PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement. - Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc. - Talk/voice: unify realtime relay, transcription relay, managed-room handoff, Voice Call, Google Meet, VoiceClaw, and native clients around a shared Talk session controller and add the Gateway-managed `talk.session.*` RPC surface. diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index e5e7e64d63f..87372fbb4c9 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1440,6 +1440,44 @@ describe("listSessionsFromStore selected model display", () => { expect(result.sessions[0]?.modelProvider).toBe("anthropic"); expect(result.sessions[0]?.model).toBe("claude-opus-4-7"); }); + + test("uses qualified selected defaults for rows without runtime model metadata", () => { + const cfg = { + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + { id: "main", model: { primary: "anthropic/claude-sonnet-4-6" } }, + { + id: "review", + model: { primary: "vercel-ai-gateway/anthropic/claude-haiku-4-5" }, + }, + ], + }, + } as OpenClawConfig; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: 2, + } as SessionEntry, + "agent:review:review": { + sessionId: "sess-review", + updatedAt: 1, + } as SessionEntry, + }, + opts: {}, + }); + + expect( + result.sessions.map((session) => [session.key, session.modelProvider, session.model]), + ).toEqual([ + ["agent:main:main", "anthropic", "claude-sonnet-4-6"], + ["agent:review:review", "vercel-ai-gateway", "anthropic/claude-haiku-4-5"], + ]); + }); }); describe("resolveSessionModelIdentityRef", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 6c1422da410..4a2b35b1b81 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -19,6 +19,7 @@ import { import { inferUniqueProviderFromConfiguredModels, isCliProvider, + normalizeProviderId, normalizeStoredOverrideModel, parseModelRef, resolveConfiguredModelRef, @@ -77,6 +78,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeOptionalLowercaseString, + resolvePrimaryStringValue, } from "../shared/string-coerce.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; @@ -373,6 +375,7 @@ type SessionListRowContext = { subagentRuns: ReturnType; storeChildSessionsByKey: Map; selectedModelByOverrideRef: Map>; + modelIdentityByEntryKey: Map>; thinkingLevelsByModelRef: Map>; }; @@ -491,6 +494,7 @@ function buildSessionListRowContext(params: { subagentRuns, storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns), selectedModelByOverrideRef: new Map(), + modelIdentityByEntryKey: new Map(), thinkingLevelsByModelRef: new Map(), }; } @@ -499,6 +503,116 @@ function createSessionRowModelCacheKey(provider: string | undefined, model: stri return `${normalizeLowercaseStringOrEmpty(provider)}\0${normalizeOptionalString(model) ?? ""}`; } +function parseQualifiedModelRefForSessionList( + raw: string | undefined, +): { provider: string; model: string } | undefined { + const trimmed = normalizeOptionalString(raw); + const slash = trimmed?.indexOf("/") ?? -1; + if (!trimmed || slash <= 0 || slash === trimmed.length - 1) { + return undefined; + } + const provider = normalizeProviderId(trimmed.slice(0, slash).trim()); + const model = normalizeOptionalString(trimmed.slice(slash + 1)); + return model ? { provider, model } : undefined; +} + +function createSessionDefaultModelCacheKey(cfg: OpenClawConfig, agentId?: string): string { + const normalizedAgentId = normalizeAgentId(agentId); + const primary = normalizedAgentId + ? resolveAgentEffectiveModelPrimary(cfg, normalizedAgentId) + : resolvePrimaryStringValue(cfg.agents?.defaults?.model); + return normalizeOptionalString(primary) ?? ""; +} + +function createSessionEntryModelCacheKey(params: { + cfg: OpenClawConfig; + agentId?: string; + entry?: + | SessionEntry + | Pick; + fallbackModelRef?: string; +}) { + return [ + createSessionDefaultModelCacheKey(params.cfg, params.agentId), + normalizeOptionalString(params.entry?.providerOverride) ?? "", + normalizeOptionalString(params.entry?.modelOverride) ?? "", + normalizeOptionalString(params.entry?.modelProvider) ?? "", + normalizeOptionalString(params.entry?.model) ?? "", + normalizeOptionalString(params.fallbackModelRef) ?? "", + ].join("\0"); +} + +function resolveSessionDefaultModelRefForRow( + cfg: OpenClawConfig, + agentId?: string, +): { provider: string; model: string } { + if (agentId) { + const primary = resolveAgentEffectiveModelPrimary(cfg, agentId)?.trim(); + const parsed = parseQualifiedModelRefForSessionList(primary); + if (parsed) { + return parsed; + } + return resolveDefaultModelForAgent({ cfg, agentId }); + } + return resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); +} + +function resolveSessionRowModelIdentityRef(params: { + cfg: OpenClawConfig; + entry?: + | SessionEntry + | Pick; + agentId: string; + fallbackModelRef?: string; + rowContext?: SessionListRowContext; +}): ReturnType { + if (!params.rowContext) { + return resolveSessionModelIdentityRef( + params.cfg, + params.entry, + params.agentId, + params.fallbackModelRef, + ); + } + const key = createSessionEntryModelCacheKey({ + cfg: params.cfg, + agentId: params.agentId, + entry: params.entry, + fallbackModelRef: params.fallbackModelRef, + }); + const cached = params.rowContext.modelIdentityByEntryKey.get(key); + if (cached) { + return cached; + } + + const runtimeModel = normalizeOptionalString(params.entry?.model); + const runtimeProvider = normalizeOptionalString(params.entry?.modelProvider); + if ( + !runtimeModel && + !runtimeProvider && + !normalizeOptionalString(params.fallbackModelRef) && + !normalizeOptionalString(params.entry?.providerOverride) && + !normalizeOptionalString(params.entry?.modelOverride) + ) { + const resolved = resolveSessionDefaultModelRefForRow(params.cfg, params.agentId); + params.rowContext.modelIdentityByEntryKey.set(key, resolved); + return resolved; + } + + const resolved = resolveSessionModelIdentityRef( + params.cfg, + params.entry, + params.agentId, + params.fallbackModelRef, + ); + params.rowContext.modelIdentityByEntryKey.set(key, resolved); + return resolved; +} + function resolveSessionSelectedModelRef(params: { cfg: OpenClawConfig; entry?: SessionEntry; @@ -1578,12 +1692,13 @@ export function buildGatewaySessionRow(params: { agentId: sessionAgentId, rowContext, }); - const resolvedModel = resolveSessionModelIdentityRef( + const resolvedModel = resolveSessionRowModelIdentityRef({ cfg, entry, - sessionAgentId, - subagentRun?.model, - ); + agentId: sessionAgentId, + fallbackModelRef: subagentRun?.model, + rowContext, + }); const runtimeModelPresent = Boolean(entry?.model?.trim()) || Boolean(entry?.modelProvider?.trim()); const needsTranscriptTotalTokens =