From 177386ed7318d5a8c756c536c1cb9791737435fb Mon Sep 17 00:00:00 2001 From: byungsker <72309817+lbo728@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:36:27 +0900 Subject: [PATCH] fix(tui): resolve wrong provider prefix when session has model without modelProvider (#25874) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f0953a72845fb3f9e8745cb6ab476cea7a5cd98b Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 21 ++ src/agents/model-selection.test.ts | 80 ++++++ src/agents/model-selection.ts | 36 +++ src/commands/agent/session-store.ts | 12 +- src/config/sessions/sessions.test.ts | 37 +++ src/config/sessions/store.ts | 8 +- src/config/sessions/types.ts | 74 +++++- src/cron/isolated-agent/run.ts | 12 +- src/gateway/server-methods/sessions.ts | 1 + ...sessions.gateway-server-sessions-a.test.ts | 6 +- src/gateway/session-utils.test.ts | 247 ++++++++++++++++++ src/gateway/session-utils.ts | 38 ++- 12 files changed, 559 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d341f29f63..cfd0c551483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,27 @@ Docs: https://docs.openclaw.ai - Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting. - Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting. - Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting. +- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. +- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis. +- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis. +- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg. +- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. +- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728. +- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. +- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr. +- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr. +- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18. +- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi. +- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr. +- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728. +- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. +- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. +- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. +- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. +- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky. - Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey. ## 2026.2.23 diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 78c21465773..8a80768c0db 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { buildAllowedModelSet, + inferUniqueProviderFromConfiguredModels, parseModelRef, buildModelAliasIndex, modelKey, @@ -134,6 +135,85 @@ describe("model-selection", () => { }); }); + describe("inferUniqueProviderFromConfiguredModels", () => { + it("infers provider when configured model match is unique", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "claude-sonnet-4-6", + }), + ).toBe("anthropic"); + }); + + it("returns undefined when configured matches are ambiguous", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "claude-sonnet-4-6", + }), + ).toBeUndefined(); + }); + + it("returns undefined for provider-prefixed model ids", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "anthropic/claude-sonnet-4-6", + }), + ).toBeUndefined(); + }); + + it("infers provider for slash-containing model id when allowlist match is unique", () => { + const cfg = { + agents: { + defaults: { + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + expect( + inferUniqueProviderFromConfiguredModels({ + cfg, + model: "anthropic/claude-sonnet-4-6", + }), + ).toBe("vercel-ai-gateway"); + }); + }); + describe("buildModelAliasIndex", () => { it("should build alias index from config", () => { const cfg: Partial = { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index d37609af368..ac45200039f 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -171,6 +171,42 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef | return normalizeModelRef(providerRaw, model); } +export function inferUniqueProviderFromConfiguredModels(params: { + cfg: OpenClawConfig; + model: string; +}): string | undefined { + const model = params.model.trim(); + if (!model) { + return undefined; + } + const configuredModels = params.cfg.agents?.defaults?.models; + if (!configuredModels) { + return undefined; + } + const normalized = model.toLowerCase(); + const providers = new Set(); + for (const key of Object.keys(configuredModels)) { + const ref = key.trim(); + if (!ref || !ref.includes("/")) { + continue; + } + const parsed = parseModelRef(ref, DEFAULT_PROVIDER); + if (!parsed) { + continue; + } + if (parsed.model === model || parsed.model.toLowerCase() === normalized) { + providers.add(parsed.provider); + if (providers.size > 1) { + return undefined; + } + } + } + if (providers.size !== 1) { + return undefined; + } + return providers.values().next().value; +} + export function normalizeModelSelection(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); diff --git a/src/commands/agent/session-store.ts b/src/commands/agent/session-store.ts index 638a1c8eade..21574090c12 100644 --- a/src/commands/agent/session-store.ts +++ b/src/commands/agent/session-store.ts @@ -4,7 +4,11 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; +import { + setSessionRuntimeModel, + type SessionEntry, + updateSessionStore, +} from "../../config/sessions.js"; type RunResult = Awaited< ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]> @@ -58,10 +62,12 @@ export async function updateSessionStoreAfterAgentRun(params: { ...entry, sessionId, updatedAt: Date.now(), - modelProvider: providerUsed, - model: modelUsed, contextTokens, }; + setSessionRuntimeModel(next, { + provider: providerUsed, + model: modelUsed, + }); if (isCliProvider(providerUsed, cfg)) { const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); if (cliSessionId) { diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 1bcbac5711c..10ac5a13b45 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { clearSessionStoreCacheForTest, loadSessionStore, + mergeSessionEntry, resolveAndPersistSessionFile, updateSessionStore, } from "../sessions.js"; @@ -215,6 +216,42 @@ describe("session store lock (Promise chain mutex)", () => { const store = loadSessionStore(storePath); expect(store[key]?.modelOverride).toBe("recovered"); }); + + it("clears stale runtime provider when model is patched without provider", () => { + const merged = mergeSessionEntry( + { + sessionId: "sess-runtime", + updatedAt: 100, + modelProvider: "anthropic", + model: "claude-opus-4-6", + }, + { + model: "gpt-5.2", + }, + ); + expect(merged.model).toBe("gpt-5.2"); + expect(merged.modelProvider).toBeUndefined(); + }); + + it("normalizes orphan modelProvider fields at store write boundary", async () => { + const key = "agent:main:orphan-provider"; + const { storePath } = await makeTmpStore({ + [key]: { + sessionId: "sess-orphan", + updatedAt: 100, + modelProvider: "anthropic", + }, + }); + + await updateSessionStore(storePath, async (store) => { + const entry = store[key]; + entry.updatedAt = Date.now(); + }); + + const store = loadSessionStore(storePath); + expect(store[key]?.modelProvider).toBeUndefined(); + expect(store[key]?.model).toBeUndefined(); + }); }); describe("appendAssistantMessageToSessionTranscript", () => { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 210ebc99963..d721cf4ad3e 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -22,7 +22,11 @@ import { loadConfig } from "../config.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; import { deriveSessionMetaPatch } from "./metadata.js"; -import { mergeSessionEntry, type SessionEntry } from "./types.js"; +import { + mergeSessionEntry, + normalizeSessionRuntimeModelFields, + type SessionEntry, +} from "./types.js"; const log = createSubsystemLogger("sessions/store"); @@ -157,7 +161,7 @@ function normalizeSessionStore(store: Record): void { if (!entry) { continue; } - const normalized = normalizeSessionEntryDelivery(entry); + const normalized = normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)); if (normalized !== entry) { store[key] = normalized; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 25091cd065e..e0077267742 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -114,6 +114,65 @@ export type SessionEntry = { systemPromptReport?: SessionSystemPromptReport; }; +function normalizeRuntimeField(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry { + const normalizedModel = normalizeRuntimeField(entry.model); + const normalizedProvider = normalizeRuntimeField(entry.modelProvider); + let next = entry; + + if (!normalizedModel) { + if (entry.model !== undefined || entry.modelProvider !== undefined) { + next = { ...next }; + delete next.model; + delete next.modelProvider; + } + return next; + } + + if (entry.model !== normalizedModel) { + if (next === entry) { + next = { ...next }; + } + next.model = normalizedModel; + } + + if (!normalizedProvider) { + if (entry.modelProvider !== undefined) { + if (next === entry) { + next = { ...next }; + } + delete next.modelProvider; + } + return next; + } + + if (entry.modelProvider !== normalizedProvider) { + if (next === entry) { + next = { ...next }; + } + next.modelProvider = normalizedProvider; + } + return next; +} + +export function setSessionRuntimeModel( + entry: SessionEntry, + runtime: { provider: string; model: string }, +): boolean { + const provider = runtime.provider.trim(); + const model = runtime.model.trim(); + if (!provider || !model) { + return false; + } + entry.modelProvider = provider; + entry.model = model; + return true; +} + export function mergeSessionEntry( existing: SessionEntry | undefined, patch: Partial, @@ -121,9 +180,20 @@ export function mergeSessionEntry( const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID(); const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now()); if (!existing) { - return { ...patch, sessionId, updatedAt }; + return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt }); } - return { ...existing, ...patch, sessionId, updatedAt }; + const next = { ...existing, ...patch, sessionId, updatedAt }; + + // Guard against stale provider carry-over when callers patch runtime model + // without also patching runtime provider. + if (Object.hasOwn(patch, "model") && !Object.hasOwn(patch, "modelProvider")) { + const patchedModel = normalizeRuntimeField(patch.model); + const existingModel = normalizeRuntimeField(existing.model); + if (patchedModel && patchedModel !== existingModel) { + delete next.modelProvider; + } + } + return normalizeSessionRuntimeModelFields(next); } export function resolveFreshSessionTotalTokens( diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index bfc37d48249..dd5c28ae616 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -32,7 +32,11 @@ import { } from "../../auto-reply/thinking.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js"; +import { + resolveSessionTranscriptPath, + setSessionRuntimeModel, + updateSessionStore, +} from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { logWarn } from "../../logger.js"; @@ -481,8 +485,10 @@ export async function runCronIsolatedAgentTurn(params: { const contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS; - cronSession.sessionEntry.modelProvider = providerUsed; - cronSession.sessionEntry.model = modelUsed; + setSessionRuntimeModel(cronSession.sessionEntry, { + provider: providerUsed, + model: modelUsed, + }); cronSession.sessionEntry.contextTokens = contextTokens; if (isCliProvider(providerUsed, cfgWithAgentDefaults)) { const cliSessionId = runResult.meta?.agentMeta?.sessionId?.trim(); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 8813ad065f6..357d1f4e563 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -387,6 +387,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { reasoningLevel: entry?.reasoningLevel, responseUsage: entry?.responseUsage, model: entry?.model, + modelProvider: entry?.modelProvider, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, 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 0ffa73c9270..b05cf2220ed 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -202,6 +202,8 @@ describe("gateway server sessions", () => { main: { sessionId: "sess-main", updatedAt: recent, + modelProvider: "anthropic", + model: "claude-sonnet-4-6", inputTokens: 10, outputTokens: 20, thinkingLevel: "low", @@ -456,11 +458,13 @@ describe("gateway server sessions", () => { const reset = await rpcReq<{ ok: true; key: string; - entry: { sessionId: string }; + entry: { sessionId: string; modelProvider?: string; model?: string }; }>(ws, "sessions.reset", { key: "agent:main:main" }); expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); + expect(reset.payload?.entry.modelProvider).toBe("anthropic"); + expect(reset.payload?.entry.model).toBe("claude-sonnet-4-6"); const filesAfterReset = await fs.readdir(dir); expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 5ad550eb0ed..b86e3be142e 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -13,6 +13,7 @@ import { parseGroupKey, pruneLegacyStoreKeys, resolveGatewaySessionStoreTarget, + resolveSessionModelIdentityRef, resolveSessionModelRef, resolveSessionStoreKey, } from "./session-utils.js"; @@ -339,6 +340,159 @@ describe("resolveSessionModelRef", () => { expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); }); + + test("falls back to resolved provider for unprefixed legacy runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ + provider: "google-gemini-cli", + model: "claude-sonnet-4-6", + }); + }); + + test("preserves provider from slash-prefixed model when modelProvider is missing", () => { + // When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6") + // parseModelRef should extract it correctly even without modelProvider set. + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); +}); + +describe("resolveSessionModelIdentityRef", () => { + test("does not inherit default provider for unprefixed legacy runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); + }); + + test("infers provider from configured model allowlist when unambiguous", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); + + test("keeps provider unknown when configured models are ambiguous", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + "minimax/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "legacy-session", + updatedAt: Date.now(), + model: "claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ model: "claude-sonnet-4-6" }); + }); + + test("preserves provider from slash-prefixed runtime model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); + }); + + test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveSessionModelIdentityRef(cfg, { + sessionId: "slash-model", + updatedAt: Date.now(), + model: "anthropic/claude-sonnet-4-6", + modelProvider: undefined, + }); + + expect(resolved).toEqual({ + provider: "vercel-ai-gateway", + model: "anthropic/claude-sonnet-4-6", + }); + }); }); describe("deriveSessionTitle", () => { @@ -529,6 +683,99 @@ describe("listSessionsFromStore search", () => { expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]); }); + test("does not guess provider for legacy runtime model without modelProvider", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBeUndefined(); + expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); + }); + + test("infers provider for legacy runtime model when allowlist match is unique", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBe("anthropic"); + expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); + }); + + test("infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, + models: { + "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + }, + }, + }, + } as OpenClawConfig; + const now = Date.now(); + const store: Record = { + "agent:main:main": { + sessionId: "sess-main", + updatedAt: now, + model: "anthropic/claude-sonnet-4-6", + } as SessionEntry, + }; + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + + expect(result.sessions[0]?.modelProvider).toBe("vercel-ai-gateway"); + expect(result.sessions[0]?.model).toBe("anthropic/claude-sonnet-4-6"); + }); + test("exposes unknown totals when freshness is stale or missing", () => { const now = Date.now(); const store: Record = { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 73dbd9c71be..14165ab2875 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { + inferUniqueProviderFromConfiguredModels, parseModelRef, resolveConfiguredModelRef, resolveDefaultModelForAgent, @@ -692,6 +693,39 @@ export function resolveSessionModelRef( return { provider, model }; } +export function resolveSessionModelIdentityRef( + cfg: OpenClawConfig, + entry?: + | SessionEntry + | Pick, + agentId?: string, +): { provider?: string; model: string } { + const runtimeModel = entry?.model?.trim(); + const runtimeProvider = entry?.modelProvider?.trim(); + if (runtimeModel) { + if (runtimeProvider) { + return { provider: runtimeProvider, model: runtimeModel }; + } + const inferredProvider = inferUniqueProviderFromConfiguredModels({ + cfg, + model: runtimeModel, + }); + if (inferredProvider) { + return { provider: inferredProvider, model: runtimeModel }; + } + if (runtimeModel.includes("/")) { + const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER); + if (parsedRuntime) { + return { provider: parsedRuntime.provider, model: parsedRuntime.model }; + } + return { model: runtimeModel }; + } + return { model: runtimeModel }; + } + const resolved = resolveSessionModelRef(cfg, entry, agentId); + return { provider: resolved.provider, model: resolved.model }; +} + export function listSessionsFromStore(params: { cfg: OpenClawConfig; storePath: string; @@ -782,8 +816,8 @@ export function listSessionsFromStore(params: { const deliveryFields = normalizeSessionDeliveryFields(entry); const parsedAgent = parseAgentSessionKey(key); const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg)); - const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId); - const modelProvider = resolvedModel.provider ?? DEFAULT_PROVIDER; + const resolvedModel = resolveSessionModelIdentityRef(cfg, entry, sessionAgentId); + const modelProvider = resolvedModel.provider; const model = resolvedModel.model ?? DEFAULT_MODEL; return { key,