fix(status): treat CLI runtime aliases as selected route

This commit is contained in:
brokemac79
2026-05-07 21:21:28 +01:00
committed by Peter Steinberger
parent fa0506bd31
commit 2bdbec8246
10 changed files with 194 additions and 14 deletions

View File

@@ -286,6 +286,7 @@ Docs: https://docs.openclaw.ai
- Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762.
- Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews.
- Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590. Refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481.
- Status: treat CLI runtime aliases such as `claude-cli/<model>` as the canonical selected provider route in `/status`, avoiding spurious fallback/unknown-auth display and preserving fresh context usage from CLI usage snapshots. Fixes #79015. Thanks @ItsThierry.
- Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987.
- Gateway/auth: allow `gateway.auth.mode: "none"` loopback backend RPC clients to skip device identity only for local non-browser backend connections, restoring subagent spawns and gateway tools without opening remote or browser-origin bypasses. Fixes #75780. Thanks @yozakura-ava.
- Canvas plugin: keep legacy root `canvasHost` configs valid until `openclaw doctor --fix` migrates them into `plugins.entries.canvas.config.host`, move Canvas/A2UI clients to gateway protocol v4 plugin surfaces, and refresh the generated A2UI bundle hash so normal builds stay clean.

View File

@@ -97,6 +97,28 @@ export function isCliRuntimeAlias(runtime: string | undefined): boolean {
return normalized ? CLI_RUNTIME_ALIASES.has(normalizeProviderId(normalized)) : false;
}
function canonicalizeRuntimeAliasProvider(provider: string): string {
return resolveLegacyRuntimeModelProviderAlias(provider)?.provider ?? provider;
}
function normalizeRuntimeModelRefForComparison(raw: string): string {
const trimmed = raw.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash >= trimmed.length - 1) {
return normalizeProviderId(canonicalizeRuntimeAliasProvider(trimmed));
}
const provider = trimmed.slice(0, slash).trim();
const model = trimmed.slice(slash + 1).trim();
const canonicalProvider = normalizeProviderId(canonicalizeRuntimeAliasProvider(provider));
return model ? `${canonicalProvider}/${model}` : canonicalProvider;
}
export function areRuntimeModelRefsEquivalent(left: string, right: string): boolean {
return (
normalizeRuntimeModelRefForComparison(left) === normalizeRuntimeModelRefForComparison(right)
);
}
function resolveConfiguredRuntime(params: {
cfg?: OpenClawConfig;
provider: string;

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildFallbackNotice,
resolveActiveFallbackState,
resolveFallbackTransition,
type FallbackNoticeState,
@@ -120,4 +121,37 @@ describe("fallback-state", () => {
expect(resolved.nextState.activeModel).toBeUndefined();
expect(resolved.nextState.reason).toBeUndefined();
});
it("does not treat a CLI runtime alias as a model fallback", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "anthropic",
selectedModel: "claude-opus-4-7",
activeProvider: "claude-cli",
activeModel: "claude-opus-4-7",
attempts: [],
state: {
fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7",
fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7",
fallbackNoticeReason: "selected model unavailable",
},
});
expect(resolved.fallbackActive).toBe(false);
expect(resolved.fallbackCleared).toBe(false);
expect(resolved.stateChanged).toBe(true);
expect(resolved.nextState.selectedModel).toBeUndefined();
expect(resolved.nextState.activeModel).toBeUndefined();
});
it("does not build a fallback notice for equivalent CLI runtime aliases", () => {
expect(
buildFallbackNotice({
selectedProvider: "anthropic",
selectedModel: "claude-opus-4-7",
activeProvider: "claude-cli",
activeModel: "claude-opus-4-7",
attempts: [],
}),
).toBeNull();
});
});

View File

@@ -1,3 +1,4 @@
import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js";
import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { FallbackNoticeState } from "../status/fallback-notice-state.js";
@@ -96,7 +97,7 @@ export function buildFallbackNotice(params: {
}): string | null {
const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const active = formatProviderModelRef(params.activeProvider, params.activeModel);
if (selected === active) {
if (areRuntimeModelRefsEquivalent(selected, active)) {
return null;
}
const reasonSummary = buildFallbackReasonSummary(params.attempts);
@@ -152,13 +153,17 @@ export function resolveFallbackTransition(params: {
activeModel: normalizeOptionalString(params.state?.fallbackNoticeActiveModel),
reason: normalizeOptionalString(params.state?.fallbackNoticeReason),
};
const fallbackActive = selectedModelRef !== activeModelRef;
const fallbackActive = !areRuntimeModelRefsEquivalent(selectedModelRef, activeModelRef);
const fallbackTransitioned =
fallbackActive &&
(previousState.selectedModel !== selectedModelRef ||
previousState.activeModel !== activeModelRef);
const fallbackCleared =
!fallbackActive && Boolean(previousState.selectedModel || previousState.activeModel);
const previousStateWasRealFallback = Boolean(
previousState.selectedModel &&
previousState.activeModel &&
!areRuntimeModelRefsEquivalent(previousState.selectedModel, previousState.activeModel),
);
const fallbackCleared = !fallbackActive && previousStateWasRealFallback;
const reasonSummary = buildFallbackReasonSummary(params.attempts);
const attemptSummaries = buildFallbackAttemptSummaries(params.attempts);
const nextState = fallbackActive

View File

@@ -1163,6 +1163,62 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
it("does not persist fallback state for an equivalent CLI runtime alias", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7",
fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7",
fallbackNoticeReason: "selected model unavailable",
};
const sessionStore = { main: sessionEntry };
const dir = await mkdtemp(join(tmpdir(), "openclaw-agent-runner-cli-alias-"));
const storePath = join(dir, "sessions.json");
await writeFile(storePath, JSON.stringify({ main: sessionEntry }), "utf8");
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {
agentMeta: {
provider: "claude-cli",
model: "claude-opus-4-7",
usage: { input: 36_000, output: 19_000 },
},
},
});
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
runOverrides: {
provider: "anthropic",
model: "claude-opus-4-7",
config: {
agents: {
defaults: {
cliBackends: {
"claude-cli": { command: "claude" },
},
},
},
},
},
});
await run();
const stored = JSON.parse(await readFile(storePath, "utf8")).main as SessionEntry;
expect(sessionEntry.fallbackNoticeSelectedModel).toBeUndefined();
expect(sessionEntry.fallbackNoticeActiveModel).toBeUndefined();
expect(stored.fallbackNoticeSelectedModel).toBeUndefined();
expect(stored.fallbackNoticeActiveModel).toBeUndefined();
expect(stored.modelProvider).toBe("claude-cli");
expect(stored.model).toBe("claude-opus-4-7");
expect(stored.totalTokens).toBe(36_000);
expect(stored.totalTokensFresh).toBe(true);
});
it("surfaces overflow fallback when embedded run returns empty payloads", async () => {
state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
payloads: [],

View File

@@ -1416,10 +1416,11 @@ export async function runReplyAgent(params: {
});
}
}
const cliSessionId = isCliProvider(providerUsed, cfg)
const usedCliProvider = isCliProvider(providerUsed, cfg);
const cliSessionId = usedCliProvider
? normalizeOptionalString(runResult.meta?.agentMeta?.sessionId)
: undefined;
const cliSessionBinding = isCliProvider(providerUsed, cfg)
const cliSessionBinding = usedCliProvider
? runResult.meta?.agentMeta?.cliSessionBinding
: undefined;
const runtimeContextTokens =
@@ -1447,6 +1448,7 @@ export async function runReplyAgent(params: {
usage,
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
promptTokens,
usageIsContextSnapshot: usedCliProvider ? true : undefined,
modelUsed,
providerUsed,
contextTokensUsed,

View File

@@ -774,6 +774,43 @@ describe("buildStatusMessage", () => {
expect(normalized).not.toContain("Context: 49k/1.0m");
});
it("renders CLI runtime aliases as the selected model route", () => {
const text = buildStatusMessage({
agent: {
model: "anthropic/claude-opus-4-7",
},
sessionEntry: {
sessionId: "claude-cli-runtime-alias",
updatedAt: 0,
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",
modelProvider: "claude-cli",
model: "claude-opus-4-7",
fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7",
fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7",
fallbackNoticeReason: "selected model unavailable",
inputTokens: 29,
outputTokens: 19_000,
cacheRead: 3_000_000,
totalTokens: 36_000,
totalTokensFresh: true,
contextTokens: 1_000_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
modelAuth: "unknown",
activeModelAuth: "oauth (anthropic:claude-cli)",
});
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: anthropic/claude-opus-4-7");
expect(normalized).toContain("oauth (anthropic:claude-cli)");
expect(normalized).not.toContain("Fallback: claude-cli/claude-opus-4-7");
expect(normalized).not.toContain("unknown");
expect(normalized).toContain("Context: 36k/1.0m (4%)");
});
it("keeps an explicit runtime context limit when fallback status already computed one", () => {
const text = buildStatusMessage({
config: {

View File

@@ -1,3 +1,4 @@
import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js";
import type { SessionEntry } from "../config/sessions.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
@@ -15,7 +16,7 @@ export function resolveActiveFallbackState(params: {
const active = normalizeOptionalString(params.state?.fallbackNoticeActiveModel);
const reason = normalizeOptionalString(params.state?.fallbackNoticeReason);
const fallbackActive =
params.selectedModelRef !== params.activeModelRef &&
!areRuntimeModelRefsEquivalent(params.selectedModelRef, params.activeModelRef) &&
selected === params.selectedModelRef &&
active === params.activeModelRef;
return {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import { resolveContextTokensForModel } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveModelAuthMode } from "../agents/model-auth.js";
import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js";
import {
buildModelAliasIndex,
resolveConfiguredModelRef,
@@ -849,17 +850,25 @@ export function buildStatusMessage(args: StatusArgs): string {
];
const activationLine = activationParts.filter(Boolean).join(" · ");
const selectedModelLabel = modelRefs.selected.label || "unknown";
const runtimeAliasModelEquivalent = areRuntimeModelRefsEquivalent(
selectedModelLabel,
activeModelLabel,
);
const selectedAuthMode =
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config);
const selectedAuthLabelValue =
args.modelAuth ??
(selectedAuthMode && selectedAuthMode !== "unknown" ? selectedAuthMode : undefined);
const rawSelectedAuthLabelValue =
selectedAuthMode && selectedAuthMode !== "unknown"
? (args.modelAuth ?? selectedAuthMode)
: undefined;
const activeAuthMode =
normalizeAuthMode(args.activeModelAuth) ?? resolveModelAuthMode(activeProvider, args.config);
const activeAuthLabelValue =
args.activeModelAuth ??
(activeAuthMode && activeAuthMode !== "unknown" ? activeAuthMode : undefined);
const selectedModelLabel = modelRefs.selected.label || "unknown";
activeAuthMode && activeAuthMode !== "unknown"
? (args.activeModelAuth ?? activeAuthMode)
: undefined;
const selectedAuthLabelValue =
rawSelectedAuthLabelValue ?? (runtimeAliasModelEquivalent ? activeAuthLabelValue : undefined);
const fallbackState = resolveActiveFallbackState({
selectedModelRef: selectedModelLabel,
activeModelRef: activeModelLabel,

View File

@@ -10,6 +10,7 @@ import {
import { resolveContextTokensForModel } from "../agents/context.js";
import { resolveFastModeState } from "../agents/fast-mode.js";
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
@@ -238,7 +239,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
provider: activeProvider,
effectiveHarness,
});
const selectedModelAuth = Object.hasOwn(params, "modelAuthOverride")
let selectedModelAuth = Object.hasOwn(params, "modelAuthOverride")
? params.modelAuthOverride
: resolveModelAuthLabel({
provider: selectedStatusProvider,
@@ -260,6 +261,18 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
includeExternalProfiles: false,
})
: selectedModelAuth;
const runtimeAliasModelEquivalent = areRuntimeModelRefsEquivalent(
modelRefs.selected.label,
modelRefs.active.label,
);
if (
runtimeAliasModelEquivalent &&
normalizeOptionalLowercaseString(selectedModelAuth) === "unknown" &&
activeModelAuth &&
normalizeOptionalLowercaseString(activeModelAuth) !== "unknown"
) {
selectedModelAuth = activeModelAuth;
}
const usageAuthLabel = modelRefs.activeDiffers ? activeModelAuth : selectedModelAuth;
const currentUsageProvider =
resolveUsageProviderId(activeStatusProvider) ?? resolveUsageProviderId(activeProvider);