From e69d2dfe519fd1ecf50a009727e8a46b12e1b27b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 13 Aug 2026 17:19:58 +0800 Subject: [PATCH] fix: align native controls with scoped model params --- extensions/openrouter/index.test.ts | 8 +-- extensions/openrouter/provider-routing.ts | 14 +++-- src/agents/fast-mode.test.ts | 37 +++++++++++++ src/agents/fast-mode.ts | 57 +++++++++++++++++--- src/agents/model-extra-params.ts | 28 ++++++++-- src/auto-reply/reply/model-selection.test.ts | 36 +++++++++++++ src/auto-reply/reply/model-selection.ts | 28 +++++----- 7 files changed, 175 insertions(+), 33 deletions(-) diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index a1723cd130db..5cf4e287248b 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -1091,18 +1091,18 @@ describe("openrouter provider hooks", () => { }, ); - it("prefers the exact OpenRouter provider key over a differently cased key", async () => { + it("prefers the exact OpenRouter provider key regardless of config key order", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); const patch = provider.extraParamsForTransport?.({ config: { models: { providers: { - OpenRouter: { - params: { provider: { order: ["anthropic"], allow_fallbacks: false } }, - }, openrouter: { params: { provider: { order: ["openai"], allow_fallbacks: false } }, }, + OpenRouter: { + params: { provider: { order: ["anthropic"], allow_fallbacks: false } }, + }, }, }, }, diff --git a/extensions/openrouter/provider-routing.ts b/extensions/openrouter/provider-routing.ts index 708b285cc136..39dc9ac952b4 100644 --- a/extensions/openrouter/provider-routing.ts +++ b/extensions/openrouter/provider-routing.ts @@ -76,12 +76,16 @@ function resolveOpenRouterProviderConfigParams( const providers = Object.entries(ctx.config?.models?.providers ?? {}); // Preserve routing split across normalized duplicates; merge nested params - // field-wise while allowing later scalar settings to override earlier ones. + // field-wise, then apply exact-key rows so aliases cannot override them. + const matchedProviders = providers.filter( + ([provider]) => normalizeProviderId(provider) === normalizedProvider, + ); + const prioritizedProviders = [ + ...matchedProviders.filter(([provider]) => provider.trim() !== requestedProvider), + ...matchedProviders.filter(([provider]) => provider.trim() === requestedProvider), + ]; let matchedParams: Record | undefined; - for (const [provider, config] of providers) { - if (normalizeProviderId(provider) !== normalizedProvider) { - continue; - } + for (const [, config] of prioritizedProviders) { const params = readRecord(config.params); if (params) { matchedParams = mergeOpenRouterProviderConfigParams(matchedParams, params); diff --git a/src/agents/fast-mode.test.ts b/src/agents/fast-mode.test.ts index 1328e5887654..3fb7d71ffe84 100644 --- a/src/agents/fast-mode.test.ts +++ b/src/agents/fast-mode.test.ts @@ -119,6 +119,43 @@ describe("resolveFastModeState", () => { expect(state.source).toBe("config"); }); + it("uses per-agent model fast params at the narrowest config precedence", () => { + const cfg = { + agents: { + defaults: { + params: { fast_mode: false, fast_seconds: 90 }, + models: { + "openai/gpt-5.5": { params: { fastMode: true, fastAutoOnSeconds: 60 } }, + }, + }, + entries: { + audit: { + params: { fastMode: false, fastAutoOnSeconds: 30 }, + models: { + "openai/gpt-5.5": { + params: { fast_mode: "auto", fast_seconds: 15 }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + const state = resolveFastModeState({ + cfg, + provider: "openai", + model: "gpt-5.5", + agentId: "audit", + }); + + expect(state).toMatchObject({ + mode: "auto", + enabled: true, + source: "config", + fastAutoOnSeconds: 15, + }); + }); + it("formats auto mode with the default threshold", () => { expect(formatFastModeAutoLabel()).toBe("auto (60 sec)"); expect(formatFastModeStatusValue({ mode: "auto" })).toBe("auto (60 sec)"); diff --git a/src/agents/fast-mode.ts b/src/agents/fast-mode.ts index fc3e6740a858..1fcefa510a76 100644 --- a/src/agents/fast-mode.ts +++ b/src/agents/fast-mode.ts @@ -6,11 +6,12 @@ import { normalizeFastMode } from "../auto-reply/thinking.shared.js"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + DEFAULT_FAST_MODE_AUTO_ON_SECONDS, type FastModeSource, - resolveFastModeModelAutoOnSeconds, resolveFastModeModelParams, } from "../shared/fast-mode.js"; import { resolveAgentConfig } from "./agent-scope.js"; +import { resolveModelExtraParamSources } from "./model-extra-params.js"; export { DEFAULT_FAST_MODE_AUTO_ON_SECONDS, @@ -33,13 +34,51 @@ type FastModeState = { fastAutoOnSeconds: number; }; -function resolveConfiguredFastModeRaw(params: { +function resolveConfiguredFastModeParamSources(params: { cfg: OpenClawConfig | undefined; provider: string; model: string; -}): unknown { - const modelParams = resolveFastModeModelParams(params); - return modelParams?.fastMode ?? modelParams?.fast_mode; + agentId?: string; +}): Array | undefined> { + const sources = resolveModelExtraParamSources({ + config: params.cfg, + provider: params.provider, + modelId: params.model, + agentId: params.agentId, + }); + return [ + sources.agentModelParams, + sources.agentEntryParams, + resolveFastModeModelParams(params), + sources.defaultParams, + ]; +} + +function resolveConfiguredFastModeValue( + sources: Array | undefined>, + keys: readonly string[], + accepts?: (value: unknown) => boolean, +): unknown { + for (const source of sources) { + for (const key of keys) { + const value = source?.[key]; + if (source && Object.hasOwn(source, key) && (!accepts || accepts(value))) { + return value; + } + } + } + return undefined; +} + +function resolveConfiguredFastModeAutoOnSeconds( + sources: Array | undefined>, +): number { + const value = resolveConfiguredFastModeValue( + sources, + ["fastAutoOnSeconds", "fast_auto_on_seconds", "fastSeconds", "fast_seconds"], + (candidate) => typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0, + ); + return typeof value === "number" ? value : DEFAULT_FAST_MODE_AUTO_ON_SECONDS; } /** Resolve the effective fast-mode setting and its source. */ @@ -50,7 +89,8 @@ export function resolveFastModeState(params: { agentId?: string; sessionEntry?: Pick | undefined; }): FastModeState { - const fastAutoOnSeconds = resolveFastModeModelAutoOnSeconds(params); + const configuredParamSources = resolveConfiguredFastModeParamSources(params); + const fastAutoOnSeconds = resolveConfiguredFastModeAutoOnSeconds(configuredParamSources); const sessionOverride = normalizeFastMode(params.sessionEntry?.fastMode); if (sessionOverride !== undefined) { return { @@ -75,7 +115,10 @@ export function resolveFastModeState(params: { }; } - const configuredRaw = resolveConfiguredFastModeRaw(params); + const configuredRaw = resolveConfiguredFastModeValue(configuredParamSources, [ + "fastMode", + "fast_mode", + ]); const configured = normalizeFastMode(configuredRaw as string | boolean | null | undefined); if (configured !== undefined) { return { diff --git a/src/agents/model-extra-params.ts b/src/agents/model-extra-params.ts index b5773e4e0bd4..c89c4c3b4df6 100644 --- a/src/agents/model-extra-params.ts +++ b/src/agents/model-extra-params.ts @@ -8,6 +8,8 @@ type ModelExtraParamSources = { defaultParams?: Record; modelParams?: Record; agentParams?: Record; + agentEntryParams?: Record; + agentModelParams?: Record; }; const FAST_MODE_CUTOFF_MODEL_PARAM_KEYS = new Set([ @@ -62,6 +64,7 @@ export function resolveModelExtraParamSources(params: { : undefined; const agentConfig = params.agentId && params.config ? resolveAgentConfig(params.config, params.agentId) : undefined; + const agentEntryParams = agentConfig?.params; const agentModelParams = canonicalKey ? (agentConfig?.models?.[canonicalKey]?.params ?? (legacyKey ? agentConfig?.models?.[legacyKey]?.params : undefined)) @@ -69,9 +72,28 @@ export function resolveModelExtraParamSources(params: { // Model-specific agent settings are narrower than agent-wide settings and // must stay in the same precedence source for transport alias normalization. const agentParams = agentModelParams - ? { ...agentConfig?.params, ...agentModelParams } - : agentConfig?.params; - return { defaultParams, modelParams, agentParams }; + ? { ...agentEntryParams, ...agentModelParams } + : agentEntryParams; + return { defaultParams, modelParams, agentParams, agentEntryParams, agentModelParams }; +} + +/** Resolves one authored parameter across the canonical config precedence. */ +export function resolveModelExtraParamValue( + params: Parameters[0], + key: string, +): unknown { + const sources = resolveModelExtraParamSources(params); + for (const source of [ + sources.agentModelParams, + sources.agentEntryParams, + sources.modelParams, + sources.defaultParams, + ]) { + if (source && Object.hasOwn(source, key)) { + return source[key]; + } + } + return undefined; } /** Returns whether embedded OpenClaw would apply authored provider request parameters. */ diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 81336a13f035..4d48d55f6d28 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -621,6 +621,42 @@ describe("createModelSelectionState catalog loading", () => { await expect(state.resolveDefaultThinkingLevel()).resolves.toBe("minimal"); }); + it("uses per-agent model thinking at the narrowest config precedence", async () => { + vi.mocked(loadModelCatalogLocal).mockClear(); + const cfg = { + agents: { + defaults: { + params: { thinking: "low" }, + models: { + "openai/gpt-5.4": { params: { thinking: "medium" } }, + }, + }, + entries: { + audit: { + params: { thinking: "high" }, + models: { + "openai/gpt-5.4": { params: { thinking: "xhigh" } }, + }, + }, + }, + }, + } as OpenClawConfig; + + const state = await createModelSelectionState({ + cfg, + agentId: "audit", + agentCfg: cfg.agents?.defaults, + defaultProvider: "openai", + defaultModel: "gpt-5.4", + provider: "openai", + model: "gpt-5.4", + hasModelDirective: false, + }); + + await expect(state.resolveDefaultThinkingLevel()).resolves.toBe("xhigh"); + expect(loadModelCatalogLocal).not.toHaveBeenCalled(); + }); + it("loads the full catalog for explicit model directives", async () => { vi.mocked(loadModelCatalogLocal).mockClear(); const cfg = { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index f1011dfdb456..88dea084a2be 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -8,11 +8,11 @@ import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/sess import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { ModelCatalogSnapshot } from "../../agents/model-catalog.types.js"; +import { resolveModelExtraParamValue } from "../../agents/model-extra-params.js"; import type { ModelFallbackRouteResolution } from "../../agents/model-fallback.types.js"; import { type ModelAliasIndex, buildConfiguredModelCatalog, - legacyModelKey, modelKey, normalizeProviderId, resolveModelAliasFromPair, @@ -629,14 +629,16 @@ export async function createModelSelectionState(params: { defaultThinkingLevels.set(cacheKey, agentThinkingDefault); return agentThinkingDefault; } - const configuredModels = cfg.agents?.defaults?.models; - const canonicalKey = modelKey(selectedProvider, selectedModel); - const legacyKey = legacyModelKey(selectedProvider, selectedModel); - const configuredModelThinkingDefault = - configuredModels?.[canonicalKey]?.params?.thinking ?? - (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); const resolvedConfiguredModelThinkingDefault = resolveConfiguredModelThinkingDefault( - configuredModelThinkingDefault, + resolveModelExtraParamValue( + { + config: cfg, + provider: selectedProvider, + modelId: selectedModel, + agentId: params.agentId, + }, + "thinking", + ), ); if (resolvedConfiguredModelThinkingDefault) { defaultThinkingLevels.set(cacheKey, resolvedConfiguredModelThinkingDefault); @@ -707,12 +709,10 @@ export async function createModelSelectionState(params: { provider, model, }); - const configuredModels = cfg.agents?.defaults?.models; - const canonicalKey = modelKey(provider, model); - const legacyKey = legacyModelKey(provider, model); - const configuredModelThinkingDefault = - configuredModels?.[canonicalKey]?.params?.thinking ?? - (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); + const configuredModelThinkingDefault = resolveModelExtraParamValue( + { config: cfg, provider, modelId: model, agentId: params.agentId }, + "thinking", + ); const hasConfiguredThinkingDefault = agentEntry?.thinkingDefault !== undefined || resolveConfiguredModelThinkingDefault(configuredModelThinkingDefault) !== undefined ||