From 4eea0134abebf947da7cb10438b490d9d364ae79 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 24 Feb 2026 21:01:34 -0500 Subject: [PATCH] fix(gateway): stop guessing legacy session model providers --- CHANGELOG.md | 1 + ...sessions.gateway-server-sessions-a.test.ts | 6 +- src/gateway/session-utils.test.ts | 160 +++++++++++++++++- src/gateway/session-utils.ts | 83 +++++++-- 4 files changed, 226 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f05b11ae77d..3e6f5c40cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - 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 model identity: preserve `modelProvider` across `sessions.reset` and stop guessing provider prefixes for legacy unprefixed session models unless `agents.defaults.models` provides a unique match, keeping ambiguous entries model-only until the next runtime write records the true provider. (#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. 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 f22592ccba7..1af536850b2 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"; @@ -340,10 +341,7 @@ describe("resolveSessionModelRef", () => { expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); }); - test("does not inherit config default provider for legacy session without modelProvider", () => { - // Regression: when config default_model is "google-gemini-cli/gemini-3-pro-preview" - // and a legacy session entry has model="claude-sonnet-4-6" but modelProvider is - // undefined, the TUI footer should NOT show "google-gemini-cli/claude-sonnet-4-6". + test("falls back to resolved provider for unprefixed legacy runtime model", () => { const cfg = { agents: { defaults: { @@ -359,10 +357,10 @@ describe("resolveSessionModelRef", () => { modelProvider: undefined, }); - // The provider must NOT be "google-gemini-cli" — it should fall back to - // the system default provider, not the config-resolved provider. - expect(resolved.provider).not.toBe("google-gemini-cli"); - expect(resolved.model).toBe("claude-sonnet-4-6"); + expect(resolved).toEqual({ + provider: "google-gemini-cli", + model: "claude-sonnet-4-6", + }); }); test("preserves provider from slash-prefixed model when modelProvider is missing", () => { @@ -387,6 +385,91 @@ describe("resolveSessionModelRef", () => { }); }); +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" }); + }); +}); + describe("deriveSessionTitle", () => { test("returns undefined for undefined entry", () => { expect(deriveSessionTitle(undefined)).toBeUndefined(); @@ -575,6 +658,67 @@ 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("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 46c019fb82a..dde5d993988 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -634,6 +634,42 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults }; } +function inferProviderFromConfiguredModels( + cfg: OpenClawConfig, + runtimeModel: string, +): string | undefined { + const model = runtimeModel.trim(); + if (!model || model.includes("/")) { + return undefined; + } + const configuredModels = 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 resolveSessionModelRef( cfg: OpenClawConfig, entry?: @@ -665,19 +701,7 @@ export function resolveSessionModelRef( // provider the user has no credentials for. return { provider: runtimeProvider, model: runtimeModel }; } - // Legacy session entries may have model recorded without modelProvider. - // When runtimeModel has no "/" prefix, parseModelRef would use the fallback - // provider — but using `resolved.provider` (from config default_model) is wrong - // because the default model may belong to a completely different provider. - // Example: config default_model = "google-gemini-cli/gemini-3-pro-preview" but - // session model = "claude-sonnet-4-6" → would wrongly resolve to - // { provider: "google-gemini-cli", model: "claude-sonnet-4-6" }. - // Fix: use DEFAULT_PROVIDER as the fallback for unprefixed model names so we - // don't cross-contaminate the provider from an unrelated config default. - const fallbackProvider = runtimeModel.includes("/") - ? provider || DEFAULT_PROVIDER - : DEFAULT_PROVIDER; - const parsedRuntime = parseModelRef(runtimeModel, fallbackProvider); + const parsedRuntime = parseModelRef(runtimeModel, provider || DEFAULT_PROVIDER); if (parsedRuntime) { provider = parsedRuntime.provider; model = parsedRuntime.model; @@ -704,6 +728,35 @@ 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 }; + } + if (runtimeModel.includes("/")) { + const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER); + if (parsedRuntime) { + return { provider: parsedRuntime.provider, model: parsedRuntime.model }; + } + return { model: runtimeModel }; + } + const inferredProvider = inferProviderFromConfiguredModels(cfg, runtimeModel); + return inferredProvider + ? { provider: inferredProvider, model: runtimeModel } + : { model: runtimeModel }; + } + const resolved = resolveSessionModelRef(cfg, entry, agentId); + return { provider: resolved.provider, model: resolved.model }; +} + export function listSessionsFromStore(params: { cfg: OpenClawConfig; storePath: string; @@ -794,8 +847,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,