diff --git a/src/agents/agent-command.live-model-switch.test-helpers.ts b/src/agents/agent-command.live-model-switch.test-helpers.ts index 73dbbf900a76..117491749e13 100644 --- a/src/agents/agent-command.live-model-switch.test-helpers.ts +++ b/src/agents/agent-command.live-model-switch.test-helpers.ts @@ -237,6 +237,33 @@ export function resolveTestModelRefFromString({ }; } +export function resolveTestModelAliasFromPair(params: { + provider: string; + model: string; + defaultProvider: string; + aliasIndex?: ReturnType; +}) { + 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; diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index c7d8292ad3f1..6bf30767e935 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -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?.({ diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index b47e5ec0c7ec..b682f2c53a9f 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -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 ( diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index 31137386f4a6..d80dd742a006 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -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, diff --git a/src/agents/command/run-embedded-attempt.ts b/src/agents/command/run-embedded-attempt.ts index c726a39d7eb9..bfca816bdc27 100644 --- a/src/agents/command/run-embedded-attempt.ts +++ b/src/agents/command/run-embedded-attempt.ts @@ -236,6 +236,7 @@ export async function runEmbeddedAgentAttempt(params: { cfg, provider, model, + requestedRouteResolution: params.modelSelection.requestedRouteResolution, agentDir, fallbacksOverride: effectiveFallbacksOverride, ...modelManifestContext, diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 7885f069a646..4c438f0c4f26 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run-entry.ts b/src/agents/embedded-agent-runner/run-entry.ts index 7436b7cdbf30..6db11d6c6851 100644 --- a/src/agents/embedded-agent-runner/run-entry.ts +++ b/src/agents/embedded-agent-runner/run-entry.ts @@ -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 = { cfg: OpenClawConfig; provider: string; model: string; + requestedRouteResolution?: ModelFallbackRouteResolution; fallbacksOverride?: string[]; agentDir?: string; } & ModelManifestNormalizationContext; diff --git a/src/agents/model-fallback-candidates.ts b/src/agents/model-fallback-candidates.ts index c735b114bb64..ef1a739f611f 100644 --- a/src/agents/model-fallback-candidates.ts +++ b/src/agents/model-fallback-candidates.ts @@ -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(); +const fallbackCandidateCache = new Map(); 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(); - 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; } diff --git a/src/agents/model-fallback-observation.test.ts b/src/agents/model-fallback-observation.test.ts index 71829a4173aa..55cf1d11a635 100644 --- a/src/agents/model-fallback-observation.test.ts +++ b/src/agents/model-fallback-observation.test.ts @@ -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` })); diff --git a/src/agents/model-fallback-observation.ts b/src/agents/model-fallback-observation.ts index b46ee08ba738..53e5fa7b873f 100644 --- a/src/agents/model-fallback-observation.ts +++ b/src/agents/model-fallback-observation.ts @@ -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(); 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, diff --git a/src/agents/model-fallback-runner.ts b/src/agents/model-fallback-runner.ts index d0a96854c1e0..f7925d410639 100644 --- a/src/agents/model-fallback-runner.ts +++ b/src/agents/model-fallback-runner.ts @@ -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 = { agentDir?: string; /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ fallbacksOverride?: string[]; + requestedRouteResolution?: ModelFallbackRouteResolution; run: ModelFallbackRunFn; onError?: ModelFallbackErrorHandler; onFallbackStep?: ModelFallbackStepHandler; @@ -157,6 +158,7 @@ async function runWithModelFallbackInternal( provider: params.provider, model: params.model, fallbacksOverride: params.fallbacksOverride, + requestedRouteResolution: params.requestedRouteResolution, manifestPlugins: params.manifestPlugins, }); const authRuntime = @@ -219,7 +221,7 @@ async function runWithModelFallbackInternal( }; 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( prepareAgentHarnessRuntime: params.prepareAgentHarnessRuntime, ...candidate, }); - const isPrimary = i === 0; + const isPrimary = candidate.routeOrigin === "requested"; const requestedModel = requestedCandidate ? sameModelCandidate(candidate, requestedCandidate) : false; diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 7f1853dffc1d..16fbeac9fa7f 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -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[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: { diff --git a/src/agents/model-fallback.types.ts b/src/agents/model-fallback.types.ts index 668f78110fd5..58868ca7d921 100644 --- a/src/agents/model-fallback.types.ts +++ b/src/agents/model-fallback.types.ts @@ -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; diff --git a/src/agents/model-selection-resolve.ts b/src/agents/model-selection-resolve.ts index 1c6d3e8152a9..6673376e1bf8 100644 --- a/src/agents/model-selection-resolve.ts +++ b/src/agents/model-selection-resolve.ts @@ -21,6 +21,7 @@ export { normalizeModelSelection, resolveConfiguredModelRef, resolveHooksGmailModel, + resolveModelAliasFromPair, resolveModelRefFromString, } from "./model-selection-shared.js"; diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 4988871c2bc4..13cbe1f4fca1 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -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; policyAliasIndex: ModelAliasIndex; + selectionAliasIndex: ModelAliasIndex; configuredKeys: ReadonlySet; retainedKeys: ReadonlySet; exactModelRefs: readonly string[]; @@ -1797,6 +1830,7 @@ export function createModelVisibilityPolicyWithFallbacks( allowedCatalog: allowed.allowedCatalog, allowedKeys: allowed.allowedKeys, policyAliasIndex, + selectionAliasIndex, configuredKeys, retainedKeys, exactModelRefs: visibility.exactModelRefs, diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 5428d8c2fd65..c5f3796f2235 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -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"; diff --git a/src/agents/session-model-auto-revert.ts b/src/agents/session-model-auto-revert.ts index 6229bb12864f..0602ebf57526 100644 --- a/src/agents/session-model-auto-revert.ts +++ b/src/agents/session-model-auto-revert.ts @@ -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, diff --git a/src/agents/subagent-spawn-session-patch.ts b/src/agents/subagent-spawn-session-patch.ts index 8cb7e700cc9b..10c02ceab09b 100644 --- a/src/agents/subagent-spawn-session-patch.ts +++ b/src/agents/subagent-spawn-session-patch.ts @@ -78,6 +78,7 @@ function buildDirectChildSessionPatch(patch: Record): Partial { ).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", diff --git a/src/auto-reply/reply/agent-runner-fallback-candidate.ts b/src/auto-reply/reply/agent-runner-fallback-candidate.ts index 9f1bf9fa47d9..07a0a8b33763 100644 --- a/src/auto-reply/reply/agent-runner-fallback-candidate.ts +++ b/src/auto-reply/reply/agent-runner-fallback-candidate.ts @@ -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, }, diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index e5d8961721ef..2e731932f20b 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -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([]); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 4cb9b943d42e..51692052ae92 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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, }, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 1100c33d8f1b..f7464ece52eb 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 635aeb9119de..77b86dd5920f 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -45,6 +45,7 @@ function makeRun(overrides: Partial = {}): 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, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index a8c9516f6ade..f2150ee19307 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -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, diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 3b35078488cd..8801dd69ada0 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -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", diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 6fb4cb2ee38c..9d4f9d0b576b 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -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, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 55ace0109be5..37f0d66ac06a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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, }, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index d604651cbe27..40d95a5a2a68 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -134,6 +134,9 @@ type ReplyDirectiveContinuation = { resolvedBlockStreamingBreak: "text_end" | "message_end"; provider: string; model: string; + requestedRouteResolution: Awaited< + ReturnType + >["requestedRouteResolution"]; modelState: Awaited>; contextTokens: number; inlineStatusRequested: boolean; @@ -712,6 +715,9 @@ export async function resolveReplyDirectives(params: { resolvedBlockStreamingBreak, provider, model, + requestedRouteResolution: effectiveModelDirective + ? "resolved" + : modelState.requestedRouteResolution, modelState, contextTokens, inlineStatusRequested, diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index ad24c322b2b5..23f717ab0254 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-run.types.ts b/src/auto-reply/reply/get-reply-run.types.ts index 76fa351e4467..cccf319f3d88 100644 --- a/src/auto-reply/reply/get-reply-run.types.ts +++ b/src/auto-reply/reply/get-reply-run.types.ts @@ -76,6 +76,9 @@ export type RunPreparedReplyParams = { modelState: Awaited>; provider: string; model: string; + requestedRouteResolution?: Awaited< + ReturnType + >["requestedRouteResolution"]; perMessageQueueMode?: InlineDirectives["queueMode"]; perMessageQueueOptions?: { debounceMs?: number; diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index ce2e4d9f14fd..02f1d318c2d3 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -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, diff --git a/src/auto-reply/reply/model-selection-context.ts b/src/auto-reply/reply/model-selection-context.ts new file mode 100644 index 000000000000..3624f40bdba6 --- /dev/null +++ b/src/auto-reply/reply/model-selection-context.ts @@ -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["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; +} diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 64b852be0895..07978e656509 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -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. diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 11d96578eded..97fd6a584697 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -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; allowedModelCatalog: ModelCatalog; policyAliasIndex: ModelAliasIndex; @@ -98,6 +99,7 @@ export function createFastTestModelSelectionState(params: { return { provider: params.provider, model: params.model, + requestedRouteResolution: "resolved", allowedModelKeys: new Set(), 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["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; -} diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index 43be7fbb8b01..efcf12f38100 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -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); diff --git a/src/auto-reply/reply/queue/state.test.ts b/src/auto-reply/reply/queue/state.test.ts index c53f2b2c9cc2..5d93dd1359cc 100644 --- a/src/auto-reply/reply/queue/state.test.ts +++ b/src/auto-reply/reply/queue/state.test.ts @@ -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" }, }); diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts index e4ebff9d941f..14c521478f00 100644 --- a/src/auto-reply/reply/queue/state.ts +++ b/src/auto-reply/reply/queue/state.ts @@ -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; } diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 93ffce432b34..84a882585a2b 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -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; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 9bc1c5949b7e..8e4fe7de26c4 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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 { + return { + modelOverride: entry.modelOverride, + providerOverride: entry.providerOverride, + modelOverrideSource: entry.modelOverrideSource, + modelOverrideRouteResolution: entry.modelOverrideRouteResolution, + }; +} + export async function initSessionState(params: InitSessionStateParams): Promise { 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, diff --git a/src/auto-reply/reply/stored-model-override.test.ts b/src/auto-reply/reply/stored-model-override.test.ts index 1d4397f9c1b7..b43ca97de053 100644 --- a/src/auto-reply/reply/stored-model-override.test.ts +++ b/src/auto-reply/reply/stored-model-override.test.ts @@ -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"); }); diff --git a/src/auto-reply/reply/stored-model-override.ts b/src/auto-reply/reply/stored-model-override.ts index 6d6acdf36586..7683131e74dc 100644 --- a/src/auto-reply/reply/stored-model-override.ts +++ b/src/auto-reply/reply/stored-model-override.ts @@ -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: { diff --git a/src/commands/doctor-session-state-providers.test.ts b/src/commands/doctor-session-state-providers.test.ts index 11a46784d170..74e3737a8d3d 100644 --- a/src/commands/doctor-session-state-providers.test.ts +++ b/src/commands/doctor-session-state-providers.test.ts @@ -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(); diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index f33aacefd5d1..fbca3e77e0b4 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -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")) { diff --git a/src/commands/doctor/shared/codex-route-session-repair.ts b/src/commands/doctor/shared/codex-route-session-repair.ts index 67593a05db45..a55e156978f4 100644 --- a/src/commands/doctor/shared/codex-route-session-repair.ts +++ b/src/commands/doctor/shared/codex-route-session-repair.ts @@ -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, diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 72694ecf6919..8e99766a7455 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -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", ); diff --git a/src/config/sessions/model-override-provenance.ts b/src/config/sessions/model-override-provenance.ts index 5cfa49aa7459..4330b767ce2a 100644 --- a/src/config/sessions/model-override-provenance.ts +++ b/src/config/sessions/model-override-provenance.ts @@ -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: diff --git a/src/config/sessions/reset-preserved-selection.test.ts b/src/config/sessions/reset-preserved-selection.test.ts new file mode 100644 index 000000000000..f8a46fc1509b --- /dev/null +++ b/src/config/sessions/reset-preserved-selection.test.ts @@ -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", + }); + }); +}); diff --git a/src/config/sessions/reset-preserved-selection.ts b/src/config/sessions/reset-preserved-selection.ts index f4e7d7910a7f..08f95d0eccc3 100644 --- a/src/config/sessions/reset-preserved-selection.ts +++ b/src/config/sessions/reset-preserved-selection.ts @@ -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) { diff --git a/src/config/sessions/session-entry-selection.ts b/src/config/sessions/session-entry-selection.ts index de69df441026..2785030f5263 100644 --- a/src/config/sessions/session-entry-selection.ts +++ b/src/config/sessions/session-entry-selection.ts @@ -23,6 +23,9 @@ export function inheritSessionSelection( ...(parentEntry.modelOverrideSource ? { modelOverrideSource: parentEntry.modelOverrideSource } : {}), + ...(parentEntry.modelOverrideRouteResolution + ? { modelOverrideRouteResolution: parentEntry.modelOverrideRouteResolution } + : {}), ...(parentEntry.agentRuntimeOverride ? { agentRuntimeOverride: parentEntry.agentRuntimeOverride } : {}), diff --git a/src/config/sessions/session-model-fallback.ts b/src/config/sessions/session-model-fallback.ts index 977921e8b3df..869e3a770fa1 100644 --- a/src/config/sessions/session-model-fallback.ts +++ b/src/config/sessions/session-model-fallback.ts @@ -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 } : {}), diff --git a/src/config/sessions/session-snapshot-merge.ts b/src/config/sessions/session-snapshot-merge.ts index 3ae524f1ae5b..4241e0059c20 100644 --- a/src/config/sessions/session-snapshot-merge.ts +++ b/src/config/sessions/session-snapshot-merge.ts @@ -8,6 +8,7 @@ export const SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS = [ "modelOverride", "agentRuntimeOverride", "modelOverrideSource", + "modelOverrideRouteResolution", "modelOverrideFallbackOriginProvider", "modelOverrideFallbackOriginModel", "authProfileOverride", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 3f106ab44cef..6fd3391e08d1 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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; diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 12f7e98e2ce6..3865943d95e6 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -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), diff --git a/src/cron/isolated-agent/run-fallback-policy.test.ts b/src/cron/isolated-agent/run-fallback-policy.test.ts index 78f1d073ce68..67a8553b6577 100644 --- a/src/cron/isolated-agent/run-fallback-policy.test.ts +++ b/src/cron/isolated-agent/run-fallback-policy.test.ts @@ -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", () => { diff --git a/src/cron/isolated-agent/run-fallback-policy.ts b/src/cron/isolated-agent/run-fallback-policy.ts index 7069a7a499f3..eb7323e6dc57 100644 --- a/src/cron/isolated-agent/run-fallback-policy.ts +++ b/src/cron/isolated-agent/run-fallback-policy.ts @@ -70,6 +70,7 @@ export function resolveCronPreflightCandidates(params: { cfg: params.cfg, provider: params.provider, model: params.model, + requestedRouteResolution: "resolved", fallbacksOverride, }); } diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 266fca6c6b40..6fa7690569cb 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -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) { diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 184b4e80b936..cd6fe14cedbb 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -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 diff --git a/src/plugins/runtime/runtime-agent.test.ts b/src/plugins/runtime/runtime-agent.test.ts index 6c6273ac333d..3b001f2dc1e1 100644 --- a/src/plugins/runtime/runtime-agent.test.ts +++ b/src/plugins/runtime/runtime-agent.test.ts @@ -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", diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index 4f202fd01d68..fa95e2b84b06 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -371,6 +371,7 @@ async function createSessionEntry( pluginOwnerId: cliInitial.pluginOwnerId, providerOverride: cliInitial.cliBackendId, modelOverride: cliInitial.model, + modelOverrideRouteResolution: "resolved", cliSessionBindings: { [cliInitial.cliBackendId]: cliInitial.cliSessionBinding, }, diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index a352f1b4d2ba..b0fd9e3dfb06 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -91,6 +91,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "modelOverride", "agentRuntimeOverride", "modelOverrideSource", + "modelOverrideRouteResolution", "modelOverrideFallbackOriginProvider", "modelOverrideFallbackOriginModel", "modelFallback", diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index 3276334d72be..c4cfc36d9efe 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -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", () => { diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index 254cc4997536..d1ae0eee3296 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -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; }