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