fix: preserve provider routing across config scopes

This commit is contained in:
Dallin Romney
2026-08-13 15:42:21 +08:00
parent fd22f7a1c8
commit 6919bf4f3d
11 changed files with 601 additions and 101 deletions
+1 -1
View File
@@ -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).
+75
View File
@@ -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();
+42 -34
View File
@@ -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,
+144
View File
@@ -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",
+58 -7
View File
@@ -1,13 +1,14 @@
// Openrouter provider module implements model/runtime integration.
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
type OpenRouterProviderConfig = {
params?: Record<string, unknown>;
};
type OpenRouterExtraParamsContext = {
config?: {
models?: {
providers?: Record<
string,
{
params?: Record<string, unknown>;
}
>;
providers?: Record<string, OpenRouterProviderConfig>;
};
};
extraParams: Record<string, unknown>;
@@ -48,6 +49,56 @@ 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 {
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<string, unknown> | 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<string, unknown>;
modelParams?: Record<string, unknown>;
@@ -67,7 +118,7 @@ function mergeOpenRouterProviderRouting(params: {
export function resolveOpenRouterExtraParamsForTransport(
ctx: OpenRouterExtraParamsContext,
): { patch?: Record<string, unknown> } | 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,
@@ -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.
@@ -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: {
+13 -13
View File
@@ -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<typeof resolveModelExtraParamSources>[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)),
);
}
+90 -34
View File
@@ -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<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],
params: Record<string, unknown>,
): { 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", () => {
@@ -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");
});
@@ -197,8 +197,11 @@ function collectCodexModelParamHits(
): CodexModelParamHit[] {
const hits: CodexModelParamHit[] = [];
const seen = new Set<string>();
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;
}