diff --git a/docs/help/faq.md b/docs/help/faq.md index 59ced0924fc5..274bee828436 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -167,7 +167,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - } ``` - Put shared per-model defaults in `agents.defaults.models["provider/model"].params`, then agent-specific overrides in flat `agents.entries.*.params`. Do not duplicate the same model under nested `agents.entries.*.models["provider/model"].params`; that path is for per-agent model catalog and runtime overrides. + Put shared per-model defaults in `agents.defaults.models["provider/model"].params` and broad per-agent overrides in flat `agents.entries.*.params`. When one agent needs an override only for one model, use `agents.entries.*.models["provider/model"].params`; those values are narrower than flat agent params and win for that model. The same nested entry also owns per-agent catalog metadata and runtime policy. See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), [Configuration](/gateway/config-agents), [Slash commands](/tools/slash-commands). diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 828469f6a9a3..f9a50d78249c 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -497,6 +497,81 @@ 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 ed809e4c9be4..228aad4636e4 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -947,45 +947,49 @@ export function buildOpenAIProvider(): ProviderPlugin { order: "simple", run: async (ctx) => { const auth = ctx.resolveProviderAuth(PROVIDER_ID); - try { - const { resolveApiKeyForProvider, resolveProviderAuthProfileMetadata } = - await import("openclaw/plugin-sdk/provider-auth-runtime"); - const runtimeAuth = await resolveApiKeyForProvider({ - provider: PROVIDER_ID, - cfg: ctx.config, - ...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}), - ...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}), - ...(auth.profileId - ? { - profileId: auth.profileId, - lockedProfile: true, - } - : {}), - }); - if (runtimeAuth && isCodexCatalogAuthMode(runtimeAuth.mode) && runtimeAuth.apiKey) { - const metadata = resolveProviderAuthProfileMetadata({ + // 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") { + try { + const { resolveApiKeyForProvider, resolveProviderAuthProfileMetadata } = + await import("openclaw/plugin-sdk/provider-auth-runtime"); + const runtimeAuth = await resolveApiKeyForProvider({ provider: PROVIDER_ID, cfg: ctx.config, ...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}), - ...((runtimeAuth.profileId ?? auth.profileId) - ? { profileId: runtimeAuth.profileId ?? auth.profileId } + ...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}), + ...(auth.profileId + ? { + profileId: auth.profileId, + lockedProfile: true, + } : {}), }); - const catalog = scopeOpenAICatalogOutcome( - await buildOpenAICodexLiveProviderConfig({ - discoveryApiKey: runtimeAuth.apiKey, - accountId: metadata.accountId, - }), - runtimeAuth.profileId ?? auth.profileId, - ); - return { - providers: { [PROVIDER_ID]: catalog.provider }, - ...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}), - }; + if (runtimeAuth && isCodexCatalogAuthMode(runtimeAuth.mode) && runtimeAuth.apiKey) { + const metadata = resolveProviderAuthProfileMetadata({ + provider: PROVIDER_ID, + cfg: ctx.config, + ...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}), + ...((runtimeAuth.profileId ?? auth.profileId) + ? { profileId: runtimeAuth.profileId ?? auth.profileId } + : {}), + }); + const catalog = scopeOpenAICatalogOutcome( + await buildOpenAICodexLiveProviderConfig({ + discoveryApiKey: runtimeAuth.apiKey, + accountId: metadata.accountId, + }), + runtimeAuth.profileId ?? auth.profileId, + ); + return { + providers: { [PROVIDER_ID]: catalog.provider }, + ...(catalog.outcome ? { outcomes: [catalog.outcome] } : {}), + }; + } + } catch { + // OAuth discovery is advisory; fall through so configured API-key + // auth can still publish the standard OpenAI catalog. } - } catch { - // OAuth discovery is advisory; fall through so configured API-key - // auth can still publish the standard OpenAI catalog. } if (auth.mode === "api_key" && auth.apiKey) { const catalog = scopeOpenAICatalogOutcome( @@ -1061,7 +1065,11 @@ export function buildOpenAIProvider(): ProviderPlugin { }, ...responsesHooks, prepareExtraParams: (ctx) => { - const providerConfig = ctx.config?.models?.providers?.[PROVIDER_ID]; + const providerConfig = resolveAuthoredOpenAIConfigRoute({ + provider: ctx.provider, + modelId: ctx.modelId, + config: ctx.config, + })?.configuredProvider; const useCodexTransport = shouldUseCodexResponsesHooks({ provider: ctx.provider, diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 1ac49b305a99..fff8ec407bfa 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -1048,6 +1048,150 @@ describe("openrouter provider hooks", () => { }); }); + 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 over a differently cased key", 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 } }, + }, + }, + }, + }, + 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 trimmed duplicate OpenRouter provider 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 8c14ce30f4c7..92fc72749483 100644 --- a/extensions/openrouter/provider-routing.ts +++ b/extensions/openrouter/provider-routing.ts @@ -1,13 +1,14 @@ // Openrouter provider module implements model/runtime integration. +import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; + +type OpenRouterProviderConfig = { + params?: Record; +}; + type OpenRouterExtraParamsContext = { config?: { models?: { - providers?: Record< - string, - { - params?: Record; - } - >; + providers?: Record; }; }; extraParams: Record; @@ -48,6 +49,56 @@ 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 { + const requestedProvider = ctx.provider.trim(); + const normalizedProvider = normalizeProviderId(requestedProvider); + if (!normalizedProvider) { + return undefined; + } + + const providers = Object.entries(ctx.config?.models?.providers ?? {}); + const exactKey = providers.find(([provider]) => provider.trim() === requestedProvider)?.[0]; + const fallbackKey = providers.find( + ([provider]) => normalizeProviderId(provider) === normalizedProvider, + )?.[0]; + const providerKey = (exactKey ?? fallbackKey)?.trim(); + if (!providerKey) { + return undefined; + } + + // Preserve routing split across normalized duplicates; merge nested params + // field-wise while allowing later scalar settings to override earlier ones. + let matchedParams: Record | undefined; + for (const [provider, config] of providers) { + if (provider.trim() !== providerKey) { + continue; + } + const params = readRecord(config.params); + if (params) { + matchedParams = mergeOpenRouterProviderConfigParams(matchedParams, params); + } + } + return matchedParams; +} + function mergeOpenRouterProviderRouting(params: { providerParams?: Record; modelParams?: Record; @@ -67,7 +118,7 @@ function mergeOpenRouterProviderRouting(params: { export function resolveOpenRouterExtraParamsForTransport( ctx: OpenRouterExtraParamsContext, ): { patch?: Record } | undefined { - const providerConfigParams = readRecord(ctx.config?.models?.providers?.[ctx.provider]?.params); + const providerConfigParams = resolveOpenRouterProviderConfigParams(ctx); const modelParams = readRecord(ctx.model?.params); const providerRouting = mergeOpenRouterProviderRouting({ providerParams: providerConfigParams, diff --git a/src/agents/embedded-agent-runner-extraparams-resolve.test.ts b/src/agents/embedded-agent-runner-extraparams-resolve.test.ts index 0cae193218a0..24c498267be7 100644 --- a/src/agents/embedded-agent-runner-extraparams-resolve.test.ts +++ b/src/agents/embedded-agent-runner-extraparams-resolve.test.ts @@ -2,6 +2,29 @@ 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({ @@ -139,6 +162,110 @@ 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", () => { + const result = resolveExtraParams({ + cfg: { + agents: { + entries: { + audit: { + models: { + "anthropic/claude-sonnet-4-6": { params: { temperature: 0.2 } }, + }, + }, + main: {}, + }, + }, + }, + provider: "anthropic", + modelId: "claude-sonnet-4-6", + agentId: "main", + }); + + expect(result).toBeUndefined(); + }); + + 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 } }, + }, + }, + }, + }, + }, + provider: "anthropic", + modelId: "claude-opus-4-6", + agentId: "audit", + }); + + expect(result).toBeUndefined(); + }); + it("preserves higher-precedence agent parallelToolCalls override across alias styles", () => { // Canonicalization must happen after precedence resolution, or a broad // snake_case value can overwrite the agent's camelCase override. 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 aeac206de0e1..713ccc18dd82 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: [], }, }, - list: [ - { - id: agentId, + entries: { + main: { default: true }, + [agentId]: { model: { fallbacks: ["openai/mock-2"], }, }, - ], + }, }, models: { providers: { diff --git a/src/agents/model-extra-params.ts b/src/agents/model-extra-params.ts index 476429b4ac47..b5773e4e0bd4 100644 --- a/src/agents/model-extra-params.ts +++ b/src/agents/model-extra-params.ts @@ -60,10 +60,17 @@ export function resolveModelExtraParamSources(params: { ? (configuredModels?.[canonicalKey]?.params ?? (legacyKey ? configuredModels?.[legacyKey]?.params : undefined)) : undefined; - const agentParams = - params.agentId && params.config - ? resolveAgentConfig(params.config, params.agentId)?.params - : undefined; + const agentConfig = + params.agentId && params.config ? resolveAgentConfig(params.config, params.agentId) : undefined; + const agentModelParams = canonicalKey + ? (agentConfig?.models?.[canonicalKey]?.params ?? + (legacyKey ? agentConfig?.models?.[legacyKey]?.params : undefined)) + : undefined; + // Model-specific agent settings are narrower than agent-wide settings and + // must stay in the same precedence source for transport alias normalization. + const agentParams = agentModelParams + ? { ...agentConfig?.params, ...agentModelParams } + : agentConfig?.params; return { defaultParams, modelParams, agentParams }; } @@ -72,14 +79,7 @@ export function hasAuthoredProviderRequestParams( params: Parameters[0], ): boolean { const sources = resolveModelExtraParamSources(params); - if ( - [sources.defaultParams, sources.agentParams].some( - (source) => source !== undefined && Object.keys(source).length > 0, - ) - ) { - return true; - } - return Object.entries(sources.modelParams ?? {}).some( - ([key, value]) => !isAgentRuntimeModelParam(key, value), + return [sources.defaultParams, sources.modelParams, sources.agentParams].some((source) => + Object.entries(source ?? {}).some(([key, value]) => !isAgentRuntimeModelParam(key, value)), ); } diff --git a/src/agents/openai-routing.test.ts b/src/agents/openai-routing.test.ts index 387e2dc384b0..ef66701d7135 100644 --- a/src/agents/openai-routing.test.ts +++ b/src/agents/openai-routing.test.ts @@ -10,6 +10,60 @@ import { resolveSelectedOpenAIRuntimeProvider, } 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], + params: Record, +): { config: OpenClawConfig; agentId?: string } { + const modelKey = "openai/gpt-5.6-sol"; + if (scope === "model") { + return { config: { agents: { defaults: { models: { [modelKey]: { params } } } } } }; + } + if (scope === "global") { + return { config: { agents: { defaults: { params } } } }; + } + if (scope === "agent") { + return { config: { agents: { entries: { audit: { params } } } }, agentId: "audit" }; + } + return { + config: { agents: { entries: { audit: { models: { [modelKey]: { params } } } } } }, + agentId: "audit", + }; +} + describe("OpenAI runtime routing policy", () => { beforeEach(() => { vi.stubEnv("OPENAI_BASE_URL", ""); @@ -36,60 +90,62 @@ describe("OpenAI runtime routing policy", () => { ).toBe(true); }); - 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 = { - agents: { - defaults: { - models: { - "openai/gpt-5.6-sol": { - params, - }, - }, - }, - }, - } as OpenClawConfig; + 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); 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 = { - agents: { - defaults: { - models: { - "openai/gpt-5.6-sol": { params }, - }, - }, - }, - } as OpenClawConfig; + 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); 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("maps provider route facts onto a closed implicit runtime", () => { diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 2cc3c98679c9..9cf7d91ab4f8 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -441,7 +441,7 @@ describe("collectCodexRouteWarnings", () => { agents: { defaults: { model: "openai/gpt-5.6-sol", - params: { temperature: 0.7 }, + params: { temperature: 0.7, thinking: "high" }, models: { "openai/gpt-5.6-sol": { params: { @@ -459,7 +459,14 @@ describe("collectCodexRouteWarnings", () => { }, }, entries: { - coder: { params: { topP: 0.8 } }, + coder: { + params: { topP: 0.8, fastMode: "auto", temperature: 0.6 }, + models: { + "openai/gpt-5.6-sol": { + params: { temperature: 0.1, thinking: "medium", topK: 40 }, + }, + }, + }, worker: { models: { "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, @@ -481,6 +488,18 @@ describe("collectCodexRouteWarnings", () => { ); expect(result.warnings.join("\n")).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", + ); + expect(result.warnings.join("\n")).toContain( + "agents.entries.coder.models.openai/gpt-5.6-sol.params.topK", + ); + expect(result.warnings.join("\n")).not.toContain("agents.entries.coder.params.temperature"); + expect(result.warnings.join("\n")).not.toContain("agents.defaults.params.thinking"); + expect(result.warnings.join("\n")).not.toContain("agents.entries.coder.params.fastMode"); + expect(result.warnings.join("\n")).not.toContain( + "agents.entries.coder.models.openai/gpt-5.6-sol.params.thinking", + ); expect(result.warnings.join("\n")).not.toContain("gpt-5.6-openclaw.params.serviceTier"); }); diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index eda372dfff2e..002b58d17657 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -197,8 +197,11 @@ function collectCodexModelParamHits( ): CodexModelParamHit[] { const hits: CodexModelParamHit[] = []; const seen = new Set(); - const agentPaths = new Map( - listMutableCodexRouteAgentEntries(cfg).map(({ agentId, path }) => [agentId, path]), + const agentEntries = new Map( + listMutableCodexRouteAgentEntries(cfg).map(({ agent, agentId, path }) => [ + agentId, + { agent, path }, + ]), ); for (const route of collectCodexRuntimeRouteHits(cfg, env)) { const parsed = parseCodexRouteModelRef(route.canonicalModel); @@ -211,6 +214,11 @@ 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); @@ -221,28 +229,40 @@ function collectCodexModelParamHits( serviceTiers.every((configured) => normalizeString(configured) === "priority") && modelUsesCodexForEveryAgent(cfg, route.canonicalModel); const paramSources = [ - { params: sources.defaultParams, path: "agents.defaults.params", modelScoped: false }, + { + 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: `${agentPaths.get(route.agentId) ?? `agents.entries.${route.agentId}`}.params`, + path: agentEntry?.path ?? `agents.entries.${route.agentId}`, modelScoped: false, + agentModelParams, }, ] : []), ]; for (const source of paramSources) { for (const [key, paramValue] of Object.entries(source.params ?? {})) { - if (source.modelScoped && isAgentRuntimeModelParam(key, paramValue)) { + if (isAgentRuntimeModelParam(key, paramValue)) { continue; } - const path = `${source.path}.${key}`; + 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; }