mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
refactor: consolidate provider parameter precedence
This commit is contained in:
@@ -433,7 +433,7 @@ date context. Falls back to the host timezone.
|
||||
- For direct Anthropic models using API-key auth, set `params.anthropicServerCompaction: true` to enable server-side compaction. Use `params.anthropicCompactThreshold` to override the input-token trigger; the default is `max(50000, floor(contextWindow * 0.7))`, and lower configured values clamp to `50000`. OAuth/subscription and non-direct endpoints are excluded. See [Anthropic server-side compaction](/providers/anthropic#advanced-configuration).
|
||||
- For store-capable direct OpenAI Responses models, server-side compaction is enabled automatically and the same effective threshold delays local preflight compaction. Use `params.responsesServerCompaction: false` to stop injecting `context_management`, or `params.responsesCompactThreshold` to override the default of 70% of the resolved context window (80,000 when unavailable). ChatGPT OAuth, custom proxies, and routes with `compat.supportsStore: false` do not enable this path. See [OpenAI server-side compaction](/providers/openai#advanced-configuration).
|
||||
- `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`).
|
||||
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.entries.*.params` (matching agent id), and finally `agents.entries.*.models["provider/model"].params` (matching agent and model). Later scopes override earlier scopes by key. See [Prompt Caching](/reference/prompt-caching) for details.
|
||||
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.entries.*.params` (matching agent id), and finally `agents.entries.*.models["provider/model"].params` (matching agent and model). Later scopes override earlier scopes, including supported camelCase/snake_case alias spellings. See [Prompt Caching](/reference/prompt-caching) for details.
|
||||
- `models.providers.openrouter.params.provider`: OpenRouter-wide default provider-routing policy. OpenClaw forwards this to OpenRouter's request `provider` object; per-model `agents.defaults.models["openrouter/<model>"].params.provider` and agent params override by key. See [OpenRouter provider routing](/providers/openrouter#advanced-configuration).
|
||||
- `params.extra_body`/`params.extraBody`: advanced pass-through JSON merged into `api: "openai-completions"` request bodies for OpenAI-compatible proxies. If it collides with generated request keys, the extra body wins; non-native completions routes still strip OpenAI-only `store` afterward.
|
||||
- `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, the bundled vLLM plugin automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override generated defaults, and `extra_body.chat_template_kwargs` still has final precedence. Configured vLLM Qwen and Nemotron thinking models expose binary `/think` choices (`off`, `on`) instead of the multi-level effort ladder.
|
||||
|
||||
@@ -143,6 +143,7 @@ export function buildDiscordCommandOptions(params: {
|
||||
cfg: currentCfg,
|
||||
provider: context?.provider,
|
||||
model: context?.model,
|
||||
agentId: context?.agentId,
|
||||
agentRuntime: context?.agentRuntime,
|
||||
...(choiceCatalog?.length ? { catalog: choiceCatalog } : {}),
|
||||
});
|
||||
|
||||
@@ -525,6 +525,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
cfg,
|
||||
provider: menuModelContext?.provider,
|
||||
model: menuModelContext?.model,
|
||||
agentId: menuModelContext?.agentId,
|
||||
agentRuntime: menuModelContext?.agentRuntime,
|
||||
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
|
||||
});
|
||||
|
||||
@@ -539,81 +539,6 @@ describe("buildOpenAIProvider", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "the official OpenAI API",
|
||||
baseUrl: OPENAI_API_BASE_URL,
|
||||
expectedFetches: 1,
|
||||
},
|
||||
{
|
||||
name: "a custom OpenAI-compatible endpoint",
|
||||
baseUrl: "https://example-proxy.invalid/v1",
|
||||
expectedFetches: 0,
|
||||
},
|
||||
])(
|
||||
"keeps a selected $name API key when an OAuth profile is also available",
|
||||
async ({ baseUrl, expectedFetches }) => {
|
||||
mocks.resolveApiKeyForProvider.mockResolvedValue({
|
||||
mode: "oauth",
|
||||
apiKey: "synthetic-oauth-token",
|
||||
source: "profile:openai:chatgpt",
|
||||
profileId: "openai:chatgpt",
|
||||
});
|
||||
mocks.resolveProviderAuthProfileMetadata.mockReturnValue({
|
||||
profileId: "openai:chatgpt",
|
||||
accountId: "synthetic-oauth-account",
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
Response.json({
|
||||
data: [{ id: "gpt-5.5", object: "model" }],
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await buildOpenAIProvider().catalog?.run({
|
||||
resolveProviderAuth: () => ({
|
||||
mode: "api_key",
|
||||
apiKey: "synthetic-platform-key",
|
||||
discoveryApiKey: "synthetic-platform-discovery-key",
|
||||
source: "env",
|
||||
}),
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai:chatgpt": { provider: "openai", mode: "oauth" },
|
||||
},
|
||||
},
|
||||
models: { providers: { openai: { baseUrl, models: [] } } },
|
||||
},
|
||||
agentDir: "/tmp/openai-agent",
|
||||
workspaceDir: "/tmp/openai-workspace",
|
||||
} as never);
|
||||
|
||||
if (!result || "provider" in result) {
|
||||
throw new Error("expected OpenAI API-key provider catalog");
|
||||
}
|
||||
expect(result.providers.openai).toMatchObject({
|
||||
api: "openai-responses",
|
||||
apiKey: "synthetic-platform-key",
|
||||
baseUrl,
|
||||
});
|
||||
expect(mocks.resolveApiKeyForProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveProviderAuthProfileMetadata).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(expectedFetches);
|
||||
if (expectedFetches > 0) {
|
||||
const headers = fetchSpy.mock.calls[0]?.[1]?.headers;
|
||||
expect(headers).toBeInstanceOf(Headers);
|
||||
if (!(headers instanceof Headers)) {
|
||||
throw new Error("expected OpenAI discovery request headers");
|
||||
}
|
||||
expect(headers.get("Authorization")).toBe("Bearer synthetic-platform-discovery-key");
|
||||
}
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back to direct API-key catalog discovery when OAuth resolution fails", async () => {
|
||||
mocks.resolveApiKeyForProvider.mockRejectedValue(new Error("expired oauth profile"));
|
||||
const provider = buildOpenAIProvider();
|
||||
|
||||
@@ -950,6 +950,14 @@ export function buildOpenAIProvider(): ProviderPlugin {
|
||||
return null;
|
||||
}
|
||||
const auth = ctx.resolveProviderAuth(PROVIDER_ID);
|
||||
const selectedApiKey =
|
||||
auth.mode === "api_key" && auth.apiKey
|
||||
? {
|
||||
apiKey: auth.apiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
profileId: auth.profileId,
|
||||
}
|
||||
: undefined;
|
||||
// Catalog auth is already selected. Re-resolving an explicit API key
|
||||
// would let an unrelated OAuth profile replace its account and models.
|
||||
if (auth.mode !== "api_key") {
|
||||
@@ -994,29 +1002,18 @@ export function buildOpenAIProvider(): ProviderPlugin {
|
||||
// auth can still publish the standard OpenAI catalog.
|
||||
}
|
||||
}
|
||||
if (auth.mode === "api_key" && auth.apiKey) {
|
||||
const catalog = scopeOpenAICatalogOutcome(
|
||||
await buildOpenAILiveProviderConfig({
|
||||
apiKey: auth.apiKey,
|
||||
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
auth.profileId,
|
||||
);
|
||||
return {
|
||||
providers: { [PROVIDER_ID]: catalog.provider },
|
||||
...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}),
|
||||
};
|
||||
}
|
||||
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
const apiKey = selectedApiKey ?? ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
if (!apiKey.apiKey) {
|
||||
return null;
|
||||
}
|
||||
const catalog = await buildOpenAILiveProviderConfig({
|
||||
apiKey: apiKey.apiKey,
|
||||
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
|
||||
discoveryApiKey: apiKey.discoveryApiKey,
|
||||
});
|
||||
const catalog = scopeOpenAICatalogOutcome(
|
||||
await buildOpenAILiveProviderConfig({
|
||||
apiKey: apiKey.apiKey,
|
||||
baseUrl: resolveOpenAICatalogBaseUrl(ctx),
|
||||
discoveryApiKey: apiKey.discoveryApiKey,
|
||||
}),
|
||||
selectedApiKey?.profileId,
|
||||
);
|
||||
return {
|
||||
providers: { [PROVIDER_ID]: catalog.provider },
|
||||
...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}),
|
||||
|
||||
@@ -76,7 +76,6 @@ describe("OpenAI realtime voice provider routing", () => {
|
||||
{ encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 },
|
||||
{ encoding: "pcm16", sampleRateHz: 24000, channels: 1 },
|
||||
],
|
||||
supportsActivationNameGating: true,
|
||||
supportsBrowserSession: true,
|
||||
supportsBargeIn: true,
|
||||
handlesInputAudioBargeIn: true,
|
||||
|
||||
@@ -515,6 +515,7 @@ describe("openrouter provider hooks", () => {
|
||||
provider: "openrouter",
|
||||
modelId: "openrouter/fusion",
|
||||
promptMode: "full",
|
||||
agentId: "analyst",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -525,7 +526,7 @@ describe("openrouter provider hooks", () => {
|
||||
plugins: [
|
||||
{
|
||||
id: "fusion",
|
||||
analysis_models: ["deepseek/deepseek-v4-pro"],
|
||||
analysis_models: ["default/model"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -533,33 +534,6 @@ describe("openrouter provider hooks", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(contribution?.dynamicSuffix).toContain("Analysis models: deepseek/deepseek-v4-pro.");
|
||||
});
|
||||
|
||||
it("describes Fusion config from the selected agent model", async () => {
|
||||
const provider = await registerSingleProviderPlugin(openrouterPlugin);
|
||||
const contribution = provider.resolveSystemPromptContribution?.({
|
||||
provider: "openrouter",
|
||||
modelId: "openrouter/fusion",
|
||||
promptMode: "full",
|
||||
agentId: "analyst",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openrouter/fusion": {
|
||||
params: {
|
||||
extraBody: {
|
||||
plugins: [{ id: "fusion", analysis_models: ["default/model"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
analyst: {
|
||||
models: {
|
||||
@@ -1050,16 +1024,26 @@ describe("openrouter provider hooks", () => {
|
||||
sort: "price",
|
||||
data_collection: "deny",
|
||||
},
|
||||
frequencyPenalty: 0.3,
|
||||
},
|
||||
},
|
||||
" OpenRouter ": {
|
||||
params: {
|
||||
provider: {
|
||||
sort: "throughput",
|
||||
allow_fallbacks: false,
|
||||
},
|
||||
frequencyPenalty: 0.1,
|
||||
topK: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openrouter",
|
||||
provider: " openrouter ",
|
||||
modelId: "openai/gpt-5.4",
|
||||
extraParams: {
|
||||
provider: {
|
||||
sort: "latency",
|
||||
require_parameters: true,
|
||||
},
|
||||
temperature: 0.2,
|
||||
@@ -1081,158 +1065,17 @@ describe("openrouter provider hooks", () => {
|
||||
|
||||
expect(patch?.responseCache).toBe(true);
|
||||
expect(patch?.temperature).toBe(0.2);
|
||||
expect(patch?.frequencyPenalty).toBe(0.3);
|
||||
expect(patch?.topK).toBe(40);
|
||||
expect(patch?.provider).toEqual({
|
||||
sort: "latency",
|
||||
sort: "price",
|
||||
data_collection: "deny",
|
||||
allow_fallbacks: false,
|
||||
order: ["openai"],
|
||||
require_parameters: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ configuredProvider: "openrouter", runtimeProvider: "openrouter" },
|
||||
{ configuredProvider: "OpenRouter", runtimeProvider: "openrouter" },
|
||||
{ configuredProvider: " openrouter ", runtimeProvider: "openrouter" },
|
||||
{ configuredProvider: "openrouter", runtimeProvider: "OpenRouter" },
|
||||
{ configuredProvider: "openrouter", runtimeProvider: " openrouter " },
|
||||
])(
|
||||
"preserves configured OpenRouter routing for $configuredProvider and $runtimeProvider",
|
||||
async ({ configuredProvider, runtimeProvider }) => {
|
||||
const provider = await registerSingleProviderPlugin(openrouterPlugin);
|
||||
const patch = provider.extraParamsForTransport?.({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
[configuredProvider]: {
|
||||
params: {
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
allow_fallbacks: false,
|
||||
},
|
||||
temperature: 0.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: runtimeProvider,
|
||||
modelId: "openai/gpt-5.4",
|
||||
extraParams: { topP: 0.7 },
|
||||
transport: "sse",
|
||||
} as never)?.patch;
|
||||
|
||||
expect(patch).toEqual({
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
allow_fallbacks: false,
|
||||
},
|
||||
temperature: 0.25,
|
||||
topP: 0.7,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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: ["openai"], allow_fallbacks: false } },
|
||||
},
|
||||
OpenRouter: {
|
||||
params: { provider: { order: ["anthropic"], allow_fallbacks: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openrouter",
|
||||
modelId: "openai/gpt-5.4",
|
||||
extraParams: {},
|
||||
transport: "sse",
|
||||
} as never)?.patch;
|
||||
|
||||
expect(patch?.provider).toEqual({ order: ["openai"], allow_fallbacks: false });
|
||||
});
|
||||
|
||||
it("preserves the later routing config for trimmed duplicate OpenRouter keys", 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 },
|
||||
temperature: 0.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openrouter",
|
||||
modelId: "openai/gpt-5.4",
|
||||
extraParams: {},
|
||||
transport: "sse",
|
||||
} as never)?.patch;
|
||||
|
||||
expect(patch).toEqual({
|
||||
provider: { order: ["openai"], allow_fallbacks: false },
|
||||
temperature: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges routing split across case- and whitespace-equivalent OpenRouter keys", async () => {
|
||||
const provider = await registerSingleProviderPlugin(openrouterPlugin);
|
||||
const patch = provider.extraParamsForTransport?.({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
" OpenRouter ": {
|
||||
params: {
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
allow_fallbacks: false,
|
||||
},
|
||||
responseCache: true,
|
||||
temperature: 0.1,
|
||||
},
|
||||
},
|
||||
openrouter: {
|
||||
params: {
|
||||
provider: {
|
||||
sort: "price",
|
||||
require_parameters: true,
|
||||
},
|
||||
temperature: 0.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openrouter",
|
||||
modelId: "openai/gpt-5.4",
|
||||
extraParams: { topP: 0.7 },
|
||||
transport: "sse",
|
||||
} as never)?.patch;
|
||||
|
||||
expect(patch).toEqual({
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
allow_fallbacks: false,
|
||||
sort: "price",
|
||||
require_parameters: true,
|
||||
},
|
||||
responseCache: true,
|
||||
temperature: 0.25,
|
||||
topP: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inject OpenRouter reasoning for Hunter Alpha", async () => {
|
||||
const capturedPayload = await captureOpenRouterWrappedPayload({
|
||||
modelId: "openrouter/hunter-alpha",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Openrouter provider module implements model/runtime integration.
|
||||
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
type OpenRouterProviderConfig = {
|
||||
@@ -49,22 +50,6 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function mergeOpenRouterProviderConfigParams(
|
||||
previous: Record<string, unknown> | undefined,
|
||||
next: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...previous };
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
const previousRecord = readRecord(merged[key]);
|
||||
const nextRecord = readRecord(value);
|
||||
merged[key] =
|
||||
previousRecord && nextRecord
|
||||
? mergeOpenRouterProviderConfigParams(previousRecord, nextRecord)
|
||||
: value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveOpenRouterProviderConfigParams(
|
||||
ctx: OpenRouterExtraParamsContext,
|
||||
): Record<string, unknown> | undefined {
|
||||
@@ -88,7 +73,7 @@ function resolveOpenRouterProviderConfigParams(
|
||||
for (const [, config] of prioritizedProviders) {
|
||||
const params = readRecord(config.params);
|
||||
if (params) {
|
||||
matchedParams = mergeOpenRouterProviderConfigParams(matchedParams, params);
|
||||
matchedParams = mergeDeep(matchedParams ?? {}, params) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return matchedParams;
|
||||
|
||||
@@ -696,6 +696,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
command: commandDefinition,
|
||||
args: commandArgs,
|
||||
cfg,
|
||||
agentId: menuRoute?.agentId,
|
||||
...menuModelContext,
|
||||
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
|
||||
});
|
||||
|
||||
@@ -321,6 +321,7 @@ export async function executeTelegramBuiltinCommand(
|
||||
command: commandDefinition,
|
||||
args: commandArgs,
|
||||
cfg: dispatch.runtimeCfg,
|
||||
agentId: dispatch.route.agentId,
|
||||
...menuModelContext,
|
||||
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
|
||||
})
|
||||
|
||||
@@ -2,29 +2,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveExtraParams } from "./embedded-agent-runner/extra-params.js";
|
||||
|
||||
const AGENT_MODEL_PARAM_CASES = [
|
||||
{
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.6-luna",
|
||||
params: { temperature: 0.1, topP: 0.2, serviceTier: "priority", transport: "websocket" },
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
params: { temperature: 0.2, topP: 0.3, maxTokens: 321, cacheRetention: "short" },
|
||||
},
|
||||
{
|
||||
provider: "google",
|
||||
modelId: "gemini-2.5-pro",
|
||||
params: { temperature: 0.3, topP: 0.4, cachedContent: "cachedContents/agent-model-proof" },
|
||||
},
|
||||
{
|
||||
provider: "google-vertex",
|
||||
modelId: "gemini-2.5-pro",
|
||||
params: { temperature: 0.4, topP: 0.5, maxTokens: 456 },
|
||||
},
|
||||
];
|
||||
|
||||
describe("resolveExtraParams", () => {
|
||||
it("returns undefined with no model config", () => {
|
||||
const result = resolveExtraParams({
|
||||
@@ -162,108 +139,72 @@ describe("resolveExtraParams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(AGENT_MODEL_PARAM_CASES)(
|
||||
"applies canonical agent-specific model params for $provider/$modelId",
|
||||
({ provider, modelId, params }) => {
|
||||
const modelRef = `${provider}/${modelId}`;
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
entries: {
|
||||
audit: {
|
||||
models: { [modelRef]: { params } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider,
|
||||
modelId,
|
||||
agentId: "audit",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining(params));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(AGENT_MODEL_PARAM_CASES)(
|
||||
"applies the narrowest agent-specific model precedence for $provider/$modelId",
|
||||
({ provider, modelId, params }) => {
|
||||
const modelRef = `${provider}/${modelId}`;
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
params: { temperature: 0.9, topP: 0.9, cacheRetention: "long" },
|
||||
models: {
|
||||
[modelRef]: {
|
||||
params: { temperature: 0.8, topP: 0.8, maxTokens: 2048 },
|
||||
},
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
audit: {
|
||||
params: { temperature: 0.7, cacheRetention: "none" },
|
||||
models: { [modelRef]: { params } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider,
|
||||
modelId,
|
||||
agentId: "audit",
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
maxTokens: 2048,
|
||||
cacheRetention: "none",
|
||||
...params,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores model params belonging to another agent", () => {
|
||||
it("applies agent-model params at the narrowest config precedence", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
entries: {
|
||||
audit: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { temperature: 0.2 } },
|
||||
defaults: {
|
||||
params: { temperature: 0.9, topP: 0.9, cacheRetention: "long" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {
|
||||
params: { temperature: 0.8, topP: 0.8, maxTokens: 2048 },
|
||||
},
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
audit: {
|
||||
params: { temperature: 0.7, cacheRetention: "none" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {
|
||||
params: { temperature: 0.2, topP: 0.3, maxTokens: 321 },
|
||||
},
|
||||
},
|
||||
},
|
||||
main: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
agentId: "main",
|
||||
agentId: "audit",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
temperature: 0.2,
|
||||
topP: 0.3,
|
||||
maxTokens: 321,
|
||||
cacheRetention: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the selected agent's params for another model", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: {
|
||||
agents: {
|
||||
entries: {
|
||||
audit: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { temperature: 0.2 } },
|
||||
},
|
||||
it("isolates agent-model params to the selected agent and model", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
entries: {
|
||||
audit: {
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { params: { temperature: 0.2 } },
|
||||
},
|
||||
},
|
||||
main: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const otherAgent = resolveExtraParams({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
agentId: "main",
|
||||
});
|
||||
const otherModel = resolveExtraParams({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-6",
|
||||
agentId: "audit",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(otherAgent).toBeUndefined();
|
||||
expect(otherModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves higher-precedence agent parallelToolCalls override across alias styles", () => {
|
||||
|
||||
+4
-4
@@ -205,14 +205,14 @@ const makeAgentOverrideOnlyFallbackConfig = (agentId: string): OpenClawConfig =>
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
main: { default: true },
|
||||
[agentId]: {
|
||||
list: [
|
||||
{
|
||||
id: agentId,
|
||||
model: {
|
||||
fallbacks: ["openai/mock-2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
|
||||
@@ -118,18 +118,16 @@ export function resolveExtraParams(params: {
|
||||
modelId: string;
|
||||
agentId?: string;
|
||||
}): Record<string, unknown> | undefined {
|
||||
const { defaultParams, modelParams, agentEntryParams, agentModelParams } =
|
||||
resolveModelExtraParamSources({
|
||||
config: params.cfg,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const scopedParams = [defaultParams, modelParams, agentEntryParams, agentModelParams];
|
||||
const { paramSources } = resolveModelExtraParamSources({
|
||||
config: params.cfg,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
|
||||
const merged = Object.assign({}, ...scopedParams);
|
||||
const merged = Object.assign({}, ...paramSources);
|
||||
const resolvedParallelToolCalls = resolveAliasedParamValue(
|
||||
scopedParams,
|
||||
paramSources,
|
||||
"parallel_tool_calls",
|
||||
"parallelToolCalls",
|
||||
);
|
||||
@@ -139,7 +137,7 @@ export function resolveExtraParams(params: {
|
||||
}
|
||||
|
||||
const resolvedTextVerbosity = resolveAliasedParamValue(
|
||||
scopedParams.slice(1),
|
||||
paramSources.slice(1),
|
||||
"text_verbosity",
|
||||
"textVerbosity",
|
||||
);
|
||||
@@ -149,7 +147,7 @@ export function resolveExtraParams(params: {
|
||||
}
|
||||
|
||||
const resolvedResponseFormat = resolveAliasedParamValue(
|
||||
scopedParams,
|
||||
paramSources,
|
||||
"response_format",
|
||||
"responseFormat",
|
||||
);
|
||||
@@ -159,11 +157,11 @@ export function resolveExtraParams(params: {
|
||||
}
|
||||
canonicalizeMaxTokensParam({
|
||||
merged,
|
||||
sources: scopedParams,
|
||||
sources: paramSources,
|
||||
});
|
||||
|
||||
const resolvedCachedContent = resolveAliasedParamValue(
|
||||
scopedParams,
|
||||
paramSources,
|
||||
"cached_content",
|
||||
"cachedContent",
|
||||
);
|
||||
@@ -172,7 +170,7 @@ export function resolveExtraParams(params: {
|
||||
delete merged.cached_content;
|
||||
}
|
||||
if (params.provider === "openrouter") {
|
||||
canonicalizeOpenRouterResponseCacheParams(merged, scopedParams);
|
||||
canonicalizeOpenRouterResponseCacheParams(merged, paramSources);
|
||||
}
|
||||
|
||||
applyDefaultOpenAIGptRuntimeParams(params, merged);
|
||||
|
||||
@@ -200,9 +200,10 @@ describe("resolveFastModeState", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["fastSeconds", { fastSeconds: 15 }],
|
||||
["fast_seconds", { fast_seconds: 15 }],
|
||||
])("uses model %s alias for auto cutoff", (_label, params) => {
|
||||
["fastSeconds", { fastSeconds: 15 }, 15],
|
||||
["fast_seconds", { fast_seconds: 15 }, 15],
|
||||
["fast_auto_on_seconds before fastSeconds", { fast_auto_on_seconds: 20, fastSeconds: 15 }, 20],
|
||||
])("uses model %s alias for auto cutoff", (_label, params, expected) => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -221,7 +222,7 @@ describe("resolveFastModeState", () => {
|
||||
|
||||
expect(state.mode).toBe("auto");
|
||||
expect(state.source).toBe("config");
|
||||
expect(state.fastAutoOnSeconds).toBe(15);
|
||||
expect(state.fastAutoOnSeconds).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses model config when the runtime passes a provider-qualified model ref", () => {
|
||||
|
||||
+22
-59
@@ -5,13 +5,13 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
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,
|
||||
resolveFastModeModelParams,
|
||||
} from "../shared/fast-mode.js";
|
||||
import { DEFAULT_FAST_MODE_AUTO_ON_SECONDS, type FastModeSource } from "../shared/fast-mode.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
import { resolveModelExtraParamSources } from "./model-extra-params.js";
|
||||
import {
|
||||
FAST_MODE_CUTOFF_MODEL_PARAM_KEYS,
|
||||
FAST_MODE_MODEL_PARAM_KEYS,
|
||||
resolveModelExtraParamValue,
|
||||
} from "./model-extra-params.js";
|
||||
|
||||
export {
|
||||
DEFAULT_FAST_MODE_AUTO_ON_SECONDS,
|
||||
@@ -34,53 +34,6 @@ type FastModeState = {
|
||||
fastAutoOnSeconds: number;
|
||||
};
|
||||
|
||||
function resolveConfiguredFastModeParamSources(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
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. */
|
||||
export function resolveFastModeState(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
@@ -89,8 +42,21 @@ export function resolveFastModeState(params: {
|
||||
agentId?: string;
|
||||
sessionEntry?: Pick<SessionEntry, "fastMode"> | undefined;
|
||||
}): FastModeState {
|
||||
const configuredParamSources = resolveConfiguredFastModeParamSources(params);
|
||||
const fastAutoOnSeconds = resolveConfiguredFastModeAutoOnSeconds(configuredParamSources);
|
||||
const modelParamContext = {
|
||||
config: params.cfg,
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
agentId: params.agentId,
|
||||
};
|
||||
const configuredAutoOnSeconds = resolveModelExtraParamValue(
|
||||
modelParamContext,
|
||||
FAST_MODE_CUTOFF_MODEL_PARAM_KEYS,
|
||||
(value) => typeof value === "number" && Number.isInteger(value) && value > 0,
|
||||
);
|
||||
const fastAutoOnSeconds =
|
||||
typeof configuredAutoOnSeconds === "number"
|
||||
? configuredAutoOnSeconds
|
||||
: DEFAULT_FAST_MODE_AUTO_ON_SECONDS;
|
||||
const sessionOverride = normalizeFastMode(params.sessionEntry?.fastMode);
|
||||
if (sessionOverride !== undefined) {
|
||||
return {
|
||||
@@ -115,10 +81,7 @@ export function resolveFastModeState(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const configuredRaw = resolveConfiguredFastModeValue(configuredParamSources, [
|
||||
"fastMode",
|
||||
"fast_mode",
|
||||
]);
|
||||
const configuredRaw = resolveModelExtraParamValue(modelParamContext, FAST_MODE_MODEL_PARAM_KEYS);
|
||||
const configured = normalizeFastMode(configuredRaw as string | boolean | null | undefined);
|
||||
if (configured !== undefined) {
|
||||
return {
|
||||
|
||||
@@ -4,20 +4,22 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { modelKey } from "../shared/model-key.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
|
||||
type ModelExtraParamSources = {
|
||||
export type ModelExtraParamSources = {
|
||||
defaultParams?: Record<string, unknown>;
|
||||
modelParams?: Record<string, unknown>;
|
||||
agentParams?: Record<string, unknown>;
|
||||
agentEntryParams?: Record<string, unknown>;
|
||||
agentModelParams?: Record<string, unknown>;
|
||||
paramSources: Array<Record<string, unknown> | undefined>;
|
||||
};
|
||||
|
||||
const FAST_MODE_CUTOFF_MODEL_PARAM_KEYS = new Set([
|
||||
export const FAST_MODE_MODEL_PARAM_KEYS = ["fastMode", "fast_mode"] as const;
|
||||
export const FAST_MODE_CUTOFF_MODEL_PARAM_KEYS = [
|
||||
"fastAutoOnSeconds",
|
||||
"fastSeconds",
|
||||
"fast_auto_on_seconds",
|
||||
"fastSeconds",
|
||||
"fast_seconds",
|
||||
]);
|
||||
] as const;
|
||||
const FAST_MODE_CUTOFF_MODEL_PARAM_KEY_SET = new Set<string>(FAST_MODE_CUTOFF_MODEL_PARAM_KEYS);
|
||||
|
||||
// Native harnesses receive recognized values as typed run controls. Other value
|
||||
// shapes with the same keys remain authored provider request parameters.
|
||||
@@ -30,11 +32,11 @@ export function isAgentRuntimeModelParam(key: string, value: unknown): boolean {
|
||||
(typeof value === "string" && normalizeThinkLevel(value) !== undefined)
|
||||
);
|
||||
}
|
||||
if (key === "fastMode" || key === "fast_mode") {
|
||||
if (FAST_MODE_MODEL_PARAM_KEYS.some((candidate) => candidate === key)) {
|
||||
return normalizeFastMode(value) !== undefined;
|
||||
}
|
||||
return (
|
||||
FAST_MODE_CUTOFF_MODEL_PARAM_KEYS.has(key) &&
|
||||
FAST_MODE_CUTOFF_MODEL_PARAM_KEY_SET.has(key) &&
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value > 0
|
||||
@@ -69,31 +71,70 @@ export function resolveModelExtraParamSources(params: {
|
||||
? (agentConfig?.models?.[canonicalKey]?.params ??
|
||||
(legacyKey ? agentConfig?.models?.[legacyKey]?.params : undefined))
|
||||
: undefined;
|
||||
// Keep the merged exact-key projection for classifiers and diagnostics.
|
||||
// Request construction uses the separate records so alias precedence survives.
|
||||
const agentParams = agentModelParams
|
||||
? { ...agentEntryParams, ...agentModelParams }
|
||||
: agentEntryParams;
|
||||
return { defaultParams, modelParams, agentParams, agentEntryParams, agentModelParams };
|
||||
const paramSources = [defaultParams, modelParams, agentEntryParams, agentModelParams];
|
||||
return { defaultParams, modelParams, agentEntryParams, agentModelParams, paramSources };
|
||||
}
|
||||
|
||||
function resolveModelExtraParamEntryFromSources(
|
||||
sources: readonly (Record<string, unknown> | undefined)[],
|
||||
keys: readonly string[],
|
||||
accepts?: (value: unknown) => boolean,
|
||||
): { key: string; value: unknown; sourceIndex: number } | undefined {
|
||||
for (let sourceIndex = sources.length - 1; sourceIndex >= 0; sourceIndex -= 1) {
|
||||
const source = sources[sourceIndex];
|
||||
for (const key of keys) {
|
||||
if (!source || !Object.hasOwn(source, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = source[key];
|
||||
if (!accepts || accepts(value)) {
|
||||
return { key, value, sourceIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolves the effective parameter set with the winning config scope retained. */
|
||||
export function resolveEffectiveModelExtraParams(sources: ModelExtraParamSources): Array<{
|
||||
key: string;
|
||||
effectiveKey: string;
|
||||
value: unknown;
|
||||
sourceIndex: number;
|
||||
}> {
|
||||
const effective = new Map<
|
||||
string,
|
||||
{ key: string; effectiveKey: string; value: unknown; sourceIndex: number }
|
||||
>();
|
||||
sources.paramSources.forEach((source, sourceIndex) => {
|
||||
for (const [key, value] of Object.entries(source ?? {})) {
|
||||
effective.set(key, { key, effectiveKey: key, value, sourceIndex });
|
||||
}
|
||||
});
|
||||
for (const aliases of [FAST_MODE_MODEL_PARAM_KEYS, FAST_MODE_CUTOFF_MODEL_PARAM_KEYS]) {
|
||||
const entry = resolveModelExtraParamEntryFromSources(sources.paramSources, aliases);
|
||||
for (const alias of aliases) {
|
||||
effective.delete(alias);
|
||||
}
|
||||
if (entry) {
|
||||
effective.set(aliases[0], { ...entry, effectiveKey: aliases[0] });
|
||||
}
|
||||
}
|
||||
return [...effective.values()];
|
||||
}
|
||||
|
||||
/** Resolves one authored parameter across the canonical config precedence. */
|
||||
export function resolveModelExtraParamValue(
|
||||
params: Parameters<typeof resolveModelExtraParamSources>[0],
|
||||
key: string,
|
||||
key: string | readonly string[],
|
||||
accepts?: (value: unknown) => boolean,
|
||||
): 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;
|
||||
return resolveModelExtraParamEntryFromSources(
|
||||
sources.paramSources,
|
||||
typeof key === "string" ? [key] : key,
|
||||
accepts,
|
||||
)?.value;
|
||||
}
|
||||
|
||||
/** Returns whether embedded OpenClaw would apply authored provider request parameters. */
|
||||
@@ -101,7 +142,7 @@ export function hasAuthoredProviderRequestParams(
|
||||
params: Parameters<typeof resolveModelExtraParamSources>[0],
|
||||
): boolean {
|
||||
const sources = resolveModelExtraParamSources(params);
|
||||
return Object.entries(
|
||||
Object.assign({}, sources.defaultParams, sources.modelParams, sources.agentParams),
|
||||
).some(([key, value]) => !isAgentRuntimeModelParam(key, value));
|
||||
return resolveEffectiveModelExtraParams(sources).some(
|
||||
({ effectiveKey, value }) => !isAgentRuntimeModelParam(effectiveKey, value),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,38 +11,6 @@ import {
|
||||
} from "./openai-routing.js";
|
||||
|
||||
const CODEX_RUNTIME_CONTROL_SCOPES = ["model", "global", "agent", "agent-model"] as const;
|
||||
const CODEX_RUNTIME_CONTROL_CASES: ReadonlyArray<readonly [string, Record<string, unknown>]> = [
|
||||
["thinking off", { thinking: "off" }],
|
||||
["thinking minimal", { thinking: "minimal" }],
|
||||
["thinking low", { thinking: "low" }],
|
||||
["thinking medium", { thinking: "medium" }],
|
||||
["thinking high", { thinking: "high" }],
|
||||
["thinking xhigh", { thinking: "xhigh" }],
|
||||
["thinking adaptive", { thinking: "adaptive" }],
|
||||
["thinking max", { thinking: "max" }],
|
||||
["thinking ultra", { thinking: "ultra" }],
|
||||
["thinking false", { thinking: false }],
|
||||
["thinking disabled", { thinking: "disabled" }],
|
||||
["thinking none", { thinking: "none" }],
|
||||
["fastMode on", { fastMode: true }],
|
||||
["fastMode off", { fastMode: false }],
|
||||
["fastMode auto", { fastMode: "auto" }],
|
||||
["fast_mode", { fast_mode: true }],
|
||||
["fastAutoOnSeconds", { fastMode: "auto", fastAutoOnSeconds: 30 }],
|
||||
["fast_auto_on_seconds", { fastMode: "auto", fast_auto_on_seconds: 30 }],
|
||||
["fastSeconds", { fastMode: "auto", fastSeconds: 30 }],
|
||||
["fast_seconds", { fastMode: "auto", fast_seconds: 30 }],
|
||||
];
|
||||
const OPENAI_PROVIDER_REQUEST_PARAM_CASES: ReadonlyArray<
|
||||
readonly [string, Record<string, unknown>]
|
||||
> = [
|
||||
["provider-native thinking", { thinking: { type: "enabled", budget_tokens: 2_048 } }],
|
||||
["invalid fast mode", { fastMode: { enabled: true } }],
|
||||
["invalid fast cutoff", { fastAutoOnSeconds: "30" }],
|
||||
["provider temperature", { temperature: 0.4 }],
|
||||
["provider service tier", { serviceTier: "priority" }],
|
||||
["provider transport", { transport: "sse" }],
|
||||
];
|
||||
|
||||
function createScopedOpenAIRoutingConfig(
|
||||
scope: (typeof CODEX_RUNTIME_CONTROL_SCOPES)[number],
|
||||
@@ -90,75 +58,103 @@ describe("OpenAI runtime routing policy", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(
|
||||
CODEX_RUNTIME_CONTROL_CASES.flatMap(([label, params]) =>
|
||||
CODEX_RUNTIME_CONTROL_SCOPES.map((scope) => ({
|
||||
label,
|
||||
params,
|
||||
scope,
|
||||
})),
|
||||
),
|
||||
)("keeps Codex for $label controls at $scope scope", ({ params, scope }) => {
|
||||
const { config, agentId } = createScopedOpenAIRoutingConfig(scope, params);
|
||||
it.each([
|
||||
["thinking", { thinking: "xhigh" }],
|
||||
["fastMode", { fastMode: true }],
|
||||
["fast_mode", { fast_mode: true }],
|
||||
["fastAutoOnSeconds", { fastMode: "auto", fastAutoOnSeconds: 30 }],
|
||||
["fast_auto_on_seconds", { fastMode: "auto", fast_auto_on_seconds: 30 }],
|
||||
["fastSeconds", { fastMode: "auto", fastSeconds: 30 }],
|
||||
["fast_seconds", { fastMode: "auto", fast_seconds: 30 }],
|
||||
])("keeps Codex for model-scoped %s controls", (_label, params) => {
|
||||
const { config } = createScopedOpenAIRoutingConfig("model", params);
|
||||
|
||||
expect(
|
||||
resolveOpenAIImplicitAgentRuntime({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
env: {},
|
||||
}),
|
||||
).toBe("codex");
|
||||
expect(
|
||||
modelSelectionShouldEnsureCodexPlugin({
|
||||
model: "openai/gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(
|
||||
OPENAI_PROVIDER_REQUEST_PARAM_CASES.flatMap(([label, params]) =>
|
||||
CODEX_RUNTIME_CONTROL_SCOPES.map((scope) => ({
|
||||
label,
|
||||
params,
|
||||
scope,
|
||||
})),
|
||||
),
|
||||
)("keeps $label values at $scope scope on the OpenClaw runtime", ({ params, scope }) => {
|
||||
const { config, agentId } = createScopedOpenAIRoutingConfig(scope, params);
|
||||
it.each(["global", "agent", "agent-model"] as const)(
|
||||
"keeps typed native controls at %s scope on Codex",
|
||||
(scope) => {
|
||||
const { config, agentId } = createScopedOpenAIRoutingConfig(scope, { thinking: "high" });
|
||||
|
||||
expect(
|
||||
resolveOpenAIImplicitAgentRuntime({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
env: {},
|
||||
}),
|
||||
).toBe("codex");
|
||||
expect(
|
||||
modelSelectionShouldEnsureCodexPlugin({
|
||||
model: "openai/gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["provider-native thinking", { thinking: { type: "enabled", budget_tokens: 2_048 } }],
|
||||
["invalid fast mode", { fastMode: { enabled: true } }],
|
||||
["invalid fast cutoff", { fastAutoOnSeconds: "30" }],
|
||||
])("keeps %s values on the OpenClaw runtime", (_label, params) => {
|
||||
const { config } = createScopedOpenAIRoutingConfig("model", params);
|
||||
|
||||
expect(
|
||||
resolveOpenAIImplicitAgentRuntime({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
env: {},
|
||||
}),
|
||||
).toBe("openclaw");
|
||||
expect(
|
||||
modelSelectionShouldEnsureCodexPlugin({
|
||||
model: "openai/gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies only the effective value after agent-model parameter precedence", () => {
|
||||
it.each(["global", "agent", "agent-model"] as const)(
|
||||
"keeps authored provider params at %s scope on OpenClaw",
|
||||
(scope) => {
|
||||
const { config, agentId } = createScopedOpenAIRoutingConfig(scope, { temperature: 0.4 });
|
||||
|
||||
expect(
|
||||
resolveOpenAIImplicitAgentRuntime({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
env: {},
|
||||
}),
|
||||
).toBe("openclaw");
|
||||
expect(
|
||||
modelSelectionShouldEnsureCodexPlugin({
|
||||
model: "openai/gpt-5.6-sol",
|
||||
config,
|
||||
agentId,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("classifies the effective alias after agent-model parameter precedence", () => {
|
||||
const modelKey = "openai/gpt-5.6-sol";
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
params: { thinking: { type: "enabled", budget_tokens: 2_048 } },
|
||||
params: { fastMode: { enabled: true } },
|
||||
},
|
||||
entries: {
|
||||
audit: {
|
||||
models: {
|
||||
[modelKey]: { params: { thinking: "high" } },
|
||||
[modelKey]: { params: { fast_mode: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Shared command registry builders used by browser-safe and runtime command lists. */
|
||||
import { normalizeOptionalLowercaseString } from "../../packages/normalization-core/src/string-coerce.js";
|
||||
import { normalizeStringEntries } from "../../packages/normalization-core/src/string-normalization.js";
|
||||
import { formatFastModeAutoLabel, resolveFastModeModelAutoOnSeconds } from "../shared/fast-mode.js";
|
||||
import { formatFastModeAutoLabel } from "../shared/fast-mode.js";
|
||||
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
|
||||
import type {
|
||||
ChatCommandDefinition,
|
||||
@@ -590,14 +590,12 @@ export function buildBuiltinChatCommands(
|
||||
defineBuiltinCommand("fast", "Toggle fast mode.", "options", "standard", {
|
||||
args: [
|
||||
defineCommandArgument("mode", "on, off, auto, default, or status", {
|
||||
choices: ({ cfg, provider, model }) => [
|
||||
choices: ({ fastAutoOnSeconds }) => [
|
||||
"on",
|
||||
"off",
|
||||
{
|
||||
value: "auto",
|
||||
label: formatFastModeAutoLabel({
|
||||
fastAutoOnSeconds: resolveFastModeModelAutoOnSeconds({ cfg, provider, model }),
|
||||
}),
|
||||
label: formatFastModeAutoLabel({ fastAutoOnSeconds }),
|
||||
},
|
||||
"default",
|
||||
"status",
|
||||
|
||||
@@ -548,22 +548,31 @@ describe("commands registry", () => {
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai-codex/gpt-5.5",
|
||||
model: "openai/gpt-5.5",
|
||||
models: {
|
||||
"openai-codex/gpt-5.5": {
|
||||
"openai/gpt-5.5": {
|
||||
params: { fastMode: "auto", fastAutoOnSeconds: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
audit: {
|
||||
params: { fastAutoOnSeconds: 20 },
|
||||
models: {
|
||||
"openai/gpt-5.5": { params: { fast_seconds: 15 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
provider: "openai-codex",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
agentId: "audit",
|
||||
});
|
||||
expect(menu.choices).toEqual([
|
||||
{ label: "on", value: "on" },
|
||||
{ label: "off", value: "off" },
|
||||
{ label: "auto (30 sec)", value: "auto" },
|
||||
{ label: "auto (15 sec)", value: "auto" },
|
||||
{ label: "default", value: "default" },
|
||||
{ label: "status", value: "status" },
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveFastModeState } from "../agents/fast-mode.js";
|
||||
import {
|
||||
buildConfiguredModelCatalog,
|
||||
resolveConfiguredModelRef,
|
||||
@@ -331,6 +332,7 @@ export function resolveCommandArgChoices(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
agentId?: string;
|
||||
agentRuntime?: string;
|
||||
catalog?: ThinkingCatalogEntry[];
|
||||
}): ResolvedCommandArgChoice[] {
|
||||
@@ -343,11 +345,19 @@ export function resolveCommandArgChoices(params: {
|
||||
? provided
|
||||
: (() => {
|
||||
const defaults = resolveDefaultCommandContext(cfg);
|
||||
const provider = params.provider ?? defaults.provider;
|
||||
const model = params.model ?? defaults.model;
|
||||
const context: CommandArgChoiceContext = {
|
||||
cfg,
|
||||
provider: params.provider ?? defaults.provider,
|
||||
model: params.model ?? defaults.model,
|
||||
provider,
|
||||
model,
|
||||
agentId: params.agentId,
|
||||
agentRuntime: params.agentRuntime,
|
||||
fastAutoOnSeconds:
|
||||
command.key === "fast"
|
||||
? resolveFastModeState({ cfg, provider, model, agentId: params.agentId })
|
||||
.fastAutoOnSeconds
|
||||
: undefined,
|
||||
catalog: params.catalog ?? (cfg ? buildConfiguredModelCatalog({ cfg }) : undefined),
|
||||
command,
|
||||
arg,
|
||||
@@ -366,10 +376,11 @@ export function resolveCommandArgMenu(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
agentId?: string;
|
||||
agentRuntime?: string;
|
||||
catalog?: ThinkingCatalogEntry[];
|
||||
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
|
||||
const { command, args, cfg, provider, model, agentRuntime, catalog } = params;
|
||||
const { command, args, cfg, provider, model, agentId, agentRuntime, catalog } = params;
|
||||
if (!command.args || !command.argsMenu) {
|
||||
return null;
|
||||
}
|
||||
@@ -388,6 +399,7 @@ export function resolveCommandArgMenu(params: {
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
agentId,
|
||||
agentRuntime,
|
||||
catalog: resolvedCatalog,
|
||||
}).length > 0,
|
||||
@@ -412,6 +424,7 @@ export function resolveCommandArgMenu(params: {
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
agentId,
|
||||
agentRuntime,
|
||||
catalog: resolvedCatalog,
|
||||
});
|
||||
|
||||
@@ -33,7 +33,9 @@ export type CommandArgChoiceContext = {
|
||||
cfg?: OpenClawConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
agentId?: string;
|
||||
agentRuntime?: string;
|
||||
fastAutoOnSeconds?: number;
|
||||
catalog?: ThinkingCatalogEntry[];
|
||||
command: ChatCommandDefinition;
|
||||
arg: CommandArgDefinition;
|
||||
|
||||
@@ -460,10 +460,10 @@ describe("collectCodexRouteWarnings", () => {
|
||||
},
|
||||
entries: {
|
||||
coder: {
|
||||
params: { topP: 0.8, fastMode: "auto", temperature: 0.6 },
|
||||
params: { topP: 0.8, fastMode: { enabled: true }, temperature: 0.6 },
|
||||
models: {
|
||||
"openai/gpt-5.6-sol": {
|
||||
params: { temperature: 0.1, thinking: "medium", topK: 40 },
|
||||
params: { fast_mode: true, temperature: 0.1, thinking: "medium", topK: 40 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -483,10 +483,10 @@ describe("collectCodexRouteWarnings", () => {
|
||||
expect(result.warnings.join("\n")).toContain(
|
||||
"agents.defaults.models.openai/gpt-5.6-sol.params.serviceTier",
|
||||
);
|
||||
expect(result.warnings.join("\n")).toContain(
|
||||
expect(result.warnings.join("\n")).not.toContain(
|
||||
"agents.defaults.models.openai/gpt-5.6-sol.params.temperature",
|
||||
);
|
||||
expect(result.warnings.join("\n")).toContain("agents.defaults.params.temperature");
|
||||
expect(result.warnings.join("\n")).not.toContain("agents.defaults.params.temperature");
|
||||
expect(result.warnings.join("\n")).toContain("agents.entries.coder.params.topP");
|
||||
expect(result.warnings.join("\n")).toContain(
|
||||
"agents.entries.coder.models.openai/gpt-5.6-sol.params.temperature",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
isAgentRuntimeModelParam,
|
||||
resolveEffectiveModelExtraParams,
|
||||
resolveModelExtraParamSources,
|
||||
} from "../../../agents/model-extra-params.js";
|
||||
import { resolveModelRuntimePolicy } from "../../../agents/model-runtime-policy.js";
|
||||
@@ -197,11 +198,8 @@ function collectCodexModelParamHits(
|
||||
): CodexModelParamHit[] {
|
||||
const hits: CodexModelParamHit[] = [];
|
||||
const seen = new Set<string>();
|
||||
const agentEntries = new Map(
|
||||
listMutableCodexRouteAgentEntries(cfg).map(({ agent, agentId, path }) => [
|
||||
agentId,
|
||||
{ agent, path },
|
||||
]),
|
||||
const agentPaths = new Map(
|
||||
listMutableCodexRouteAgentEntries(cfg).map(({ agentId, path }) => [agentId, path]),
|
||||
);
|
||||
for (const route of collectCodexRuntimeRouteHits(cfg, env)) {
|
||||
const parsed = parseCodexRouteModelRef(route.canonicalModel);
|
||||
@@ -214,11 +212,6 @@ function collectCodexModelParamHits(
|
||||
modelId: parsed.modelId,
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const agentEntry = route.agentId ? agentEntries.get(route.agentId) : undefined;
|
||||
const agentModels = asMutableRecord(agentEntry?.agent.models);
|
||||
const agentModelParams = asMutableRecord(
|
||||
asMutableRecord(agentModels?.[route.canonicalModel])?.params,
|
||||
);
|
||||
const modelParams = sources.modelParams;
|
||||
const fastModes = ownValues(modelParams ?? {}, FAST_MODE_PARAM_KEYS);
|
||||
const serviceTiers = ownValues(modelParams ?? {}, SERVICE_TIER_PARAM_KEYS);
|
||||
@@ -228,55 +221,36 @@ function collectCodexModelParamHits(
|
||||
serviceTiers.length > 0 &&
|
||||
serviceTiers.every((configured) => normalizeString(configured) === "priority") &&
|
||||
modelUsesCodexForEveryAgent(cfg, route.canonicalModel);
|
||||
const paramSources = [
|
||||
{
|
||||
params: sources.defaultParams,
|
||||
path: "agents.defaults.params",
|
||||
modelScoped: false,
|
||||
agentModelParams: undefined,
|
||||
},
|
||||
{
|
||||
params: modelParams,
|
||||
path: `agents.defaults.models.${route.canonicalModel}.params`,
|
||||
modelScoped: true,
|
||||
agentModelParams: undefined,
|
||||
},
|
||||
...(route.agentId
|
||||
? [
|
||||
{
|
||||
params: sources.agentParams,
|
||||
path: agentEntry?.path ?? `agents.entries.${route.agentId}`,
|
||||
modelScoped: false,
|
||||
agentModelParams,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
const agentPath = route.agentId
|
||||
? (agentPaths.get(route.agentId) ?? `agents.entries.${route.agentId}`)
|
||||
: undefined;
|
||||
const sourcePaths = [
|
||||
"agents.defaults.params",
|
||||
`agents.defaults.models.${route.canonicalModel}.params`,
|
||||
agentPath ? `${agentPath}.params` : undefined,
|
||||
agentPath ? `${agentPath}.models.${route.canonicalModel}.params` : undefined,
|
||||
];
|
||||
for (const source of paramSources) {
|
||||
for (const [key, paramValue] of Object.entries(source.params ?? {})) {
|
||||
if (isAgentRuntimeModelParam(key, paramValue)) {
|
||||
continue;
|
||||
}
|
||||
const sourcePath = Object.hasOwn(source.agentModelParams ?? {}, key)
|
||||
? `${source.path}.models.${route.canonicalModel}.params`
|
||||
: source.modelScoped || source.path === "agents.defaults.params"
|
||||
? source.path
|
||||
: `${source.path}.params`;
|
||||
const path = `${sourcePath}.${key}`;
|
||||
if (seen.has(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(path);
|
||||
hits.push({
|
||||
key,
|
||||
path,
|
||||
modelRef: route.canonicalModel,
|
||||
removable:
|
||||
source.modelScoped &&
|
||||
canRemoveServiceTier &&
|
||||
SERVICE_TIER_PARAM_KEYS.some((alias) => alias === key),
|
||||
});
|
||||
for (const { key, effectiveKey, value, sourceIndex } of resolveEffectiveModelExtraParams(
|
||||
sources,
|
||||
)) {
|
||||
const sourcePath = sourcePaths[sourceIndex];
|
||||
if (!sourcePath || isAgentRuntimeModelParam(effectiveKey, value)) {
|
||||
continue;
|
||||
}
|
||||
const path = `${sourcePath}.${key}`;
|
||||
if (seen.has(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(path);
|
||||
hits.push({
|
||||
key,
|
||||
path,
|
||||
modelRef: route.canonicalModel,
|
||||
removable:
|
||||
sourceIndex === 1 &&
|
||||
canRemoveServiceTier &&
|
||||
SERVICE_TIER_PARAM_KEYS.some((alias) => alias === key),
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
|
||||
Reference in New Issue
Block a user