fix: align native controls with scoped model params

This commit is contained in:
Dallin Romney
2026-08-13 17:19:58 +08:00
parent af3c27e098
commit e69d2dfe51
7 changed files with 175 additions and 33 deletions
+4 -4
View File
@@ -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 } },
},
},
},
},
+9 -5
View File
@@ -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<string, unknown> | 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);
+37
View File
@@ -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)");
+50 -7
View File
@@ -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<Record<string, unknown> | 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<Record<string, unknown> | 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<Record<string, unknown> | 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<SessionEntry, "fastMode"> | 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 {
+25 -3
View File
@@ -8,6 +8,8 @@ type ModelExtraParamSources = {
defaultParams?: Record<string, unknown>;
modelParams?: Record<string, unknown>;
agentParams?: Record<string, unknown>;
agentEntryParams?: Record<string, unknown>;
agentModelParams?: Record<string, unknown>;
};
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<typeof resolveModelExtraParamSources>[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. */
@@ -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 = {
+14 -14
View File
@@ -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 ||