Skip to content

Commit 177386e

Browse files
byungskergumadeiras
andauthored
fix(tui): resolve wrong provider prefix when session has model without modelProvider (#25874)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f0953a7 Co-authored-by: lbo728 <72309817+lbo728@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent 8f5f599 commit 177386e

12 files changed

Lines changed: 559 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,27 @@ Docs: https://docs.openclaw.ai
113113
- 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.
114114
- 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.
115115
- 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.
116+
- 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.
117+
- 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.
118+
- 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.
119+
- 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.
120+
- 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.
121+
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
122+
- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
123+
- 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.
124+
- 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.
125+
- 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.
126+
- 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.
127+
- 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.
128+
- 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.
129+
- 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.
130+
- 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.
131+
- 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.
132+
- 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.
133+
- 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.
134+
- 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.
135+
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
136+
- CLI/Memory search: accept `--query <text>` 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.
116137
- 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.
117138

118139
## 2026.2.23

src/agents/model-selection.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
33
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
44
import {
55
buildAllowedModelSet,
6+
inferUniqueProviderFromConfiguredModels,
67
parseModelRef,
78
buildModelAliasIndex,
89
modelKey,
@@ -134,6 +135,85 @@ describe("model-selection", () => {
134135
});
135136
});
136137

138+
describe("inferUniqueProviderFromConfiguredModels", () => {
139+
it("infers provider when configured model match is unique", () => {
140+
const cfg = {
141+
agents: {
142+
defaults: {
143+
models: {
144+
"anthropic/claude-sonnet-4-6": {},
145+
},
146+
},
147+
},
148+
} as OpenClawConfig;
149+
150+
expect(
151+
inferUniqueProviderFromConfiguredModels({
152+
cfg,
153+
model: "claude-sonnet-4-6",
154+
}),
155+
).toBe("anthropic");
156+
});
157+
158+
it("returns undefined when configured matches are ambiguous", () => {
159+
const cfg = {
160+
agents: {
161+
defaults: {
162+
models: {
163+
"anthropic/claude-sonnet-4-6": {},
164+
"minimax/claude-sonnet-4-6": {},
165+
},
166+
},
167+
},
168+
} as OpenClawConfig;
169+
170+
expect(
171+
inferUniqueProviderFromConfiguredModels({
172+
cfg,
173+
model: "claude-sonnet-4-6",
174+
}),
175+
).toBeUndefined();
176+
});
177+
178+
it("returns undefined for provider-prefixed model ids", () => {
179+
const cfg = {
180+
agents: {
181+
defaults: {
182+
models: {
183+
"anthropic/claude-sonnet-4-6": {},
184+
},
185+
},
186+
},
187+
} as OpenClawConfig;
188+
189+
expect(
190+
inferUniqueProviderFromConfiguredModels({
191+
cfg,
192+
model: "anthropic/claude-sonnet-4-6",
193+
}),
194+
).toBeUndefined();
195+
});
196+
197+
it("infers provider for slash-containing model id when allowlist match is unique", () => {
198+
const cfg = {
199+
agents: {
200+
defaults: {
201+
models: {
202+
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
203+
},
204+
},
205+
},
206+
} as OpenClawConfig;
207+
208+
expect(
209+
inferUniqueProviderFromConfiguredModels({
210+
cfg,
211+
model: "anthropic/claude-sonnet-4-6",
212+
}),
213+
).toBe("vercel-ai-gateway");
214+
});
215+
});
216+
137217
describe("buildModelAliasIndex", () => {
138218
it("should build alias index from config", () => {
139219
const cfg: Partial<OpenClawConfig> = {

src/agents/model-selection.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,42 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef |
171171
return normalizeModelRef(providerRaw, model);
172172
}
173173

174+
export function inferUniqueProviderFromConfiguredModels(params: {
175+
cfg: OpenClawConfig;
176+
model: string;
177+
}): string | undefined {
178+
const model = params.model.trim();
179+
if (!model) {
180+
return undefined;
181+
}
182+
const configuredModels = params.cfg.agents?.defaults?.models;
183+
if (!configuredModels) {
184+
return undefined;
185+
}
186+
const normalized = model.toLowerCase();
187+
const providers = new Set<string>();
188+
for (const key of Object.keys(configuredModels)) {
189+
const ref = key.trim();
190+
if (!ref || !ref.includes("/")) {
191+
continue;
192+
}
193+
const parsed = parseModelRef(ref, DEFAULT_PROVIDER);
194+
if (!parsed) {
195+
continue;
196+
}
197+
if (parsed.model === model || parsed.model.toLowerCase() === normalized) {
198+
providers.add(parsed.provider);
199+
if (providers.size > 1) {
200+
return undefined;
201+
}
202+
}
203+
}
204+
if (providers.size !== 1) {
205+
return undefined;
206+
}
207+
return providers.values().next().value;
208+
}
209+
174210
export function normalizeModelSelection(value: unknown): string | undefined {
175211
if (typeof value === "string") {
176212
const trimmed = value.trim();

src/commands/agent/session-store.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
44
import { isCliProvider } from "../../agents/model-selection.js";
55
import { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js";
66
import type { OpenClawConfig } from "../../config/config.js";
7-
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
7+
import {
8+
setSessionRuntimeModel,
9+
type SessionEntry,
10+
updateSessionStore,
11+
} from "../../config/sessions.js";
812

913
type RunResult = Awaited<
1014
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
@@ -58,10 +62,12 @@ export async function updateSessionStoreAfterAgentRun(params: {
5862
...entry,
5963
sessionId,
6064
updatedAt: Date.now(),
61-
modelProvider: providerUsed,
62-
model: modelUsed,
6365
contextTokens,
6466
};
67+
setSessionRuntimeModel(next, {
68+
provider: providerUsed,
69+
model: modelUsed,
70+
});
6571
if (isCliProvider(providerUsed, cfg)) {
6672
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
6773
if (cliSessionId) {

src/config/sessions/sessions.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
66
import {
77
clearSessionStoreCacheForTest,
88
loadSessionStore,
9+
mergeSessionEntry,
910
resolveAndPersistSessionFile,
1011
updateSessionStore,
1112
} from "../sessions.js";
@@ -215,6 +216,42 @@ describe("session store lock (Promise chain mutex)", () => {
215216
const store = loadSessionStore(storePath);
216217
expect(store[key]?.modelOverride).toBe("recovered");
217218
});
219+
220+
it("clears stale runtime provider when model is patched without provider", () => {
221+
const merged = mergeSessionEntry(
222+
{
223+
sessionId: "sess-runtime",
224+
updatedAt: 100,
225+
modelProvider: "anthropic",
226+
model: "claude-opus-4-6",
227+
},
228+
{
229+
model: "gpt-5.2",
230+
},
231+
);
232+
expect(merged.model).toBe("gpt-5.2");
233+
expect(merged.modelProvider).toBeUndefined();
234+
});
235+
236+
it("normalizes orphan modelProvider fields at store write boundary", async () => {
237+
const key = "agent:main:orphan-provider";
238+
const { storePath } = await makeTmpStore({
239+
[key]: {
240+
sessionId: "sess-orphan",
241+
updatedAt: 100,
242+
modelProvider: "anthropic",
243+
},
244+
});
245+
246+
await updateSessionStore(storePath, async (store) => {
247+
const entry = store[key];
248+
entry.updatedAt = Date.now();
249+
});
250+
251+
const store = loadSessionStore(storePath);
252+
expect(store[key]?.modelProvider).toBeUndefined();
253+
expect(store[key]?.model).toBeUndefined();
254+
});
218255
});
219256

220257
describe("appendAssistantMessageToSessionTranscript", () => {

src/config/sessions/store.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import { loadConfig } from "../config.js";
2222
import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js";
2323
import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js";
2424
import { deriveSessionMetaPatch } from "./metadata.js";
25-
import { mergeSessionEntry, type SessionEntry } from "./types.js";
25+
import {
26+
mergeSessionEntry,
27+
normalizeSessionRuntimeModelFields,
28+
type SessionEntry,
29+
} from "./types.js";
2630

2731
const log = createSubsystemLogger("sessions/store");
2832

@@ -157,7 +161,7 @@ function normalizeSessionStore(store: Record<string, SessionEntry>): void {
157161
if (!entry) {
158162
continue;
159163
}
160-
const normalized = normalizeSessionEntryDelivery(entry);
164+
const normalized = normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry));
161165
if (normalized !== entry) {
162166
store[key] = normalized;
163167
}

src/config/sessions/types.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,86 @@ export type SessionEntry = {
114114
systemPromptReport?: SessionSystemPromptReport;
115115
};
116116

117+
function normalizeRuntimeField(value: string | undefined): string | undefined {
118+
const trimmed = value?.trim();
119+
return trimmed ? trimmed : undefined;
120+
}
121+
122+
export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry {
123+
const normalizedModel = normalizeRuntimeField(entry.model);
124+
const normalizedProvider = normalizeRuntimeField(entry.modelProvider);
125+
let next = entry;
126+
127+
if (!normalizedModel) {
128+
if (entry.model !== undefined || entry.modelProvider !== undefined) {
129+
next = { ...next };
130+
delete next.model;
131+
delete next.modelProvider;
132+
}
133+
return next;
134+
}
135+
136+
if (entry.model !== normalizedModel) {
137+
if (next === entry) {
138+
next = { ...next };
139+
}
140+
next.model = normalizedModel;
141+
}
142+
143+
if (!normalizedProvider) {
144+
if (entry.modelProvider !== undefined) {
145+
if (next === entry) {
146+
next = { ...next };
147+
}
148+
delete next.modelProvider;
149+
}
150+
return next;
151+
}
152+
153+
if (entry.modelProvider !== normalizedProvider) {
154+
if (next === entry) {
155+
next = { ...next };
156+
}
157+
next.modelProvider = normalizedProvider;
158+
}
159+
return next;
160+
}
161+
162+
export function setSessionRuntimeModel(
163+
entry: SessionEntry,
164+
runtime: { provider: string; model: string },
165+
): boolean {
166+
const provider = runtime.provider.trim();
167+
const model = runtime.model.trim();
168+
if (!provider || !model) {
169+
return false;
170+
}
171+
entry.modelProvider = provider;
172+
entry.model = model;
173+
return true;
174+
}
175+
117176
export function mergeSessionEntry(
118177
existing: SessionEntry | undefined,
119178
patch: Partial<SessionEntry>,
120179
): SessionEntry {
121180
const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
122181
const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now());
123182
if (!existing) {
124-
return { ...patch, sessionId, updatedAt };
183+
return normalizeSessionRuntimeModelFields({ ...patch, sessionId, updatedAt });
184+
}
185+
const next = { ...existing, ...patch, sessionId, updatedAt };
186+
187+
// Guard against stale provider carry-over when callers patch runtime model
188+
// without also patching runtime provider.
189+
if (Object.hasOwn(patch, "model") && !Object.hasOwn(patch, "modelProvider")) {
190+
const patchedModel = normalizeRuntimeField(patch.model);
191+
const existingModel = normalizeRuntimeField(existing.model);
192+
if (patchedModel && patchedModel !== existingModel) {
193+
delete next.modelProvider;
194+
}
125195
}
126-
return { ...existing, ...patch, sessionId, updatedAt };
196+
return normalizeSessionRuntimeModelFields(next);
127197
}
128198

129199
export function resolveFreshSessionTotalTokens(

src/cron/isolated-agent/run.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import {
3232
} from "../../auto-reply/thinking.js";
3333
import type { CliDeps } from "../../cli/outbound-send-deps.js";
3434
import type { OpenClawConfig } from "../../config/config.js";
35-
import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js";
35+
import {
36+
resolveSessionTranscriptPath,
37+
setSessionRuntimeModel,
38+
updateSessionStore,
39+
} from "../../config/sessions.js";
3640
import type { AgentDefaultsConfig } from "../../config/types.js";
3741
import { registerAgentRunContext } from "../../infra/agent-events.js";
3842
import { logWarn } from "../../logger.js";
@@ -481,8 +485,10 @@ export async function runCronIsolatedAgentTurn(params: {
481485
const contextTokens =
482486
agentCfg?.contextTokens ?? lookupContextTokens(modelUsed) ?? DEFAULT_CONTEXT_TOKENS;
483487

484-
cronSession.sessionEntry.modelProvider = providerUsed;
485-
cronSession.sessionEntry.model = modelUsed;
488+
setSessionRuntimeModel(cronSession.sessionEntry, {
489+
provider: providerUsed,
490+
model: modelUsed,
491+
});
486492
cronSession.sessionEntry.contextTokens = contextTokens;
487493
if (isCliProvider(providerUsed, cfgWithAgentDefaults)) {
488494
const cliSessionId = runResult.meta?.agentMeta?.sessionId?.trim();

src/gateway/server-methods/sessions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
387387
reasoningLevel: entry?.reasoningLevel,
388388
responseUsage: entry?.responseUsage,
389389
model: entry?.model,
390+
modelProvider: entry?.modelProvider,
390391
contextTokens: entry?.contextTokens,
391392
sendPolicy: entry?.sendPolicy,
392393
label: entry?.label,

0 commit comments

Comments
 (0)