fix(agents): preserve fallback route provenance (#114360)

Co-authored-by: 1052326311 <65798732+1052326311@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-27 02:20:56 -04:00
committed by GitHub
parent ab3e9645e7
commit 390f46d042
65 changed files with 873 additions and 150 deletions
@@ -237,6 +237,33 @@ export function resolveTestModelRefFromString({
};
}
export function resolveTestModelAliasFromPair(params: {
provider: string;
model: string;
defaultProvider: string;
aliasIndex?: ReturnType<typeof buildTestModelAliasIndex>;
}) {
const bareAlias = resolveTestModelRefFromString({
raw: params.model,
defaultProvider: params.provider,
aliasIndex: params.aliasIndex,
});
const providerAlias = resolveTestModelRefFromString({
raw: `${params.provider}/${params.model}`,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (providerAlias.alias) {
return providerAlias.ref;
}
const provider = normalizeTestProviderId(params.provider);
return bareAlias.alias &&
(normalizeTestProviderId(bareAlias.ref.provider) === provider ||
provider === normalizeTestProviderId(params.defaultProvider))
? bareAlias.ref
: null;
}
function configuredPrimary(cfg?: unknown): string {
const raw = (cfg as { agents?: { defaults?: { model?: string | { primary?: string } } } })?.agents
?.defaults?.model;
@@ -21,6 +21,7 @@ import {
normalizeTestProviderId,
resolveTestConfiguredModelRef,
resolveTestDefaultModelForAgent,
resolveTestModelAliasFromPair,
resolveTestModelRefFromString,
} from "./agent-command.live-model-switch.test-helpers.js";
import {
@@ -547,6 +548,7 @@ vi.mock("./model-selection.js", () => ({
: { provider, model };
},
resolveModelRefFromString: resolveTestModelRefFromString,
resolveModelAliasFromPair: resolveTestModelAliasFromPair,
resolveConfiguredModelRef: resolveTestConfiguredModelRef,
resolveDefaultModelForAgent: resolveTestDefaultModelForAgent,
resolveThinkingDefault: (args: unknown) => state.resolveThinkingDefaultMock(args),
@@ -3374,6 +3376,42 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
expect(thinkingArgs.level).toBe("xhigh");
});
it("keeps a legacy auto-fallback route ahead of a colliding model alias", async () => {
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "anthropic/claude" },
models: {
"anthropic/claude": {},
"cloudflare-ai-gateway/gemini-2.5-flash-lite": {},
"google/gemini-2.5-flash-lite": { alias: "gemini-2.5-flash-lite" },
},
},
},
};
const sessionEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: Date.now(),
providerOverride: "cloudflare-ai-gateway",
modelOverride: "gemini-2.5-flash-lite",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
};
state.sessionEntryMock = sessionEntry;
state.sessionStoreMock = { "agent:main:main": sessionEntry };
setupSuccessfulAttempt("cloudflare-ai-gateway", "gemini-2.5-flash-lite");
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.runWithModelFallbackMock), {
provider: "cloudflare-ai-gateway",
model: "gemini-2.5-flash-lite",
requestedRouteResolution: "resolved",
});
});
it("records fallback steps to the session trajectory runtime", async () => {
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
await params.onFallbackStep?.({
+1
View File
@@ -283,6 +283,7 @@ export function clearAutoFallbackPrimaryProbeSelection(
delete entry.providerOverride;
delete entry.modelOverride;
delete entry.modelOverrideSource;
delete entry.modelOverrideRouteResolution;
delete entry.modelOverrideFallbackOriginProvider;
delete entry.modelOverrideFallbackOriginModel;
if (
+40 -2
View File
@@ -6,6 +6,7 @@ import {
type ThinkLevel,
} from "../../auto-reply/thinking.js";
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
@@ -35,11 +36,13 @@ import { ensureAuthProfileStore } from "../auth-profiles/store.js";
import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js";
import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js";
import { loadManifestModelCatalog } from "../model-catalog.js";
import type { ModelFallbackRouteResolution } from "../model-fallback.types.js";
import { splitTrailingAuthProfile } from "../model-ref-profile.js";
import type { ModelManifestNormalizationContext } from "../model-ref-shared.js";
import {
modelKey,
resolveDefaultModelForAgent,
resolveModelAliasFromPair,
resolveThinkingDefault,
} from "../model-selection.js";
import {
@@ -103,9 +106,13 @@ export async function resolveEmbeddedModelSelection(params: {
);
let provider = defaultProvider;
let model = defaultModel;
let requestedRouteResolution: ModelFallbackRouteResolution = "resolved";
let sessionEntry = params.sessionEntry;
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
let storedModelOverrideSource = hasStoredOverride ? sessionEntry?.modelOverrideSource : undefined;
let storedModelOverrideRouteResolution = hasStoredOverride
? resolveSessionModelOverrideRouteResolution(sessionEntry)
: undefined;
let hasStoredAutoFallbackProvenance =
hasStoredOverride && hasSessionAutoModelFallbackProvenance(sessionEntry);
let hasLegacyAutoFallbackOverrideWithoutOrigin =
@@ -218,6 +225,9 @@ export async function resolveEmbeddedModelSelection(params: {
storedModelOverrideSource = adoptedHasStoredOverride
? sessionEntry?.modelOverrideSource
: undefined;
storedModelOverrideRouteResolution = adoptedHasStoredOverride
? resolveSessionModelOverrideRouteResolution(sessionEntry)
: undefined;
hasStoredAutoFallbackProvenance =
adoptedHasStoredOverride && hasSessionAutoModelFallbackProvenance(sessionEntry);
hasLegacyAutoFallbackOverrideWithoutOrigin =
@@ -270,18 +280,39 @@ export async function resolveEmbeddedModelSelection(params: {
if (normalizedChannelOverride && !hasEffectiveStoredOverride) {
provider = normalizedChannelOverride.provider;
model = normalizedChannelOverride.model;
requestedRouteResolution = "resolved";
}
if (storedModelOverride) {
const candidateProvider = storedProviderOverride || defaultProvider;
const storedRouteKey = modelKey(candidateProvider, storedModelOverride);
const storedRouteCataloged = (modelCatalog ?? allowedModelCatalog).some(
(entry) => modelKey(entry.provider, entry.id) === storedRouteKey,
);
const storedAlias =
storedModelOverrideRouteResolution === "raw" && !storedRouteCataloged
? resolveModelAliasFromPair({
cfg: params.cfg,
provider: candidateProvider,
model: storedModelOverride,
defaultProvider,
aliasIndex: visibilityPolicy.selectionAliasIndex,
allowPluginNormalization: params.pluginsEnabled,
...params.modelManifestContext,
})
: null;
const normalizedStored = normalizeAgentCommandModelRef(
params.cfg,
candidateProvider,
storedModelOverride,
storedAlias?.provider ?? candidateProvider,
storedAlias?.model ?? storedModelOverride,
params.modelManifestContext,
);
if (visibilityPolicy.allowsKey(modelKey(normalizedStored.provider, normalizedStored.model))) {
provider = normalizedStored.provider;
model = normalizedStored.model;
requestedRouteResolution =
storedAlias || storedRouteCataloged
? "resolved"
: (storedModelOverrideRouteResolution ?? "raw");
}
}
const autoFallbackPrimaryProbe =
@@ -297,6 +328,7 @@ export async function resolveEmbeddedModelSelection(params: {
if (autoFallbackPrimaryProbe && sessionEntry) {
provider = autoFallbackPrimaryProbe.provider;
model = autoFallbackPrimaryProbe.model;
requestedRouteResolution = "resolved";
autoFallbackPrimaryProbeSessionEntry = { ...sessionEntry };
clearAutoFallbackPrimaryProbeSelection(autoFallbackPrimaryProbeSessionEntry);
}
@@ -337,7 +369,9 @@ export async function resolveEmbeddedModelSelection(params: {
}
provider = explicitRef.provider;
model = explicitRef.model;
requestedRouteResolution = "resolved";
}
const unresolvedSelectionKey = modelKey(provider, model);
const allowedInitialSelection = visibilityPolicy.resolveSelection({ provider, model });
if (!allowedInitialSelection) {
const policyPath = visibilityPolicy.allowConfigPath ?? "modelPolicy.allow";
@@ -347,6 +381,9 @@ export async function resolveEmbeddedModelSelection(params: {
}
provider = allowedInitialSelection.provider;
model = allowedInitialSelection.model;
if (modelKey(provider, model) !== unresolvedSelectionKey) {
requestedRouteResolution = "resolved";
}
const providerForAuthProfileValidation = provider;
let sessionEntryForAttempt = autoFallbackPrimaryProbeSessionEntry ?? sessionEntry;
const initialAgentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
@@ -544,6 +581,7 @@ export async function resolveEmbeddedModelSelection(params: {
sessionEntry,
provider,
model,
requestedRouteResolution,
defaultProvider,
defaultModel,
configuredDefaultAuthProfileId,
@@ -236,6 +236,7 @@ export async function runEmbeddedAgentAttempt(params: {
cfg,
provider,
model,
requestedRouteResolution: params.modelSelection.requestedRouteResolution,
agentDir,
fallbacksOverride: effectiveFallbacksOverride,
...modelManifestContext,
@@ -188,6 +188,7 @@ export async function compactEmbeddedAgentSessionDirect(
cfg: params.config,
provider: primaryProvider,
model: primaryModel,
requestedRouteResolution: "resolved",
fallbacksOverride,
})[0];
const fallbackAgentId = resolveSessionAgentIds({
@@ -200,6 +201,7 @@ export async function compactEmbeddedAgentSessionDirect(
cfg: params.config,
provider: primaryProvider,
model: primaryModel,
requestedRouteResolution: "resolved",
runId: params.runId ?? params.sessionId,
agentDir: params.agentDir,
agentId: fallbackAgentId,
@@ -3,7 +3,7 @@ import { buildAgentRunTerminalOutcome } from "../agent-run-terminal-outcome.js";
import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js";
import type { ModelFallbackStepFields } from "../model-fallback-observation.js";
import { runWithModelFallback, type ModelFallbackResultClassification } from "../model-fallback.js";
import type { FallbackAttempt } from "../model-fallback.types.js";
import type { FallbackAttempt, ModelFallbackRouteResolution } from "../model-fallback.types.js";
import type { ModelManifestNormalizationContext } from "../model-ref-shared.js";
import { resolveAgentRunAbortLifecycleFields } from "../run-termination.js";
import {
@@ -69,6 +69,7 @@ type EmbeddedAgentRunEntryParams<T extends EmbeddedAgentRunResult> = {
cfg: OpenClawConfig;
provider: string;
model: string;
requestedRouteResolution?: ModelFallbackRouteResolution;
fallbacksOverride?: string[];
agentDir?: string;
} & ModelManifestNormalizationContext;
+83 -57
View File
@@ -14,7 +14,12 @@ import {
getPluginRegistryState,
} from "../plugins/runtime-state.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type { ModelCandidate } from "./model-fallback.types.js";
import type {
ModelCandidate,
ModelFallbackCandidate,
ModelFallbackRouteOrigin,
ModelFallbackRouteResolution,
} from "./model-fallback.types.js";
import {
type ModelManifestNormalizationContext,
modelKey,
@@ -24,11 +29,12 @@ import {
import {
buildModelAliasIndex,
resolveConfiguredModelRef,
resolveModelAliasFromPair,
resolveModelRefFromString,
} from "./model-selection-resolve.js";
const MAX_FALLBACK_CANDIDATE_CACHE_ENTRIES = 256;
const fallbackCandidateCache = new Map<string, ModelCandidate[]>();
const fallbackCandidateCache = new Map<string, ModelFallbackCandidate[]>();
function hasExactConfiguredProviderModel(params: {
cfg?: OpenClawConfig;
@@ -75,13 +81,21 @@ function allowPluginModelNormalizationForRef(params: {
}
function createModelCandidateCollector(): {
candidates: ModelCandidate[];
addExplicitCandidate: (candidate: ModelCandidate) => void;
candidates: ModelFallbackCandidate[];
addCandidate: (
candidate: ModelCandidate,
routeOrigin: ModelFallbackRouteOrigin,
routeResolution: ModelFallbackRouteResolution,
) => void;
} {
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const candidates: ModelFallbackCandidate[] = [];
const addCandidate = (candidate: ModelCandidate) => {
const addCandidate = (
candidate: ModelCandidate,
routeOrigin: ModelFallbackRouteOrigin,
routeResolution: ModelFallbackRouteResolution,
) => {
if (!candidate.provider || !candidate.model) {
return;
}
@@ -90,12 +104,12 @@ function createModelCandidateCollector(): {
return;
}
seen.add(key);
candidates.push(candidate);
candidates.push({ ...candidate, routeOrigin, routeResolution });
};
return {
candidates,
addExplicitCandidate: addCandidate,
addCandidate,
};
}
@@ -105,15 +119,15 @@ export function resolveImageFallbackCandidates(
defaultProvider: string;
modelOverride?: string;
} & ModelManifestNormalizationContext,
): ModelCandidate[] {
): ModelFallbackCandidate[] {
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: params.defaultProvider,
manifestPlugins: params.manifestPlugins,
});
const { candidates, addExplicitCandidate } = createModelCandidateCollector();
const { candidates, addCandidate } = createModelCandidateCollector();
const addRaw = (raw: string) => {
const addRaw = (raw: string, routeOrigin: ModelFallbackRouteOrigin) => {
const resolved = resolveModelRefFromString({
cfg: params.cfg,
raw,
@@ -124,15 +138,15 @@ export function resolveImageFallbackCandidates(
if (!resolved) {
return;
}
addExplicitCandidate(resolved.ref);
addCandidate(resolved.ref, routeOrigin, "resolved");
};
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride);
addRaw(params.modelOverride, "requested");
} else {
const primary = resolveAgentModelPrimaryValue(params.cfg?.agents?.defaults?.imageModel);
if (primary?.trim()) {
addRaw(primary);
addRaw(primary, "configured-primary");
}
}
@@ -140,7 +154,7 @@ export function resolveImageFallbackCandidates(
for (const raw of imageFallbacks) {
// Explicitly configured image fallbacks should remain reachable even when a
// model allowlist is present.
addRaw(raw);
addRaw(raw, "configured-fallback");
}
return candidates;
}
@@ -172,8 +186,9 @@ export function resolveModelCandidateChain(
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
requestedRouteResolution?: ModelFallbackRouteResolution;
} & ModelManifestNormalizationContext,
): ModelCandidate[] {
): ModelFallbackCandidate[] {
const cacheKey = resolveFallbackCandidateCacheKey(params);
if (cacheKey) {
const cached = fallbackCandidateCache.get(cacheKey);
@@ -195,8 +210,13 @@ export function resolveModelCandidateChain(
return candidates;
}
function cloneModelCandidate(candidate: ModelCandidate): ModelCandidate {
return { provider: candidate.provider, model: candidate.model };
function cloneModelCandidate(candidate: ModelFallbackCandidate): ModelFallbackCandidate {
return {
provider: candidate.provider,
model: candidate.model,
routeOrigin: candidate.routeOrigin,
routeResolution: candidate.routeResolution,
};
}
function resolveFallbackCandidateCacheKey(
@@ -205,6 +225,7 @@ function resolveFallbackCandidateCacheKey(
provider: string;
model: string;
fallbacksOverride?: string[];
requestedRouteResolution?: ModelFallbackRouteResolution;
} & ModelManifestNormalizationContext,
): string | null {
if (params.manifestPlugins) {
@@ -239,6 +260,7 @@ function resolveFallbackCandidateCacheKey(
return JSON.stringify({
provider: params.provider,
model: params.model,
requestedRouteResolution: params.requestedRouteResolution,
fallbacksOverride: params.fallbacksOverride,
agentsDefaultsModel: params.cfg?.agents?.defaults?.model,
agentsDefaultsModels: params.cfg?.agents?.defaults?.models,
@@ -277,8 +299,9 @@ function resolveFallbackCandidatesUncached(
provider: string;
model: string;
fallbacksOverride?: string[];
requestedRouteResolution?: ModelFallbackRouteResolution;
} & ModelManifestNormalizationContext,
): ModelCandidate[] {
): ModelFallbackCandidate[] {
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
@@ -311,43 +334,38 @@ function resolveFallbackCandidatesUncached(
allowPluginNormalization: allowPluginModelAliases,
manifestPlugins: params.manifestPlugins,
});
const { candidates, addExplicitCandidate } = createModelCandidateCollector();
const resolvedModelAlias = resolveModelRefFromString({
cfg: params.cfg,
raw: modelRaw,
defaultProvider: providerRaw,
aliasIndex,
allowPluginNormalization: allowPluginModelNormalizationForRef({
const { candidates, addCandidate } = createModelCandidateCollector();
const requestedRouteResolution = params.requestedRouteResolution ?? "raw";
let requestedCandidate = normalizedPrimary;
const exactRequestedRouteConfigured =
hasExactConfiguredProviderModel({
cfg: params.cfg,
provider: providerRaw,
model: modelRaw,
}),
manifestPlugins: params.manifestPlugins,
});
const resolvedProviderModelAlias = resolveModelRefFromString({
cfg: params.cfg,
raw: `${providerRaw}/${modelRaw}`,
defaultProvider,
aliasIndex,
allowPluginNormalization: allowPluginModelNormalizationForRef({
cfg: params.cfg,
provider: providerRaw,
model: modelRaw,
}),
manifestPlugins: params.manifestPlugins,
});
const resolvedBareModelAlias =
resolvedModelAlias?.alias &&
(resolvedModelAlias.ref.provider === normalizedPrimary.provider ||
normalizedPrimary.provider === defaultProvider)
? resolvedModelAlias.ref
: null;
const resolvedPrimary =
(resolvedProviderModelAlias?.alias ? resolvedProviderModelAlias.ref : null) ??
resolvedBareModelAlias ??
normalizedPrimary;
const effectivePrimary = normalizeCandidateRef(resolvedPrimary.provider, resolvedPrimary.model);
addExplicitCandidate(effectivePrimary);
provider: normalizedPrimary.provider,
model: normalizedPrimary.model,
}) || aliasIndex.byKey.has(modelKey(normalizedPrimary.provider, normalizedPrimary.model));
// Persisted legacy pairs may still contain aliases. Prepared routes already
// own their provider, so reparsing them can silently select another route.
if (requestedRouteResolution === "raw" && !exactRequestedRouteConfigured) {
requestedCandidate =
resolveModelAliasFromPair({
cfg: params.cfg,
provider: providerRaw,
model: modelRaw,
defaultProvider,
aliasIndex,
allowPluginNormalization: allowPluginModelNormalizationForRef({
cfg: params.cfg,
provider: providerRaw,
model: modelRaw,
}),
manifestPlugins: params.manifestPlugins,
}) ?? normalizedPrimary;
}
addCandidate(
normalizeCandidateRef(requestedCandidate.provider, requestedCandidate.model),
"requested",
requestedRouteResolution,
);
const modelFallbacks =
params.fallbacksOverride !== undefined
@@ -367,11 +385,19 @@ function resolveFallbackCandidatesUncached(
}
// Fallbacks are explicit user intent; do not silently filter them by the
// model allowlist.
addExplicitCandidate(normalizeCandidateRef(resolved.ref.provider, resolved.ref.model));
addCandidate(
normalizeCandidateRef(resolved.ref.provider, resolved.ref.model),
"configured-fallback",
"resolved",
);
}
if (params.fallbacksOverride === undefined && primary?.provider && primary.model) {
addExplicitCandidate(normalizeCandidateRef(primary.provider, primary.model));
addCandidate(
normalizeCandidateRef(primary.provider, primary.model),
"configured-primary",
"resolved",
);
}
return candidates;
}
@@ -118,6 +118,32 @@ describe("logModelFallbackDecision", () => {
]);
});
it("records the candidate route provenance", () => {
logModelFallbackDecision(
makeAuthFailure({
candidate: {
provider: "modelstudio",
model: "glm-5",
routeOrigin: "requested",
routeResolution: "raw",
},
nextCandidate: {
provider: "minimax",
model: "MiniMax-M2.7-highspeed",
routeOrigin: "configured-fallback",
routeResolution: "resolved",
},
}),
);
expect(loggedPayloads()[0]).toMatchObject({
candidateRouteOrigin: "requested",
candidateRouteResolution: "raw",
nextCandidateRouteOrigin: "configured-fallback",
nextCandidateRouteResolution: "resolved",
});
});
it("keeps distinct sessions visible", () => {
logModelFallbackDecision(makeAuthFailure({ sessionId: `${activeSessionId}-first` }));
logModelFallbackDecision(makeAuthFailure({ sessionId: `${activeSessionId}-second` }));
+32 -3
View File
@@ -7,7 +7,12 @@ import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildTextObservationFields } from "./embedded-agent-error-observation.js";
import type { FailoverReason } from "./embedded-agent-helpers.js";
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
import type {
FallbackAttempt,
ModelCandidate,
ModelFallbackRouteOrigin,
ModelFallbackRouteResolution,
} from "./model-fallback.types.js";
const decisionLog = createSubsystemLogger("model-fallback").child("decision");
const AUTH_DECISION_LOG_COALESCE_WINDOW_MS = 30_000;
@@ -48,6 +53,10 @@ type AuthDecisionLogCoalesceEntry = {
const authDecisionLogCoalesceEntries = new Map<string, AuthDecisionLogCoalesceEntry>();
type FallbackStepOutcome = "next_fallback" | "succeeded" | "chain_exhausted";
type ObservedModelCandidate = ModelCandidate & {
routeOrigin?: ModelFallbackRouteOrigin;
routeResolution?: ModelFallbackRouteResolution;
};
/** Structured fields that describe one fallback-chain transition. */
export type ModelFallbackStepFields = {
@@ -72,14 +81,14 @@ export type ModelFallbackDecisionParams = {
lane?: string;
requestedProvider: string;
requestedModel: string;
candidate: ModelCandidate;
candidate: ObservedModelCandidate;
attempt?: number;
total?: number;
reason?: FailoverReason | null;
status?: number;
code?: string;
error?: string;
nextCandidate?: ModelCandidate;
nextCandidate?: ObservedModelCandidate;
isPrimary?: boolean;
requestedModelMatched?: boolean;
fallbackConfigured?: boolean;
@@ -92,6 +101,14 @@ function formatModelRef(candidate: ModelCandidate): string {
return `${candidate.provider}/${candidate.model}`;
}
function readRouteOrigin(candidate: ObservedModelCandidate) {
return candidate.routeOrigin;
}
function readRouteResolution(candidate: ObservedModelCandidate) {
return candidate.routeResolution;
}
function isAuthDecisionLogCoalescingEligible(params: ModelFallbackDecisionParams): boolean {
return (
(params.decision === "candidate_failed" || params.decision === "skip_candidate") &&
@@ -111,6 +128,8 @@ function buildAuthDecisionLogCoalesceKey(
params.decision,
params.candidate.provider,
params.candidate.model,
readRouteOrigin(params.candidate),
readRouteResolution(params.candidate),
params.attempt,
params.total,
params.reason,
@@ -120,6 +139,8 @@ function buildAuthDecisionLogCoalesceKey(
observedError.providerErrorType,
observedError.errorFingerprint ?? observedError.errorHash,
params.nextCandidate ? formatModelRef(params.nextCandidate) : null,
params.nextCandidate ? readRouteOrigin(params.nextCandidate) : null,
params.nextCandidate ? readRouteResolution(params.nextCandidate) : null,
params.isPrimary,
params.requestedModelMatched,
params.fallbackConfigured,
@@ -288,6 +309,8 @@ export function logModelFallbackDecision(
requestedModel: params.requestedModel,
candidateProvider: params.candidate.provider,
candidateModel: params.candidate.model,
candidateRouteOrigin: readRouteOrigin(params.candidate),
candidateRouteResolution: readRouteResolution(params.candidate),
attempt: params.attempt,
total: params.total,
reason: params.reason,
@@ -297,6 +320,12 @@ export function logModelFallbackDecision(
...fallbackStepFields,
nextCandidateProvider: params.nextCandidate?.provider,
nextCandidateModel: params.nextCandidate?.model,
nextCandidateRouteOrigin: params.nextCandidate
? readRouteOrigin(params.nextCandidate)
: undefined,
nextCandidateRouteResolution: params.nextCandidate
? readRouteResolution(params.nextCandidate)
: undefined,
isPrimary: params.isPrimary,
requestedModelMatched: params.requestedModelMatched,
fallbackConfigured: params.fallbackConfigured,
+5 -3
View File
@@ -65,7 +65,7 @@ import {
logModelFallbackDecision,
type ModelFallbackDecisionParams,
} from "./model-fallback-observation.js";
import type { FallbackAttempt } from "./model-fallback.types.js";
import type { FallbackAttempt, ModelFallbackRouteResolution } from "./model-fallback.types.js";
import type { ModelManifestNormalizationContext } from "./model-ref-shared.js";
import {
resolveSessionSuspensionReason,
@@ -100,6 +100,7 @@ type RunWithModelFallbackParams<T> = {
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
requestedRouteResolution?: ModelFallbackRouteResolution;
run: ModelFallbackRunFn<T>;
onError?: ModelFallbackErrorHandler;
onFallbackStep?: ModelFallbackStepHandler;
@@ -157,6 +158,7 @@ async function runWithModelFallbackInternal<T>(
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
requestedRouteResolution: params.requestedRouteResolution,
manifestPlugins: params.manifestPlugins,
});
const authRuntime =
@@ -219,7 +221,7 @@ async function runWithModelFallbackInternal<T>(
};
const hasFallbackCandidates = candidates.length > 1;
const requestedCandidate = candidates[0];
const requestedCandidate = candidates.find((candidate) => candidate.routeOrigin === "requested");
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates.at(i);
@@ -244,7 +246,7 @@ async function runWithModelFallbackInternal<T>(
prepareAgentHarnessRuntime: params.prepareAgentHarnessRuntime,
...candidate,
});
const isPrimary = i === 0;
const isPrimary = candidate.routeOrigin === "requested";
const requestedModel = requestedCandidate
? sameModelCandidate(candidate, requestedCandidate)
: false;
+123 -5
View File
@@ -49,8 +49,13 @@ import { resolveSessionSuspensionReason } from "./session-suspension.js";
import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
function resolveFallbackCandidateRefs(params: Parameters<typeof resolveModelCandidateChain>[0]) {
return resolveModelCandidateChain(params).map(({ provider, model }) => ({ provider, model }));
}
const testing = {
resolveFallbackCandidates: resolveModelCandidateChain,
resolveFallbackCandidates: resolveFallbackCandidateRefs,
resolveFallbackCandidateRoutes: resolveModelCandidateChain,
resolveSessionSuspensionReason,
shouldDiscardDeferredSessionSuspension,
};
@@ -892,7 +897,7 @@ describe("runWithModelFallback", () => {
});
});
it("resolves primary model aliases before running", () => {
it("preserves prepared primary model routes before running", () => {
const cases = [
{
name: "keeps openai gpt-5.4 on provider",
@@ -902,7 +907,7 @@ describe("runWithModelFallback", () => {
expected: ["openai", "gpt-5.4"],
},
{
name: "resolves bare alias",
name: "resolves a raw bare alias",
cfg: makeCfg({
agents: {
defaults: {
@@ -921,7 +926,7 @@ describe("runWithModelFallback", () => {
expected: ["anthropic", "claude-sonnet-4-6"],
},
{
name: "resolves slash-form alias before provider parsing",
name: "resolves a raw slash-form alias before provider parsing",
cfg: makeCfg({
agents: {
defaults: {
@@ -959,11 +964,59 @@ describe("runWithModelFallback", () => {
model: "deepseek-v4-pro",
expected: ["opencode-go", "deepseek-v4-pro"],
},
{
name: "keeps a custom default-provider route when another provider owns the bare alias",
cfg: {
...makeProviderOrderFallbackCfg([["cloudflare-ai-gateway", "gemini-2.5-flash-lite"]]),
agents: {
defaults: {
model: {
primary: "cloudflare-ai-gateway/gemini-3.1-flash-lite",
fallbacks: [],
},
models: {
"cloudflare-ai-gateway/gemini-2.5-flash-lite": {
alias: "cf-gemini-2.5-flash-lite",
},
"google/gemini-2.5-flash-lite": { alias: "gemini-2.5-flash-lite" },
},
},
},
},
provider: "cloudflare-ai-gateway",
model: "gemini-2.5-flash-lite",
requestedRouteResolution: "resolved",
expected: ["cloudflare-ai-gateway", "gemini-2.5-flash-lite"],
},
{
name: "keeps a built-in default-provider route when another provider owns the bare alias",
cfg: makeCfg({
agents: {
defaults: {
model: {
primary: "google/gemini-3.1-pro-preview",
fallbacks: [],
},
models: {
"google/gemini-2.5-flash-lite": { alias: "google-flash-lite" },
"openrouter/google/gemini-2.5-flash-lite": {
alias: "gemini-2.5-flash-lite",
},
},
},
},
}),
provider: "google",
model: "gemini-2.5-flash-lite",
requestedRouteResolution: "resolved",
expected: ["google", "gemini-2.5-flash-lite"],
},
] satisfies Array<{
name: string;
cfg: OpenClawConfig;
provider: string;
model: string;
requestedRouteResolution?: "raw" | "resolved";
expected: [string, string];
}>;
@@ -972,6 +1025,7 @@ describe("runWithModelFallback", () => {
cfg: testCase.cfg,
provider: testCase.provider,
model: testCase.model,
requestedRouteResolution: testCase.requestedRouteResolution,
});
expect(candidates[0], testCase.name).toEqual({
@@ -981,6 +1035,70 @@ describe("runWithModelFallback", () => {
}
});
it("carries the route origin for every fallback candidate", () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5"],
},
},
},
});
expect(
testing.resolveFallbackCandidateRoutes({
cfg,
provider: "google",
model: "gemini-2.5-flash-lite",
}),
).toEqual([
{
provider: "google",
model: "gemini-2.5-flash-lite",
routeOrigin: "requested",
routeResolution: "raw",
},
{
provider: "anthropic",
model: "claude-haiku-3-5",
routeOrigin: "configured-fallback",
routeResolution: "resolved",
},
{
provider: "openai",
model: "gpt-4.1-mini",
routeOrigin: "configured-primary",
routeResolution: "resolved",
},
]);
});
it("keeps an unmarked canonical built-in route ahead of a colliding alias", () => {
const cfg = makeCfg({
agents: {
defaults: {
model: { primary: "google/gemini-3.1-pro-preview", fallbacks: [] },
models: {
"google/gemini-2.5-flash-lite": { alias: "google-flash-lite" },
"openrouter/google/gemini-2.5-flash-lite": {
alias: "gemini-2.5-flash-lite",
},
},
},
},
});
expect(
testing.resolveFallbackCandidates({
cfg,
provider: "google",
model: "gemini-2.5-flash-lite",
})[0],
).toEqual({ provider: "google", model: "gemini-2.5-flash-lite" });
});
it("falls back on unrecognized errors when candidates remain", async () => {
const cfg = makeCfg();
const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok");
@@ -3713,7 +3831,7 @@ describe("runWithModelFallback", () => {
});
});
it("probes alias-resolved primary models during rate-limit cooldowns", async () => {
it("probes raw alias targets during rate-limit cooldowns", async () => {
const { dir } = await makeAuthStoreWithCooldown("anthropic", "rate_limit");
const cfg = makeCfg({
agents: {
+9
View File
@@ -10,6 +10,15 @@ export type ModelCandidate = {
model: string;
};
export type ModelFallbackRouteOrigin = "requested" | "configured-fallback" | "configured-primary";
export type ModelFallbackRouteResolution = "raw" | "resolved";
/** A runnable route plus the selection edge and resolution state it arrived with. */
export type ModelFallbackCandidate = ModelCandidate & {
routeOrigin: ModelFallbackRouteOrigin;
routeResolution: ModelFallbackRouteResolution;
};
export type FallbackAttempt = {
provider: string;
model: string;
+1
View File
@@ -21,6 +21,7 @@ export {
normalizeModelSelection,
resolveConfiguredModelRef,
resolveHooksGmailModel,
resolveModelAliasFromPair,
resolveModelRefFromString,
} from "./model-selection-shared.js";
+34
View File
@@ -789,6 +789,38 @@ export function resolveModelRefFromString(
return { ref: parsed };
}
/** Resolves legacy provider/model pairs whose model field may still contain an alias. */
export function resolveModelAliasFromPair(
params: {
cfg?: OpenClawConfig;
provider: string;
model: string;
defaultProvider: string;
aliasIndex?: ModelAliasIndex;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ModelManifestNormalizationContext,
): ModelRef | null {
const bareAlias = resolveModelRefFromString({
...params,
raw: params.model,
defaultProvider: params.provider,
});
const providerAlias = resolveModelRefFromString({
...params,
raw: `${params.provider}/${params.model}`,
});
if (providerAlias?.alias) {
return providerAlias.ref;
}
const provider = normalizeProviderId(params.provider);
return bareAlias?.alias &&
(normalizeProviderId(bareAlias.ref.provider) === provider ||
provider === normalizeProviderId(params.defaultProvider))
? bareAlias.ref
: null;
}
/** Resolve the default configured model ref, including aliases and fallback provider rows. */
export function resolveConfiguredModelRef(
params: {
@@ -1645,6 +1677,7 @@ export type ModelVisibilityPolicy = {
allowedCatalog: ModelCatalogEntry[];
allowedKeys: Set<string>;
policyAliasIndex: ModelAliasIndex;
selectionAliasIndex: ModelAliasIndex;
configuredKeys: ReadonlySet<string>;
retainedKeys: ReadonlySet<string>;
exactModelRefs: readonly string[];
@@ -1797,6 +1830,7 @@ export function createModelVisibilityPolicyWithFallbacks(
allowedCatalog: allowed.allowedCatalog,
allowedKeys: allowed.allowedKeys,
policyAliasIndex,
selectionAliasIndex,
configuredKeys,
retainedKeys,
exactModelRefs: visibility.exactModelRefs,
+2
View File
@@ -49,6 +49,7 @@ import {
resolveConfiguredModelRef,
resolveConfiguredOpenRouterCompatAlias,
resolveHooksGmailModel,
resolveModelAliasFromPair,
resolveModelRefFromString,
type ModelAliasIndex,
type ModelRefStatus,
@@ -78,6 +79,7 @@ export {
resolveBareModelDefaultProvider,
resolveConfiguredModelRef,
resolveHooksGmailModel,
resolveModelAliasFromPair,
resolveModelRefFromString,
};
export { isCliProvider } from "./model-selection-cli.js";
+1
View File
@@ -90,6 +90,7 @@ async function reconcileAgentPatchedSessionModel(params: {
modelOverride: marker.prevModelOverride,
providerOverride: marker.prevProviderOverride,
modelOverrideSource: marker.prevModelOverrideSource,
modelOverrideRouteResolution: marker.prevModelOverrideRouteResolution,
modelOverrideFallbackOriginProvider: marker.prevModelOverrideFallbackOriginProvider,
modelOverrideFallbackOriginModel: marker.prevModelOverrideFallbackOriginModel,
authProfileOverride: marker.prevAuthProfileOverride,
@@ -78,6 +78,7 @@ function buildDirectChildSessionPatch(patch: Record<string, unknown>): Partial<S
entry.model = model;
entry.modelOverride = model;
entry.modelOverrideSource = patch.modelOverrideSource === "auto" ? "auto" : "user";
entry.modelOverrideRouteResolution = "resolved";
const fallbackOriginProvider = normalizeOptionalString(
patch.modelOverrideFallbackOriginProvider,
);
+1
View File
@@ -414,6 +414,7 @@ function withActiveStatusModelIdentity(
delete next.providerOverride;
delete next.modelOverride;
delete next.modelOverrideSource;
delete next.modelOverrideRouteResolution;
return next;
}
@@ -8,6 +8,7 @@ import {
} from "../../agents/agent-scope.js";
import { resolvePersistedOverrideModelRef } from "../../agents/model-selection.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
@@ -47,6 +48,9 @@ export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: {
...params.run,
provider: entryRef?.provider ?? params.run.provider,
model: entryRef?.model ?? params.run.model,
requestedRouteResolution: entryRef
? resolveSessionModelOverrideRouteResolution(params.entry)
: params.run.requestedRouteResolution,
autoFallbackPrimaryProbe: undefined,
};
if (hasEntryModelOverride) {
@@ -88,6 +92,7 @@ export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: {
...params.run,
provider: refreshedProbe.provider,
model: refreshedProbe.model,
requestedRouteResolution: "resolved",
autoFallbackPrimaryProbe: refreshedProbe,
};
}
@@ -144,6 +149,7 @@ export async function clearRecoveredAutoFallbackPrimaryProbeSelection(params: {
providerOverride: undefined,
modelOverride: undefined,
modelOverrideSource: undefined,
modelOverrideRouteResolution: undefined,
modelOverrideFallbackOriginProvider: undefined,
modelOverrideFallbackOriginModel: undefined,
...(shouldClearAuthProfile
@@ -65,6 +65,7 @@ describe("runAgentTurnWithFallback: primary probe routing", () => {
).toMatchObject({
provider: "google",
model: "gemini-3.1-pro-preview",
requestedRouteResolution: "resolved",
authProfileId: "google:fallback",
authProfileIdSource: "auto",
autoFallbackPrimaryProbe: undefined,
@@ -100,6 +101,7 @@ describe("runAgentTurnWithFallback: primary probe routing", () => {
).toMatchObject({
provider: "openai",
model: "gpt-5.4",
requestedRouteResolution: "raw",
authProfileId: "openai:work",
authProfileIdSource: "user",
modelOverrideSource: "user",
@@ -61,6 +61,7 @@ export async function runAgentFallbackCandidates(params: AgentFallbackCycleParam
cfg: selection.cfg,
provider: selection.provider,
model: selection.model,
requestedRouteResolution: selection.requestedRouteResolution,
agentDir: selection.agentDir,
fallbacksOverride: selection.fallbacksOverride,
},
@@ -112,6 +112,7 @@ type ModelFallbackParams = {
sessionId?: string;
sessionKey?: string;
fallbacksOverride?: unknown[];
requestedRouteResolution?: "raw" | "resolved";
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
@@ -1009,6 +1010,10 @@ describe("runMemoryFlushIfNeeded", () => {
primary: "anthropic/claude",
fallbacks: ["openai/gpt-5.4"],
},
models: {
"ollama/qwen3:8b": { alias: "memory-flush" },
"openrouter/qwen3:8b": { alias: "qwen3:8b" },
},
compaction: {
memoryFlush: {
model: "ollama/qwen3:8b",
@@ -1033,6 +1038,7 @@ describe("runMemoryFlushIfNeeded", () => {
const fallbackCall = requireModelFallbackCall();
expect(fallbackCall.provider).toBe("ollama");
expect(fallbackCall.model).toBe("qwen3:8b");
expect(fallbackCall.requestedRouteResolution).toBe("raw");
expect(fallbackCall.abortSignal).toBe(replyOperation.abortSignal);
expect(fallbackCall.sessionId).toBe("session");
expect(fallbackCall.fallbacksOverride).toEqual([]);
@@ -253,6 +253,7 @@ function resolveMemoryFlushModelFallbackOptions(
...options,
provider: overrideProvider,
model: overrideModel,
requestedRouteResolution: "raw" as const,
fallbacksOverride: [],
};
}
@@ -260,6 +261,7 @@ function resolveMemoryFlushModelFallbackOptions(
return {
...options,
model: override,
requestedRouteResolution: "raw" as const,
fallbacksOverride: [],
};
}
@@ -1357,6 +1359,7 @@ export async function runMemoryFlushIfNeeded(params: {
cfg: selection.cfg,
provider: selection.provider,
model: selection.model,
requestedRouteResolution: selection.requestedRouteResolution,
agentDir: selection.agentDir,
fallbacksOverride: selection.fallbacksOverride,
},
@@ -33,6 +33,7 @@ export function resolveModelFallbackOptions(
cfg: config,
provider: run.provider,
model: run.model,
requestedRouteResolution: run.requestedRouteResolution,
agentDir: run.agentDir,
agentId: run.agentId,
sessionKey: run.runtimePolicySessionKey ?? run.sessionKey,
@@ -45,6 +45,7 @@ function makeRun(overrides: Partial<FollowupRun["run"]> = {}): FollowupRun["run"
config: { models: { providers: {} } },
provider: "openai",
model: "gpt-4.1",
requestedRouteResolution: "resolved",
agentDir: "/tmp/agent",
sessionKey: "agent:test:session",
sessionFile: "/tmp/session.json",
@@ -88,6 +89,7 @@ describe("agent-runner-utils", () => {
cfg: run.config,
provider: run.provider,
model: run.model,
requestedRouteResolution: "resolved",
agentDir: run.agentDir,
agentId: run.agentId,
sessionKey: run.sessionKey,
@@ -623,6 +623,7 @@ export async function handleDirectiveOnly(
key: sessionKey,
nextProvider: modelSelection.provider,
nextModel: modelSelection.model,
nextRouteResolution: "resolved",
nextModelOverrideSource: "user",
nextAuthProfileId: appliedSessionEntry.authProfileOverride,
nextAuthProfileIdSource: appliedSessionEntry.authProfileOverrideSource,
@@ -1999,6 +1999,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
key: sessionKey,
nextProvider: "openai",
nextModel: "gpt-4o",
nextRouteResolution: "resolved",
nextModelOverrideSource: "user",
nextAuthProfileId: undefined,
nextAuthProfileIdSource: undefined,
@@ -2206,6 +2207,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
key: sessionKey,
nextProvider: "anthropic",
nextModel: "claude-opus-4-6",
nextRouteResolution: "resolved",
nextModelOverrideSource: "user",
nextAuthProfileId: "anthropic:work",
nextAuthProfileIdSource: "user",
@@ -430,6 +430,7 @@ export async function persistInlineDirectives(params: {
key: sessionKey,
nextProvider: provider,
nextModel: model,
nextRouteResolution: "resolved",
nextModelOverrideSource: "user",
nextAuthProfileId: appliedSessionEntry.authProfileOverride,
nextAuthProfileIdSource: appliedSessionEntry.authProfileOverrideSource,
+2
View File
@@ -947,6 +947,7 @@ export function createFollowupRunner(params: {
providerOverride: undefined,
modelOverride: undefined,
modelOverrideSource: undefined,
modelOverrideRouteResolution: undefined,
modelOverrideFallbackOriginProvider: undefined,
modelOverrideFallbackOriginModel: undefined,
...(shouldClearAuthProfile
@@ -988,6 +989,7 @@ export function createFollowupRunner(params: {
cfg: selection.cfg,
provider: selection.provider,
model: selection.model,
requestedRouteResolution: selection.requestedRouteResolution,
agentDir: selection.agentDir,
fallbacksOverride: selection.fallbacksOverride,
},
@@ -134,6 +134,9 @@ type ReplyDirectiveContinuation = {
resolvedBlockStreamingBreak: "text_end" | "message_end";
provider: string;
model: string;
requestedRouteResolution: Awaited<
ReturnType<typeof createModelSelectionState>
>["requestedRouteResolution"];
modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
contextTokens: number;
inlineStatusRequested: boolean;
@@ -712,6 +715,9 @@ export async function resolveReplyDirectives(params: {
resolvedBlockStreamingBreak,
provider,
model,
requestedRouteResolution: effectiveModelDirective
? "resolved"
: modelState.requestedRouteResolution,
modelState,
contextTokens,
inlineStatusRequested,
@@ -95,6 +95,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
command,
provider,
model,
requestedRouteResolution,
typing,
opts,
defaultModel,
@@ -361,6 +362,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
skillsSnapshot,
provider,
model,
requestedRouteResolution,
modelSelectionLocked: preparedSessionState.sessionEntry?.modelSelectionLocked === true,
hasSessionModelOverride: runHasSessionModelOverride,
modelOverrideSource: runModelOverrideSource,
@@ -76,6 +76,9 @@ export type RunPreparedReplyParams = {
modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
provider: string;
model: string;
requestedRouteResolution?: Awaited<
ReturnType<typeof createModelSelectionState>
>["requestedRouteResolution"];
perMessageQueueMode?: InlineDirectives["queueMode"];
perMessageQueueOptions?: {
debounceMs?: number;
+4
View File
@@ -846,6 +846,7 @@ export async function getReplyFromConfig(
resolvedBlockStreamingBreak,
provider: resolvedProvider,
model: resolvedModel,
requestedRouteResolution,
modelState,
contextTokens,
inlineStatusRequested,
@@ -1073,6 +1074,9 @@ export async function getReplyFromConfig(
modelState: runModelState,
provider: runProvider,
model: runModel,
requestedRouteResolution: runAutoFallbackPrimaryProbe
? runModelState.requestedRouteResolution
: requestedRouteResolution,
perMessageQueueMode,
perMessageQueueOptions,
typing,
@@ -0,0 +1,34 @@
import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
/** Resolves the context window token count for the selected provider/model. */
export function resolveContextTokens(params: {
cfg: OpenClawConfig;
agentCfg: NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> | undefined;
provider: string;
model: string;
modelContextWindow?: number;
modelContextTokens?: number;
}): number {
const modelContextTokens = resolveContextTokensForModel({
cfg: params.cfg,
provider: params.provider,
model: params.model,
modelContextWindow: params.modelContextWindow,
modelContextTokens: params.modelContextTokens,
allowAsyncLoad: false,
});
const agentContextTokens =
typeof params.agentCfg?.contextTokens === "number" && params.agentCfg.contextTokens > 0
? Math.floor(params.agentCfg.contextTokens)
: undefined;
if (agentContextTokens !== undefined) {
return modelContextTokens !== undefined
? Math.min(agentContextTokens, modelContextTokens)
: agentContextTokens;
}
return modelContextTokens ?? DEFAULT_CONTEXT_TOKENS;
}
@@ -12,6 +12,7 @@ import {
loadManifestModelCatalog,
loadPreparedModelCatalog as loadModelCatalogLocal,
} from "../../agents/model-catalog.runtime.js";
import { resolveModelCandidateChain } from "../../agents/model-fallback.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
@@ -640,6 +641,7 @@ describe("createModelSelectionState catalog loading", () => {
providerOverride: "vllm",
modelOverride: "new-local-model",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
};
const sessionStore = { main: sessionEntry };
@@ -658,6 +660,7 @@ describe("createModelSelectionState catalog loading", () => {
expect(state.provider).toBe("vllm");
expect(state.model).toBe("new-local-model");
expect(state.requestedRouteResolution).toBe("resolved");
expect(sessionStore.main.modelOverride).toBe("new-local-model");
expect(loadModelCatalogLocal).toHaveBeenCalledOnce();
});
@@ -1477,6 +1480,7 @@ describe("createModelSelectionState respects session model override", () => {
expect(state.provider).toBe("openai");
expect(state.model).toBe("gpt-added-after-startup");
expect(state.requestedRouteResolution).toBe("raw");
expect(state.resetModelOverride).toBe(false);
expect(sessionStore[sessionKey]?.providerOverride).toBe("openai");
expect(sessionStore[sessionKey]?.modelOverride).toBe("gpt-added-after-startup");
@@ -1530,6 +1534,7 @@ describe("createModelSelectionState auto-failover overrides", () => {
providerOverride: string;
modelOverride: string;
modelOverrideSource: "auto" | "user" | undefined;
modelOverrideRouteResolution?: "resolved";
modelOverrideFallbackOriginProvider?: string;
modelOverrideFallbackOriginModel?: string;
fallbackNoticeSelectedModel?: string;
@@ -1547,6 +1552,7 @@ describe("createModelSelectionState auto-failover overrides", () => {
providerOverride: params.providerOverride,
modelOverride: params.modelOverride,
modelOverrideSource: params.modelOverrideSource,
modelOverrideRouteResolution: params.modelOverrideRouteResolution,
modelOverrideFallbackOriginProvider: params.modelOverrideFallbackOriginProvider,
modelOverrideFallbackOriginModel: params.modelOverrideFallbackOriginModel,
fallbackNoticeSelectedModel: params.fallbackNoticeSelectedModel,
@@ -1757,6 +1763,7 @@ describe("createModelSelectionState auto-failover overrides", () => {
providerOverride: "openai",
modelOverride: "gpt-5.5",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
});
const sessionStore = { [sessionKey]: sessionEntry };
@@ -2028,11 +2035,13 @@ describe("createModelSelectionState auto-failover overrides", () => {
providerOverride: "openrouter",
modelOverride: "minimax/minimax-m2.7",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
});
// User-selected override must persist.
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(state.requestedRouteResolution).toBe("resolved");
expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter");
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
@@ -2049,10 +2058,95 @@ describe("createModelSelectionState auto-failover overrides", () => {
expect(state.provider).toBe("openrouter");
expect(state.model).toBe("minimax/minimax-m2.7");
expect(state.requestedRouteResolution).toBe("raw");
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
expect(state.resetModelOverride).toBe(false);
});
it("keeps a canonical stored route ahead of a colliding bare alias", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google/gemini-3.1-pro-preview", fallbacks: [] },
models: {
"google/gemini-2.5-flash-lite": { alias: "google-flash-lite" },
"openrouter/google/gemini-2.5-flash-lite": {
alias: "gemini-2.5-flash-lite",
},
},
},
},
} as OpenClawConfig;
const sessionEntry = makeEntry({
providerOverride: "google",
modelOverride: "gemini-2.5-flash-lite",
modelOverrideSource: "user",
});
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore: { main: sessionEntry },
sessionKey: "main",
defaultProvider: "google",
defaultModel: "gemini-3.1-pro-preview",
provider: "google",
model: "gemini-3.1-pro-preview",
hasModelDirective: false,
});
expect(state.requestedRouteResolution).toBe("resolved");
expect(
resolveModelCandidateChain({
cfg,
provider: state.provider,
model: state.model,
requestedRouteResolution: state.requestedRouteResolution,
})[0],
).toMatchObject({ provider: "google", model: "gemini-2.5-flash-lite" });
});
it("canonicalizes a reset-upgraded legacy alias before fallback", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: [] },
models: {
"anthropic/claude-sonnet-4-6": { alias: "legacy-fast-model" },
},
},
},
} as OpenClawConfig;
const sessionEntry = makeEntry({
providerOverride: "anthropic",
modelOverride: "legacy-fast-model",
// Older resets added the source without resolving the stored alias.
modelOverrideSource: "user",
});
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore: { main: sessionEntry },
sessionKey: "main",
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-6",
provider: "anthropic",
model: "claude-sonnet-4-6",
hasModelDirective: false,
});
expect(state.requestedRouteResolution).toBe("resolved");
expect(
resolveModelCandidateChain({
cfg,
provider: state.provider,
model: state.model,
requestedRouteResolution: state.requestedRouteResolution,
})[0],
).toMatchObject({ provider: "anthropic", model: "claude-sonnet-4-6" });
});
it("does not touch an auto-failover override inherited from a parent session", async () => {
// Auto clearing only applies to a direct session override, not one inherited
// from a parent. The parent's own session state is managed separately.
+42 -51
View File
@@ -7,18 +7,16 @@ import {
} from "../../agents/agent-scope.js";
import { isStoredCredentialCompatibleWithAuthProvider } from "../../agents/auth-profiles/order.js";
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { ModelFallbackRouteResolution } from "../../agents/model-fallback.types.js";
import {
type ModelAliasIndex,
buildConfiguredModelCatalog,
legacyModelKey,
modelKey,
normalizeProviderId,
normalizeStoredOverrideModel,
resolvePersistedOverrideModelRef,
resolveModelAliasFromPair,
resolveReasoningDefault,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
@@ -46,10 +44,12 @@ export {
resolveModelDirectiveSelection,
type ModelDirectiveSelection,
} from "./model-selection-directive.js";
export { resolveContextTokens } from "./model-selection-context.js";
import { normalizeRuntimeRef, resolveRuntimeNormalization } from "./model-runtime-normalization.js";
import {
isStaleHeartbeatAutoFallbackOverride,
normalizeStoredRuntimeModelRef,
resolveDirectStoredModelOverride,
resolveStoredModelOverride,
} from "./stored-model-override.js";
@@ -64,6 +64,7 @@ type ThinkingDefaultSelection = {
type ModelSelectionState = {
provider: string;
model: string;
requestedRouteResolution: ModelFallbackRouteResolution;
allowedModelKeys: Set<string>;
allowedModelCatalog: ModelCatalog;
policyAliasIndex: ModelAliasIndex;
@@ -98,6 +99,7 @@ export function createFastTestModelSelectionState(params: {
return {
provider: params.provider,
model: params.model,
requestedRouteResolution: "resolved",
allowedModelKeys: new Set<string>(),
allowedModelCatalog: [],
policyAliasIndex: { byAlias: new Map(), byKey: new Map() },
@@ -198,6 +200,7 @@ export async function createModelSelectionState(params: {
let provider = params.provider;
let model = params.model;
let requestedRouteResolution: ModelFallbackRouteResolution = "resolved";
const primaryProvider = params.primaryProvider ?? defaultProvider;
const primaryModel = params.primaryModel ?? defaultModel;
const hasOneTurnModelOverride = params.hasOneTurnModelOverride === true;
@@ -233,18 +236,10 @@ export async function createModelSelectionState(params: {
let resetModelOverride = false;
let resetModelOverrideRef: string | undefined;
let resetModelOverrideReason: "disallowed" | "stale" | "temporarily-unavailable" | undefined;
const normalizedDirectStoredOverride = normalizeStoredOverrideModel({
providerOverride: sessionEntry?.providerOverride,
modelOverride: sessionEntry?.modelOverride,
});
const directStoredOverride = resolvePersistedOverrideModelRef({
const directStoredModelOverride = resolveDirectStoredModelOverride({
sessionEntry,
defaultProvider,
overrideProvider: normalizedDirectStoredOverride.providerOverride,
overrideModel: normalizedDirectStoredOverride.modelOverride,
});
const directStoredModelOverride = directStoredOverride
? { ...directStoredOverride, source: "session" as const }
: null;
const staleHeartbeatAutoFallbackOverride = isStaleHeartbeatAutoFallbackOverride({
isHeartbeat: params.isHeartbeat,
hasResolvedHeartbeatModelOverride: params.hasResolvedHeartbeatModelOverride,
@@ -281,7 +276,7 @@ export async function createModelSelectionState(params: {
);
const normalizedDirectOverride = directStoredModelOverride
? normalizeRuntimeRef(
directStoredModelOverride.provider,
directStoredModelOverride.provider ?? defaultProvider,
directStoredModelOverride.model,
runtimeModelNormalization,
)
@@ -347,12 +342,12 @@ export async function createModelSelectionState(params: {
sessionEntry &&
sessionStore &&
sessionKey &&
directStoredOverride &&
directStoredModelOverride &&
!hasOneTurnModelOverride
) {
const normalizedOverride = normalizeStoredRuntimeModelRef(
directStoredOverride.provider,
directStoredOverride.model,
directStoredModelOverride.provider ?? defaultProvider,
directStoredModelOverride.model,
cfg,
sessionEntry,
runtimeModelNormalization,
@@ -417,6 +412,7 @@ export async function createModelSelectionState(params: {
if (currentSelectionKey === directStoredOverrideKey) {
provider = primaryProvider;
model = primaryModel;
requestedRouteResolution = "resolved";
}
}
@@ -438,9 +434,28 @@ export async function createModelSelectionState(params: {
(resetModelOverride && staleDirectStoredOverride && storedOverride?.source === "session");
if (storedOverride?.model && !skipStoredOverride) {
const storedProvider = storedOverride.provider || defaultProvider;
const storedRouteCataloged = Boolean(
findSelectedCatalogEntry({
catalog: modelCatalog ?? allowedModelCatalog,
provider: storedProvider,
model: storedOverride.model,
}),
);
const storedAlias =
storedOverride.routeResolution === "raw" && !storedRouteCataloged
? resolveModelAliasFromPair({
cfg,
provider: storedProvider,
model: storedOverride.model,
defaultProvider,
aliasIndex: visibilityPolicy.selectionAliasIndex,
...runtimeModelNormalization,
})
: null;
const normalizedStoredOverride = normalizeStoredRuntimeModelRef(
storedOverride.provider || defaultProvider,
storedOverride.model,
storedAlias?.provider ?? storedProvider,
storedAlias?.model ?? storedOverride.model,
cfg,
sessionEntry,
runtimeModelNormalization,
@@ -449,10 +464,13 @@ export async function createModelSelectionState(params: {
if (visibilityPolicy.allowsKey(key)) {
provider = normalizedStoredOverride.provider;
model = normalizedStoredOverride.model;
requestedRouteResolution =
storedAlias || storedRouteCataloged ? "resolved" : storedOverride.routeResolution;
}
}
if (!params.hasModelDirective && !hasOneTurnModelOverride) {
const unresolvedSelectionKey = modelKey(provider, model);
const allowedInitialSelection = visibilityPolicy.resolveSelection({
provider,
model,
@@ -465,6 +483,9 @@ export async function createModelSelectionState(params: {
}
provider = allowedInitialSelection.provider;
model = allowedInitialSelection.model;
if (modelKey(provider, model) !== unresolvedSelectionKey) {
requestedRouteResolution = "resolved";
}
}
if (
@@ -702,6 +723,7 @@ export async function createModelSelectionState(params: {
return {
provider,
model,
requestedRouteResolution,
allowedModelKeys,
allowedModelCatalog,
policyAliasIndex: visibilityPolicy.policyAliasIndex,
@@ -719,34 +741,3 @@ export async function createModelSelectionState(params: {
modelContextTokens: selectedCatalogEntry?.contextTokens,
};
}
/** Resolves the context window token count for the selected provider/model. */
export function resolveContextTokens(params: {
cfg: OpenClawConfig;
agentCfg: NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> | undefined;
provider: string;
model: string;
modelContextWindow?: number;
modelContextTokens?: number;
}): number {
const modelContextTokens = resolveContextTokensForModel({
cfg: params.cfg,
provider: params.provider,
model: params.model,
modelContextWindow: params.modelContextWindow,
modelContextTokens: params.modelContextTokens,
allowAsyncLoad: false,
});
const agentContextTokens =
typeof params.agentCfg?.contextTokens === "number" && params.agentCfg.contextTokens > 0
? Math.floor(params.agentCfg.contextTokens)
: undefined;
if (agentContextTokens !== undefined) {
return modelContextTokens !== undefined
? Math.min(agentContextTokens, modelContextTokens)
: agentContextTokens;
}
return modelContextTokens ?? DEFAULT_CONTEXT_TOKENS;
}
@@ -1251,6 +1251,7 @@ describe("followup queue collect routing", () => {
expect(calls).toHaveLength(1);
expect(calls[0]?.run.model).toBe("current-model");
expect(calls[0]?.run.requestedRouteResolution).toBe("raw");
expect(calls[0]?.originatingChatType).toBe("channel");
expect(calls[0]?.run.senderId).toBe("owner");
expect(calls[0]?.run.senderIsOwner).toBe(true);
+6
View File
@@ -21,6 +21,7 @@ function makeRun(): FollowupRun["run"] {
config: {} as FollowupRun["run"]["config"],
provider: "anthropic",
model: "claude-opus-4-6",
requestedRouteResolution: "resolved",
authProfileId: "profile-a",
authProfileIdSource: "user",
timeoutMs: 30_000,
@@ -62,6 +63,7 @@ describe("refreshQueuedFollowupSession", () => {
key: QUEUE_KEY,
nextProvider: "openai",
nextModel: "gpt-4o",
nextRouteResolution: "resolved",
nextAuthProfileId: undefined,
nextAuthProfileIdSource: undefined,
});
@@ -109,6 +111,7 @@ describe("refreshQueuedFollowupSession", () => {
key: QUEUE_KEY,
nextProvider: "ollama",
nextModel: "qwen3.5:27b",
nextRouteResolution: "resolved",
nextModelOverrideSource: "user",
});
@@ -138,6 +141,7 @@ describe("refreshQueuedFollowupSession", () => {
key: QUEUE_KEY,
nextProvider: "openai",
nextModel: "gpt-5.6-luna",
nextRouteResolution: "resolved",
nextThinking: { level: "ultra", agentRuntime: "codex" },
});
@@ -160,6 +164,7 @@ describe("refreshQueuedFollowupSession", () => {
key: QUEUE_KEY,
nextProvider: "custom",
nextModel: "reasoner",
nextRouteResolution: "resolved",
nextThinking: { level: "ultra", agentRuntime: "openclaw" },
});
@@ -178,6 +183,7 @@ describe("refreshQueuedFollowupSession", () => {
key: QUEUE_KEY,
nextProvider: "openai",
nextModel: "gpt-5.6-sol",
nextRouteResolution: "resolved",
nextThinking: { agentRuntime: "codex" },
});
+8 -3
View File
@@ -1,5 +1,6 @@
// Tracks queue state for active, pending, and recently deduped reply runs.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { ModelFallbackRouteResolution } from "../../../agents/model-fallback.types.js";
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
import { applyQueueRuntimeSettings } from "../../../utils/queue-helpers.js";
import {
@@ -185,6 +186,7 @@ export function refreshQueuedFollowupSession(params: {
nextSessionFile?: string;
nextProvider?: string;
nextModel?: string;
nextRouteResolution?: ModelFallbackRouteResolution;
nextModelOverrideSource?: "auto" | "user";
nextAuthProfileId?: string;
nextAuthProfileIdSource?: "auto" | "user";
@@ -206,10 +208,10 @@ export function refreshQueuedFollowupSession(params: {
Boolean(params.previousSessionId) &&
Boolean(params.nextSessionId) &&
params.previousSessionId !== params.nextSessionId;
const hasNextModelRoute =
typeof params.nextProvider === "string" || typeof params.nextModel === "string";
const shouldRewriteModelSelection =
typeof params.nextProvider === "string" ||
typeof params.nextModel === "string" ||
Object.hasOwn(params, "nextModelOverrideSource");
hasNextModelRoute || Object.hasOwn(params, "nextModelOverrideSource");
const shouldRewriteSelection =
shouldRewriteModelSelection ||
Object.hasOwn(params, "nextAuthProfileId") ||
@@ -237,6 +239,9 @@ export function refreshQueuedFollowupSession(params: {
if (typeof params.nextModel === "string") {
run.model = params.nextModel;
}
if (hasNextModelRoute) {
run.requestedRouteResolution = params.nextRouteResolution ?? "raw";
}
if (shouldRewriteModelSelection) {
delete run.hasAutoFallbackProvenance;
}
+2
View File
@@ -4,6 +4,7 @@ import type { AutoFallbackPrimaryProbe } from "../../../agents/agent-scope.js";
import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
import type { CliSessionBindingFacts } from "../../../agents/cli-runner/types.js";
import type { CurrentInboundPromptContext } from "../../../agents/embedded-agent-runner/run/params.js";
import type { ModelFallbackRouteResolution } from "../../../agents/model-fallback.types.js";
import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types.js";
import type { ChatType } from "../../../channels/chat-type.js";
import type { InboundEventKind } from "../../../channels/inbound-event/kind.js";
@@ -155,6 +156,7 @@ export type FollowupRun = {
skillsSnapshot?: SkillSnapshot;
provider: string;
model: string;
requestedRouteResolution?: ModelFallbackRouteResolution;
/** Prevents the queued run from selecting configured fallback models. */
modelSelectionLocked?: boolean;
hasSessionModelOverride?: boolean;
+25 -12
View File
@@ -364,6 +364,22 @@ export function resolveReplySessionPreprocessingState(
}
/** Initializes or reuses the reply session state for one inbound turn. */
type SessionModelOverrideSelection = Pick<
SessionEntry,
"modelOverride" | "providerOverride" | "modelOverrideSource" | "modelOverrideRouteResolution"
>;
function selectSessionModelOverride(
entry: Partial<SessionModelOverrideSelection>,
): SessionModelOverrideSelection {
return {
modelOverride: entry.modelOverride,
providerOverride: entry.providerOverride,
modelOverrideSource: entry.modelOverrideSource,
modelOverrideRouteResolution: entry.modelOverrideRouteResolution,
};
}
export async function initSessionState(params: InitSessionStateParams): Promise<SessionInitResult> {
return await runWithSessionInitConflictRetry(
async () => await initSessionStateAttempt(params, false),
@@ -483,9 +499,7 @@ async function initSessionStateAttemptLocked(
let persistedReasoning: string | undefined;
let persistedTtsAuto: TtsAutoMode | undefined;
let persistedResponseUsage: SessionEntry["responseUsage"];
let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined;
let persistedModelOverrideSource: SessionEntry["modelOverrideSource"];
let persistedModelSelection: SessionModelOverrideSelection | undefined;
let persistedAuthProfileOverride: string | undefined;
let persistedAuthProfileOverrideSource: SessionEntry["authProfileOverrideSource"];
let persistedAuthProfileOverrideCompactionCount: number | undefined;
@@ -776,9 +790,7 @@ async function initSessionStateAttemptLocked(
persistedReasoning = reusableEntry.reasoningLevel;
persistedTtsAuto = reusableEntry.ttsAuto;
persistedResponseUsage = reusableEntry.responseUsage;
persistedModelOverride = reusableEntry.modelOverride;
persistedProviderOverride = reusableEntry.providerOverride;
persistedModelOverrideSource = reusableEntry.modelOverrideSource;
persistedModelSelection = selectSessionModelOverride(reusableEntry);
persistedAuthProfileOverride = reusableEntry.authProfileOverride;
persistedAuthProfileOverrideSource = reusableEntry.authProfileOverrideSource;
persistedAuthProfileOverrideCompactionCount = reusableEntry.authProfileOverrideCompactionCount;
@@ -804,9 +816,7 @@ async function initSessionStateAttemptLocked(
// despite the `Model set to ... for this session` ack (#90119, #69301).
if (entry) {
const preservedSelection = resolveResetPreservedSelection({ entry });
persistedModelOverride = preservedSelection.modelOverride;
persistedProviderOverride = preservedSelection.providerOverride;
persistedModelOverrideSource = preservedSelection.modelOverrideSource;
persistedModelSelection = selectSessionModelOverride(preservedSelection);
persistedAuthProfileOverride = preservedSelection.authProfileOverride;
persistedAuthProfileOverrideSource = preservedSelection.authProfileOverrideSource;
persistedAuthProfileOverrideCompactionCount =
@@ -843,6 +853,8 @@ async function initSessionStateAttemptLocked(
}
const baseEntry = !isNewSession && effectiveFreshEntry ? reusableEntry : undefined;
const modelSelection =
persistedModelSelection ?? (baseEntry ? selectSessionModelOverride(baseEntry) : undefined);
const usageFamilyKey = previousSessionEntry
? (previousSessionEntry.usageFamilyKey ?? sessionKey)
: baseEntry?.usageFamilyKey;
@@ -943,9 +955,10 @@ async function initSessionStateAttemptLocked(
usageFamilyKey,
usageFamilySessionIds,
previousSessionId: baseEntry?.previousSessionId,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
modelOverrideSource: persistedModelOverrideSource ?? baseEntry?.modelOverrideSource,
modelOverride: modelSelection?.modelOverride,
providerOverride: modelSelection?.providerOverride,
modelOverrideSource: modelSelection?.modelOverrideSource,
modelOverrideRouteResolution: modelSelection?.modelOverrideRouteResolution,
authProfileOverride: persistedAuthProfileOverride ?? baseEntry?.authProfileOverride,
authProfileOverrideSource:
persistedAuthProfileOverrideSource ?? baseEntry?.authProfileOverrideSource,
@@ -2,6 +2,23 @@ import { describe, expect, it, vi } from "vitest";
import { resolveStoredModelOverride } from "./stored-model-override.js";
describe("resolveStoredModelOverride", () => {
it("recovers resolved provenance for legacy auto-fallback overrides", () => {
expect(
resolveStoredModelOverride({
defaultProvider: "openai",
sessionEntry: {
sessionId: "legacy-fallback",
updatedAt: 1,
providerOverride: "cloudflare-ai-gateway",
modelOverride: "gemini-2.5-flash-lite",
modelOverrideSource: "auto",
modelOverrideFallbackOriginProvider: "anthropic",
modelOverrideFallbackOriginModel: "claude-sonnet-4-6",
},
}),
).toMatchObject({ routeResolution: "resolved" });
});
it("loads parent overrides without requiring a whole session store", () => {
const loadSessionEntry = vi.fn((sessionKey: string) =>
sessionKey === "agent:main:telegram:dm:parent"
@@ -24,6 +41,7 @@ describe("resolveStoredModelOverride", () => {
provider: "anthropic",
model: "claude-sonnet-4-7",
source: "parent",
routeResolution: "raw",
});
expect(loadSessionEntry).toHaveBeenCalledWith("agent:main:telegram:dm:parent");
});
+34 -9
View File
@@ -2,6 +2,7 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
import { resolveCliRuntimeCanonicalProvider } from "../../agents/cli-backends.js";
import type { ModelFallbackRouteResolution } from "../../agents/model-fallback.types.js";
import {
modelKey,
normalizeModelRef,
@@ -10,6 +11,7 @@ import {
} from "../../agents/model-selection.js";
import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION } from "../../agents/model-visibility-policy.js";
import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js";
import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { RuntimeModelNormalization } from "./model-runtime-normalization.js";
@@ -19,8 +21,32 @@ export type StoredModelOverride = {
provider?: string;
model: string;
source: "session" | "parent";
routeResolution: ModelFallbackRouteResolution;
};
/** Resolves only the current session's persisted model override. */
export function resolveDirectStoredModelOverride(params: {
sessionEntry?: SessionEntry;
defaultProvider: string;
}): StoredModelOverride | null {
const normalized = normalizeStoredOverrideModel({
providerOverride: params.sessionEntry?.providerOverride,
modelOverride: params.sessionEntry?.modelOverride,
});
const direct = resolvePersistedOverrideModelRef({
defaultProvider: params.defaultProvider,
overrideProvider: normalized.providerOverride,
overrideModel: normalized.modelOverride,
});
return direct
? {
...direct,
source: "session",
routeResolution: resolveSessionModelOverrideRouteResolution(params.sessionEntry),
}
: null;
}
/** Normalizes a stored model ref, resolving runtime aliases only for CLI-bound sessions. */
export function normalizeStoredRuntimeModelRef(
provider: string,
@@ -67,17 +93,12 @@ export function resolveStoredModelOverride(params: {
parentSessionKey?: string;
defaultProvider: string;
}): StoredModelOverride | null {
const directOverride = normalizeStoredOverrideModel({
providerOverride: params.sessionEntry?.providerOverride,
modelOverride: params.sessionEntry?.modelOverride,
});
const direct = resolvePersistedOverrideModelRef({
const direct = resolveDirectStoredModelOverride({
sessionEntry: params.sessionEntry,
defaultProvider: params.defaultProvider,
overrideProvider: directOverride.providerOverride,
overrideModel: directOverride.modelOverride,
});
if (direct) {
return { ...direct, source: "session" };
return direct;
}
const parentKey = resolveParentSessionKeyCandidate({
sessionKey: params.sessionKey,
@@ -99,7 +120,11 @@ export function resolveStoredModelOverride(params: {
if (!parentOverride) {
return null;
}
return { ...parentOverride, source: "parent" };
return {
...parentOverride,
source: "parent",
routeResolution: resolveSessionModelOverrideRouteResolution(parentEntry),
};
}
function resolveModelRefKey(params: {
@@ -146,6 +146,7 @@ describe("doctor session state provider routes", () => {
providerOverride: "openai-codex",
modelOverride: "gpt-5.4",
modelOverrideSource: "auto",
modelOverrideRouteResolution: "resolved",
modelProvider: "openai-codex",
model: "gpt-5.4",
contextTokens: 1_050_000,
@@ -171,6 +172,7 @@ describe("doctor session state provider routes", () => {
expect(result.changes.join("\n")).toContain("Cleared stale Codex session routing state");
expect(repaired.providerOverride).toBeUndefined();
expect(repaired.modelOverride).toBeUndefined();
expect(repaired.modelOverrideRouteResolution).toBeUndefined();
expect(repaired.modelProvider).toBeUndefined();
expect(repaired.agentHarnessId).toBeUndefined();
expect(repaired.authProfileOverride).toBeUndefined();
@@ -416,6 +416,7 @@ function applySessionRouteStateRepair(params: {
clear("providerOverride");
clear("modelOverride");
clear("modelOverrideSource");
clear("modelOverrideRouteResolution");
clear("liveModelSwitchPending");
}
if (params.repair.reasons.includes("runtime model state")) {
@@ -153,6 +153,7 @@ function repairProviderlessCodexSessionOverride(
}
entry.providerOverride = "openai";
entry.modelOverrideRouteResolution = "resolved";
if (entry.model !== undefined || entry.modelProvider !== undefined) {
delete entry.model;
delete entry.modelProvider;
@@ -190,6 +191,9 @@ function repairCodexSessionStoreRoutes(params: {
modelKey: "modelOverride",
blockedModelIdentities: params.blockedModelIdentities,
});
if (changedOverrideModelRoute) {
entry.modelOverrideRouteResolution = "resolved";
}
const changedProviderlessOverride = repairProviderlessCodexSessionOverride(
entry,
params.blockedModelIdentities,
@@ -4021,6 +4021,9 @@ describe("collectCodexRouteWarnings", () => {
expect(expectDefined(store.main, "store.main test invariant").providerOverride).toBe("openai");
expect(expectDefined(store.main, "store.main test invariant").modelOverride).toBe("gpt-5.4");
expect(expectDefined(store.main, "store.main test invariant").modelOverrideSource).toBe("auto");
expect(
expectDefined(store.main, "store.main test invariant").modelOverrideRouteResolution,
).toBe("resolved");
expect(expectDefined(store.main, "store.main test invariant").authProfileOverride).toBe(
"openai-codex:default",
);
@@ -4422,6 +4425,7 @@ describe("collectCodexRouteWarnings", () => {
expect(entry.providerOverride).toBe("openai");
expect(entry.modelOverride).toBe("gpt-5.5");
expect(entry.modelOverrideSource).toBe("auto");
expect(entry.modelOverrideRouteResolution).toBe("resolved");
expect(entry.authProfileOverride).toBe("openai:work");
expect(entry.authProfileOverrideSource).toBe("auto");
expect(entry.agentHarnessId).toBeUndefined();
@@ -4472,6 +4476,9 @@ describe("collectCodexRouteWarnings", () => {
expect(expectDefined(store.main, "store.main test invariant").providerOverride).toBe("openai");
expect(expectDefined(store.main, "store.main test invariant").modelOverride).toBe("gpt-5.5");
expect(expectDefined(store.main, "store.main test invariant").modelOverrideSource).toBe("auto");
expect(
expectDefined(store.main, "store.main test invariant").modelOverrideRouteResolution,
).toBe("resolved");
expect(expectDefined(store.main, "store.main test invariant").authProfileOverride).toBe(
"openai-codex:default",
);
@@ -25,6 +25,25 @@ export function hasSessionAutoModelFallbackProvenance(
);
}
/** Resolves persisted route provenance, including fallback pins from before the marker existed. */
export function resolveSessionModelOverrideRouteResolution(
entry:
| Pick<
SessionEntry,
| "providerOverride"
| "modelOverride"
| "modelOverrideRouteResolution"
| "modelOverrideFallbackOriginProvider"
| "modelOverrideFallbackOriginModel"
>
| undefined,
): "raw" | "resolved" {
return (
entry?.modelOverrideRouteResolution ??
(hasSessionAutoModelFallbackProvenance(entry) ? "resolved" : "raw")
);
}
/** Detects an active automatic fallback rather than a self-origin configured selection. */
export function hasSessionActiveAutoModelFallback(
entry:
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveResetPreservedSelection } from "./reset-preserved-selection.js";
describe("resolveResetPreservedSelection", () => {
it("does not stamp legacy raw aliases as resolved during reset", () => {
expect(
resolveResetPreservedSelection({
entry: {
sessionId: "legacy",
updatedAt: 1,
providerOverride: "anthropic",
modelOverride: "sonnet",
},
}),
).toEqual({
providerOverride: "anthropic",
modelOverride: "sonnet",
modelOverrideSource: "user",
});
});
it("preserves canonical route provenance", () => {
expect(
resolveResetPreservedSelection({
entry: {
sessionId: "canonical",
updatedAt: 1,
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
},
}),
).toMatchObject({
modelOverride: "claude-sonnet-4-6",
modelOverrideRouteResolution: "resolved",
});
});
});
@@ -7,6 +7,7 @@ type ResetPreservedSelectionState = Pick<
| "providerOverride"
| "modelOverride"
| "modelOverrideSource"
| "modelOverrideRouteResolution"
| "authProfileOverride"
| "authProfileOverrideSource"
| "authProfileOverrideCompactionCount"
@@ -45,6 +46,9 @@ export function resolveResetPreservedSelection(params: {
preserved.providerOverride = entry.providerOverride;
preserved.modelOverride = entry.modelOverride;
preserved.modelOverrideSource = "user";
if (entry.modelOverrideRouteResolution) {
preserved.modelOverrideRouteResolution = entry.modelOverrideRouteResolution;
}
}
if (entry.authProfileOverrideSource === "user" && entry.authProfileOverride) {
@@ -23,6 +23,9 @@ export function inheritSessionSelection(
...(parentEntry.modelOverrideSource
? { modelOverrideSource: parentEntry.modelOverrideSource }
: {}),
...(parentEntry.modelOverrideRouteResolution
? { modelOverrideRouteResolution: parentEntry.modelOverrideRouteResolution }
: {}),
...(parentEntry.agentRuntimeOverride
? { agentRuntimeOverride: parentEntry.agentRuntimeOverride }
: {}),
@@ -4,6 +4,7 @@ export type AgentPatchedSessionModelFallback = {
prevModelOverride?: string;
prevProviderOverride?: string;
prevModelOverrideSource?: "auto" | "user";
prevModelOverrideRouteResolution?: "resolved";
prevModelOverrideFallbackOriginProvider?: string;
prevModelOverrideFallbackOriginModel?: string;
prevAuthProfileOverride?: string;
@@ -22,6 +23,7 @@ export function createAgentPatchedSessionModelFallback(params: {
modelOverride?: string;
providerOverride?: string;
modelOverrideSource?: "auto" | "user";
modelOverrideRouteResolution?: "resolved";
modelOverrideFallbackOriginProvider?: string;
modelOverrideFallbackOriginModel?: string;
authProfileOverride?: string;
@@ -38,6 +40,9 @@ export function createAgentPatchedSessionModelFallback(params: {
...(entry.modelOverride ? { prevModelOverride: entry.modelOverride } : {}),
...(entry.providerOverride ? { prevProviderOverride: entry.providerOverride } : {}),
...(entry.modelOverrideSource ? { prevModelOverrideSource: entry.modelOverrideSource } : {}),
...(entry.modelOverrideRouteResolution
? { prevModelOverrideRouteResolution: entry.modelOverrideRouteResolution }
: {}),
...(entry.modelOverrideFallbackOriginProvider
? { prevModelOverrideFallbackOriginProvider: entry.modelOverrideFallbackOriginProvider }
: {}),
@@ -8,6 +8,7 @@ export const SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS = [
"modelOverride",
"agentRuntimeOverride",
"modelOverrideSource",
"modelOverrideRouteResolution",
"modelOverrideFallbackOriginProvider",
"modelOverrideFallbackOriginModel",
"authProfileOverride",
+2
View File
@@ -447,6 +447,8 @@ export type SessionEntry = SessionRestartRecoveryState &
* Resets only preserve user-driven overrides.
*/
modelOverrideSource?: "auto" | "user";
/** Present only when providerOverride/modelOverride are a canonical route pair. */
modelOverrideRouteResolution?: "resolved";
/** Selected model that produced the current auto fallback override. */
modelOverrideFallbackOriginProvider?: string;
modelOverrideFallbackOriginModel?: string;
+1
View File
@@ -333,6 +333,7 @@ function createCronPromptExecutor(params: {
cfg: params.cfgWithAgentDefaults,
provider: params.liveSelection.provider,
model: params.liveSelection.model,
requestedRouteResolution: "resolved",
runId: params.cronSession.sessionEntry.sessionId,
sessionId: params.cronSession.sessionEntry.sessionId,
lane: resolveCronAgentLane(params.lane),
@@ -401,9 +401,24 @@ describe("resolveCronFallbacksOverride", () => {
}),
}),
).toEqual([
{ provider: "ollama", model: "qwen3:32b" },
{ provider: "openrouter", model: "nvidia/nemotron-3-super-120b-a12b:free" },
{ provider: "openai", model: "gpt-5.4" },
{
provider: "ollama",
model: "qwen3:32b",
routeOrigin: "requested",
routeResolution: "resolved",
},
{
provider: "openrouter",
model: "nvidia/nemotron-3-super-120b-a12b:free",
routeOrigin: "configured-fallback",
routeResolution: "resolved",
},
{
provider: "openai",
model: "gpt-5.4",
routeOrigin: "configured-fallback",
routeResolution: "resolved",
},
]);
});
@@ -420,7 +435,14 @@ describe("resolveCronFallbacksOverride", () => {
fallbacks: [],
}),
}),
).toStrictEqual([{ provider: "ollama", model: "qwen3:32b" }]);
).toStrictEqual([
{
provider: "ollama",
model: "qwen3:32b",
routeOrigin: "requested",
routeResolution: "resolved",
},
]);
});
it("documents that cron preflight walks fallbacks before skipping", () => {
@@ -70,6 +70,7 @@ export function resolveCronPreflightCandidates(params: {
cfg: params.cfg,
provider: params.provider,
model: params.model,
requestedRouteResolution: "resolved",
fallbacksOverride,
});
}
+3
View File
@@ -83,6 +83,9 @@ function preserveNonAutoModelOverride(target: SessionEntry, entry: SessionEntry)
if (entry.modelOverrideSource !== undefined) {
target.modelOverrideSource = entry.modelOverrideSource;
}
if (entry.modelOverrideRouteResolution !== undefined) {
target.modelOverrideRouteResolution = entry.modelOverrideRouteResolution;
}
// Runtime overrides qualify an explicit model selection; carrying one alone
// would pin a fresh cron session to a stale engine after its model resets.
if (preservedModelSelection && entry.agentRuntimeOverride !== undefined) {
+5
View File
@@ -250,6 +250,7 @@ type TrustedInitialSessionEntry = {
pluginOwnerId?: string;
providerOverride?: string;
modelOverride?: string;
modelOverrideRouteResolution?: "resolved";
cliSessionBindings?: SessionEntry["cliSessionBindings"];
initializationPending?: true;
modelSelectionLocked?: true;
@@ -926,6 +927,7 @@ export async function createGatewaySession(params: {
providerOverride: catalogResolvedModel.provider,
modelOverride: catalogResolvedModel.model,
modelOverrideSource: "user" as const,
modelOverrideRouteResolution: "resolved" as const,
agentRuntimeOverride: catalogAgentRuntime,
modelSelectionLocked: true,
pluginOwnerId: catalogPluginOwnerId,
@@ -946,6 +948,9 @@ export async function createGatewaySession(params: {
...(authorizedPluginCreation && params.initialEntry?.modelOverride
? { modelOverride: params.initialEntry.modelOverride }
: {}),
...(authorizedPluginCreation && params.initialEntry?.modelOverrideRouteResolution
? { modelOverrideRouteResolution: params.initialEntry.modelOverrideRouteResolution }
: {}),
// Seeded CLI bindings ride only the plugin-authorized creation path;
// harness creations must never smuggle pre-bound CLI session ids.
...(authorizedPluginCreation && params.initialEntry?.cliSessionBindings
@@ -152,6 +152,7 @@ describe("plugin runtime session creation", () => {
pluginOwnerId: "anthropic",
providerOverride: "claude-cli",
modelOverride: "claude-opus-4-8",
modelOverrideRouteResolution: "resolved",
modelSelectionLocked: true,
execHost: "node",
execNode: "node-a",
+1
View File
@@ -371,6 +371,7 @@ async function createSessionEntry(
pluginOwnerId: cliInitial.pluginOwnerId,
providerOverride: cliInitial.cliBackendId,
modelOverride: cliInitial.model,
modelOverrideRouteResolution: "resolved",
cliSessionBindings: {
[cliInitial.cliBackendId]: cliInitial.cliSessionBinding,
},
+1
View File
@@ -91,6 +91,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"modelOverride",
"agentRuntimeOverride",
"modelOverrideSource",
"modelOverrideRouteResolution",
"modelOverrideFallbackOriginProvider",
"modelOverrideFallbackOriginModel",
"modelFallback",
+4
View File
@@ -117,6 +117,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(entry.fallbackNoticeActiveModel).toBeUndefined();
expect(entry.fallbackNoticeReason).toBeUndefined();
expect(entry.modelOverrideSource).toBe("user");
expect(entry.modelOverrideRouteResolution).toBe("resolved");
});
it("clears stale runtime model fields even when override selection is unchanged", () => {
@@ -187,6 +188,7 @@ describe("applyModelOverrideToSessionEntry", () => {
updatedAt: before,
providerOverride: "local",
modelOverride: "sunapi386/llama-3-lexi-uncensored:8b",
modelOverrideRouteResolution: "resolved",
contextTokens: 4_096,
contextBudgetStatus: contextBudgetStatus({
updatedAt: before,
@@ -209,6 +211,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(entry.providerOverride).toBeUndefined();
expect(entry.modelOverride).toBeUndefined();
expect(entry.modelOverrideSource).toBeUndefined();
expect(entry.modelOverrideRouteResolution).toBeUndefined();
expect(entry.contextTokens).toBeUndefined();
expect(entry.contextBudgetStatus).toBeUndefined();
expect((entry.updatedAt ?? 0) > before).toBe(true);
@@ -266,6 +269,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(entry.providerOverride).toBe("anthropic");
expect(entry.modelOverride).toBe("claude-sonnet-4-6");
expect(entry.modelOverrideSource).toBe("auto");
expect(entry.modelOverrideRouteResolution).toBe("resolved");
});
it("sets liveModelSwitchPending only when explicitly requested", () => {
+8
View File
@@ -78,6 +78,10 @@ export function applyModelOverrideToSessionEntry(params: {
delete entry.modelOverrideSource;
updated = true;
}
if (entry.modelOverrideRouteResolution) {
delete entry.modelOverrideRouteResolution;
updated = true;
}
updated = clearFallbackOrigin(entry) || updated;
} else {
if (entry.providerOverride !== selection.provider) {
@@ -94,6 +98,10 @@ export function applyModelOverrideToSessionEntry(params: {
entry.modelOverrideSource = selectionSource;
updated = true;
}
if (entry.modelOverrideRouteResolution !== "resolved") {
entry.modelOverrideRouteResolution = "resolved";
updated = true;
}
updated = clearFallbackOrigin(entry) || updated;
}