fix(providers): stabilize runtime normalization hooks

This commit is contained in:
Peter Steinberger
2026-04-04 19:33:30 +01:00
parent e06e36d41a
commit ca200eb480
19 changed files with 196 additions and 126 deletions

View File

@@ -357,6 +357,7 @@ export function getSoonestCooldownExpiry(
): number | null {
const ts = options?.now ?? Date.now();
let soonest: number | null = null;
let latestMatchingModelCooldown: number | null = null;
for (const id of profileIds) {
const stats = store.usageStats?.[id];
if (!stats) {
@@ -369,11 +370,27 @@ export function getSoonestCooldownExpiry(
if (typeof until !== "number" || !Number.isFinite(until) || until <= 0) {
continue;
}
const matchingModelScopedCooldown =
options?.forModel &&
stats.cooldownReason === "rate_limit" &&
stats.cooldownModel === options.forModel &&
!isActiveUnusableWindow(stats.disabledUntil, ts);
if (matchingModelScopedCooldown) {
latestMatchingModelCooldown =
latestMatchingModelCooldown === null ? until : Math.max(latestMatchingModelCooldown, until);
continue;
}
if (soonest === null || until < soonest) {
soonest = until;
}
}
return soonest;
if (soonest === null) {
return latestMatchingModelCooldown;
}
if (latestMatchingModelCooldown === null) {
return soonest;
}
return Math.min(soonest, latestMatchingModelCooldown);
}
function shouldBypassModelScopedCooldown(

View File

@@ -8,12 +8,20 @@ export type ProviderModelRef = {
export function resolveConfiguredProviderFallback(params: {
cfg: Pick<OpenClawConfig, "models">;
defaultProvider: string;
defaultModel?: string;
}): ProviderModelRef | null {
const configuredProviders = params.cfg.models?.providers;
if (!configuredProviders || typeof configuredProviders !== "object") {
return null;
}
if (configuredProviders[params.defaultProvider]) {
const defaultProviderConfig = configuredProviders[params.defaultProvider];
const defaultModel = params.defaultModel?.trim();
const defaultProviderHasDefaultModel =
!!defaultProviderConfig &&
!!defaultModel &&
Array.isArray(defaultProviderConfig.models) &&
defaultProviderConfig.models.some((model) => model?.id === defaultModel);
if (defaultProviderConfig && (!defaultModel || defaultProviderHasDefaultModel)) {
return null;
}
const availableProvider = Object.entries(configuredProviders).find(

View File

@@ -404,6 +404,7 @@ export function resolveConfiguredModelRef(params: {
const fallbackProvider = resolveConfiguredProviderFallback({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
});
if (fallbackProvider) {
return fallbackProvider;

View File

@@ -1,7 +1,11 @@
import { afterEach, beforeEach, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js";
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import { resetModelsJsonReadyCacheForTest } from "./models-config.js";
import { resolveImplicitProviders } from "./models-config.providers.implicit.js";
export function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@@ -14,10 +18,16 @@ export function installModelsConfigTestHooks(opts?: { restoreFetch?: boolean })
beforeEach(() => {
previousHome = process.env.HOME;
resetPluginLoaderTestStateForTest();
resetModelsJsonReadyCacheForTest();
resetProviderRuntimeHookCacheForTest();
});
afterEach(() => {
process.env.HOME = previousHome;
resetPluginLoaderTestStateForTest();
resetModelsJsonReadyCacheForTest();
resetProviderRuntimeHookCacheForTest();
if (opts?.restoreFetch && originalFetch) {
globalThis.fetch = originalFetch;
}
@@ -103,6 +113,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"OPENROUTER_API_KEY",
"PI_CODING_AGENT_DIR",
"QIANFAN_API_KEY",
"QWEN_API_KEY",
"MODELSTUDIO_API_KEY",
"SYNTHETIC_API_KEY",
"STEPFUN_API_KEY",
@@ -113,6 +124,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"KIMI_API_KEY",
"KIMICODE_API_KEY",
"GEMINI_API_KEY",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_CLOUD_PROJECT",
@@ -146,6 +158,12 @@ export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.Pro
}
}
// Provider discovery tests can temporarily scrub VITEST/NODE_ENV to exercise
// live HTTP paths. Keep the bundled plugin root pinned to the source checkout
// so those tests do not fall back to potentially stale dist-runtime wrappers.
snapshot.OPENCLAW_BUNDLED_PLUGINS_DIR ??=
resolveBundledPluginsDir({ VITEST: "true" } as NodeJS.ProcessEnv) ?? undefined;
return snapshot;
}

View File

@@ -1,9 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
function createGoogleModelsConfig(models: ModelDefinitionConfig[]): OpenClawConfig {
return {
@@ -20,18 +21,19 @@ function createGoogleModelsConfig(models: ModelDefinitionConfig[]): OpenClawConf
};
}
async function readGeneratedProvider(providerKey: string) {
const parsed = await readGeneratedModelsJson<{
async function readGeneratedProvider(agentDir: string, providerKey: string) {
const parsed = JSON.parse(await fs.readFile(path.join(agentDir, "models.json"), "utf8")) as {
providers: Record<string, { baseUrl?: string; models: Array<{ id: string }> }>;
}>();
};
return parsed.providers[providerKey];
}
async function expectGeneratedProvider(
agentDir: string,
providerKey: string,
params: { ids: string[]; baseUrl?: string },
) {
const provider = await readGeneratedProvider(providerKey);
const provider = await readGeneratedProvider(agentDir, providerKey);
expect(provider?.models?.map((model) => model.id)).toEqual(params.ids);
if (params.baseUrl !== undefined) {
expect(provider?.baseUrl).toBe(params.baseUrl);
@@ -66,8 +68,8 @@ describe("models-config", () => {
},
]);
await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider("google", {
const { agentDir } = await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider(agentDir, "google", {
ids: ["gemini-3-pro-preview", "gemini-3-flash-preview"],
});
});
@@ -88,8 +90,8 @@ describe("models-config", () => {
},
]);
await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider("google", {
const { agentDir } = await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider(agentDir, "google", {
ids: ["gemini-3-flash-preview"],
});
});
@@ -121,8 +123,8 @@ describe("models-config", () => {
},
} satisfies OpenClawConfig;
await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider("google-paid", {
const { agentDir } = await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider(agentDir, "google-paid", {
ids: ["gemini-3-pro-preview"],
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
});
@@ -154,8 +156,8 @@ describe("models-config", () => {
},
} satisfies OpenClawConfig;
await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider("google", {
const { agentDir } = await ensureOpenClawModelsJson(cfg);
await expectGeneratedProvider(agentDir, "google", {
ids: ["gemini-3-flash-preview"],
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
});

View File

@@ -1,12 +1,8 @@
import { beforeAll, describe, expect, it } from "vitest";
let normalizeProviderSpecificConfig: typeof import("./models-config.providers.policy.js").normalizeProviderSpecificConfig;
let resolveProviderConfigApiKeyResolver: typeof import("./models-config.providers.policy.js").resolveProviderConfigApiKeyResolver;
beforeAll(async () => {
({ normalizeProviderSpecificConfig, resolveProviderConfigApiKeyResolver } =
await import("./models-config.providers.policy.js"));
});
import { describe, expect, it } from "vitest";
import {
normalizeProviderSpecificConfig,
resolveProviderConfigApiKeyResolver,
} from "./models-config.providers.policy.js";
describe("models-config.providers.policy", () => {
it("resolves config apiKey markers through provider plugin hooks", async () => {

View File

@@ -1,3 +1,8 @@
import { resolveBedrockConfigApiKey } from "../plugin-sdk/amazon-bedrock.js";
import {
normalizeGoogleProviderConfig,
shouldNormalizeGoogleProviderConfig,
} from "../plugin-sdk/google.js";
import {
applyProviderNativeStreamingUsageCompatWithPlugin,
normalizeProviderConfigWithPlugin,
@@ -32,20 +37,32 @@ export function normalizeProviderSpecificConfig(
providerKey: string,
provider: ProviderConfig,
): ProviderConfig {
return (
const normalized =
normalizeProviderConfigWithPlugin({
provider: providerKey,
context: {
provider: providerKey,
providerConfig: provider,
},
}) ?? provider
);
}) ?? undefined;
if (normalized) {
return normalized;
}
if (shouldNormalizeGoogleProviderConfig(providerKey, provider)) {
return normalizeGoogleProviderConfig(providerKey, provider);
}
return provider;
}
export function resolveProviderConfigApiKeyResolver(
providerKey: string,
): ((env: NodeJS.ProcessEnv) => string | undefined) | undefined {
if (providerKey.trim() === "amazon-bedrock") {
return (env) => {
const resolved = resolveBedrockConfigApiKey(env);
return resolved?.trim() || undefined;
};
}
if (!resolveProviderRuntimePlugin({ provider: providerKey })?.resolveConfigApiKey) {
return undefined;
}

View File

@@ -789,9 +789,7 @@ describe("applyExtraParamsToAgent", () => {
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]).toEqual({
reasoning: { effort: "none", summary: "auto" },
});
expect(payloads[0]).not.toHaveProperty("reasoning");
});
it("injects parallel_tool_calls for openai-completions payloads when configured", () => {

View File

@@ -3,7 +3,7 @@ import {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
resolveTelegramCustomCommands,
} from "../../extensions/telegram/api.js";
} from "../../extensions/telegram/config-api.js";
import { isSafeScpRemoteHost } from "../infra/scp-host.js";
import { isValidInboundPathRootPattern } from "../media/inbound-path-policy.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";

View File

@@ -412,7 +412,25 @@ export function normalizeProviderConfigWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderNormalizeConfigContext;
}): ModelProviderConfig | undefined {
return resolveProviderHookPlugin(params)?.normalizeConfig?.(params.context) ?? undefined;
const hasConfigChange = (normalized: ModelProviderConfig) =>
normalized !== params.context.providerConfig;
const matchedPlugin = resolveProviderHookPlugin(params);
const normalizedMatched = matchedPlugin?.normalizeConfig?.(params.context);
if (normalizedMatched && hasConfigChange(normalizedMatched)) {
return normalizedMatched;
}
for (const candidate of resolveProviderPluginsForHooks(params)) {
if (!candidate.normalizeConfig || candidate === matchedPlugin) {
continue;
}
const normalized = candidate.normalizeConfig(params.context);
if (normalized && hasConfigChange(normalized)) {
return normalized;
}
}
return undefined;
}
export function applyProviderNativeStreamingUsageCompatWithPlugin(params: {