From 1a28bd4341936e3a4204fbfcd6fed1ef4aff404a Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 13 Aug 2026 21:53:59 +0800 Subject: [PATCH] refactor: consolidate provider parameter precedence --- docs/gateway/config-agents.md | 2 +- .../src/monitor/native-command.options.ts | 1 + .../discord/src/monitor/native-command.ts | 1 + extensions/openai/openai-provider.test.ts | 75 ------- extensions/openai/openai-provider.ts | 37 ++-- .../realtime-voice-provider-routing.test.ts | 1 - extensions/openrouter/index.test.ts | 193 ++---------------- extensions/openrouter/provider-routing.ts | 19 +- extensions/slack/src/monitor/slash.ts | 1 + .../src/bot-native-command-builtins.ts | 1 + ...d-agent-runner-extraparams-resolve.test.ts | 145 ++++--------- ...ed-agent.auth-profile-rotation.e2e.test.ts | 8 +- .../embedded-agent-runner/extra-params.ts | 28 ++- src/agents/fast-mode.test.ts | 9 +- src/agents/fast-mode.ts | 81 ++------ src/agents/model-extra-params.ts | 97 ++++++--- src/agents/openai-routing.test.ts | 138 ++++++------- src/auto-reply/commands-registry.shared.ts | 8 +- src/auto-reply/commands-registry.test.ts | 17 +- src/auto-reply/commands-registry.ts | 19 +- src/auto-reply/commands-registry.types.ts | 2 + .../shared/codex-route-warnings.test.ts | 8 +- .../doctor/shared/codex-route-warnings.ts | 88 +++----- 23 files changed, 334 insertions(+), 645 deletions(-) diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index d542ee7b33fb..a46e8849d0fa 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -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/"].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. diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts index 9d77c80d4491..f09b1d997f3d 100644 --- a/extensions/discord/src/monitor/native-command.options.ts +++ b/extensions/discord/src/monitor/native-command.options.ts @@ -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 } : {}), }); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 37c094628eab..6d36e5de3017 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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 } : {}), }); diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index ba5b03453b32..9ceb26af8454 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -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(); diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 2577b9004ecd..0c9982c4726d 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -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] } : {}), diff --git a/extensions/openai/realtime-voice-provider-routing.test.ts b/extensions/openai/realtime-voice-provider-routing.test.ts index b6335c968e88..98c1c5769ae2 100644 --- a/extensions/openai/realtime-voice-provider-routing.test.ts +++ b/extensions/openai/realtime-voice-provider-routing.test.ts @@ -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, diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 741acd2824c6..61f9a1a2d0f6 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -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", diff --git a/extensions/openrouter/provider-routing.ts b/extensions/openrouter/provider-routing.ts index 39dc9ac952b4..5871b45546bf 100644 --- a/extensions/openrouter/provider-routing.ts +++ b/extensions/openrouter/provider-routing.ts @@ -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 | undefined { return Object.keys(sanitized).length > 0 ? sanitized : undefined; } -function mergeOpenRouterProviderConfigParams( - previous: Record | undefined, - next: Record, -): Record { - 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 | 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; } } return matchedParams; diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 4c23a58a296f..2c37692b8255 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -696,6 +696,7 @@ export async function registerSlackMonitorSlashCommands(params: { command: commandDefinition, args: commandArgs, cfg, + agentId: menuRoute?.agentId, ...menuModelContext, ...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}), }); diff --git a/extensions/telegram/src/bot-native-command-builtins.ts b/extensions/telegram/src/bot-native-command-builtins.ts index 64320bce1514..4782f51219a5 100644 --- a/extensions/telegram/src/bot-native-command-builtins.ts +++ b/extensions/telegram/src/bot-native-command-builtins.ts @@ -321,6 +321,7 @@ export async function executeTelegramBuiltinCommand( command: commandDefinition, args: commandArgs, cfg: dispatch.runtimeCfg, + agentId: dispatch.route.agentId, ...menuModelContext, ...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}), }) diff --git a/src/agents/embedded-agent-runner-extraparams-resolve.test.ts b/src/agents/embedded-agent-runner-extraparams-resolve.test.ts index e884e1582105..dd0727fc0812 100644 --- a/src/agents/embedded-agent-runner-extraparams-resolve.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-resolve.test.ts @@ -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", () => { diff --git a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts index 713ccc18dd82..aeac206de0e1 100644 --- a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts @@ -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: { diff --git a/src/agents/embedded-agent-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts index 5f2dc42d31cf..50a57f78b7ad 100644 --- a/src/agents/embedded-agent-runner/extra-params.ts +++ b/src/agents/embedded-agent-runner/extra-params.ts @@ -118,18 +118,16 @@ export function resolveExtraParams(params: { modelId: string; agentId?: string; }): Record | 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); diff --git a/src/agents/fast-mode.test.ts b/src/agents/fast-mode.test.ts index 3fb7d71ffe84..a5bf948658e0 100644 --- a/src/agents/fast-mode.test.ts +++ b/src/agents/fast-mode.test.ts @@ -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", () => { diff --git a/src/agents/fast-mode.ts b/src/agents/fast-mode.ts index 1fcefa510a76..cac4d187d2cc 100644 --- a/src/agents/fast-mode.ts +++ b/src/agents/fast-mode.ts @@ -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 | 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. */ export function resolveFastModeState(params: { cfg: OpenClawConfig | undefined; @@ -89,8 +42,21 @@ export function resolveFastModeState(params: { agentId?: string; sessionEntry?: Pick | 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 { diff --git a/src/agents/model-extra-params.ts b/src/agents/model-extra-params.ts index 43703ca61237..7df454d31228 100644 --- a/src/agents/model-extra-params.ts +++ b/src/agents/model-extra-params.ts @@ -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; modelParams?: Record; - agentParams?: Record; agentEntryParams?: Record; agentModelParams?: Record; + paramSources: Array | 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(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 | 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[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[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), + ); } diff --git a/src/agents/openai-routing.test.ts b/src/agents/openai-routing.test.ts index 07d4917bdb90..3bc7e9c28374 100644 --- a/src/agents/openai-routing.test.ts +++ b/src/agents/openai-routing.test.ts @@ -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]> = [ - ["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] -> = [ - ["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 } }, }, }, }, diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 7f189e753a45..aeb7fcccd490 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -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", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 04e5504e1572..488f7d818deb 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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" }, ]); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 3c6e312844d8..ec1377b4b72d 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -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, }); diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 8c4910924590..271bf875684e 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -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; diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 9cf7d91ab4f8..5f047d39321e 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -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", diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 002b58d17657..c526b67f2a45 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -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(); - 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;