mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix: preserve provider routing across config scopes
This commit is contained in:
+1
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-4
@@ -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: {
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user