fix(config): allow bundled provider timeout overlays (#83267)

* fix config provider timeout overlays

Allow bundled model provider config entries to act as overlays so fields like timeoutSeconds can be configured without redeclaring baseUrl and models. Keep unknown custom provider declarations strict, and guard configured-provider fallback against overlay entries without models.

* fix(config): include provider aliases in model overlays

* fix(config): guard Foundry timeout overlays

* fix(config): normalize bundled provider overlays

* fix(models): reject overlay-only fallback models
This commit is contained in:
Gio Della-Libera
2026-05-18 21:50:10 -07:00
committed by GitHub
parent f0a86450b1
commit ff871e162a
8 changed files with 292 additions and 20 deletions

View File

@@ -319,6 +319,29 @@ describe("microsoft-foundry plugin", () => {
expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:default"]);
});
it("tolerates timeout-only provider overlays when selecting a Foundry model", async () => {
const provider = registerProvider();
const config = {
models: {
providers: {
"microsoft-foundry": {
timeoutSeconds: 120,
},
},
},
} as unknown as OpenClawConfig;
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/gpt-5.4",
prompter: {} as never,
agentDir: defaultFoundryAgentDir,
});
expect(config.models?.providers?.["microsoft-foundry"]?.models?.[0]?.id).toBe("gpt-5.4");
expect(config.models?.providers?.["microsoft-foundry"]?.timeoutSeconds).toBe(120);
});
it("reports malformed Azure CLI token JSON with an owned error", async () => {
mockAzureCliTokenRaw("{not json");

View File

@@ -30,7 +30,8 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
return;
}
const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length);
const existingModel = providerConfig.models.find(
const configuredModels = providerConfig.models ?? [];
const existingModel = configuredModels.find(
(model: { id: string }) => model.id === selectedModelId,
);
const selectedModelCapabilities = resolveFoundryModelCapabilities(
@@ -45,19 +46,20 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
const selectedModelApi = isFoundryProviderApi(existingModel?.api)
? existingModel.api
: providerConfig.api;
const nextModels = providerConfig.models.map((model) =>
model.id === selectedModelId
? {
...model,
name: selectedModelCapabilities.modelName,
api: selectedModelCapabilities.api,
input: selectedModelCapabilities.input,
...(selectedModelCapabilities.compat
? { compat: selectedModelCapabilities.compat }
: {}),
}
: model,
);
const nextModels = configuredModels.map((model) => {
if (model.id !== selectedModelId) {
return model;
}
const nextModel = Object.assign({}, model, {
name: selectedModelCapabilities.modelName,
api: selectedModelCapabilities.api,
input: selectedModelCapabilities.input,
});
if (selectedModelCapabilities.compat) {
nextModel.compat = selectedModelCapabilities.compat;
}
return nextModel;
});
if (!nextModels.some((model) => model.id === selectedModelId)) {
nextModels.push({
id: selectedModelId,

View File

@@ -35,5 +35,9 @@ export function resolveConfiguredProviderFallback(params: {
return null;
}
const [provider, providerCfg] = availableProvider;
return { provider, model: providerCfg.models[0].id };
const models = providerCfg.models;
if (!Array.isArray(models) || !models[0]?.id) {
return null;
}
return { provider, model: models[0].id };
}

View File

@@ -644,6 +644,25 @@ describe("resolveModel", () => {
expect(model.api).toBe("openai-completions");
});
it("does not synthesize unknown models from timeout-only provider overlays", () => {
const cfg = {
models: {
providers: {
openai: {
timeoutSeconds: 300,
baseUrl: "",
models: [],
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "typo-model", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai/typo-model");
});
it("defaults baseUrl-only local custom fallback models to chat completions", () => {
const cfg = {
agents: {
@@ -1138,6 +1157,33 @@ describe("resolveModel", () => {
);
});
it("resolves provider request timeout metadata from built-in provider overlays", () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",
modelId: "gpt-5.5",
templateModel: {
...makeModel("gpt-5.5"),
provider: "openai",
},
});
const cfg = {
models: {
providers: {
openai: {
timeoutSeconds: 600,
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as { requestTimeoutMs?: number } | undefined)?.requestTimeoutMs).toBe(
600_000,
);
});
it("uses provider-level context defaults over discovered metadata", () => {
mockDiscoveredModel(discoverModels, {
provider: "ollama",

View File

@@ -447,6 +447,21 @@ function findConfiguredProviderModel(
);
}
function hasConfiguredFallbackSurface(params: {
providerConfig: InlineProviderConfig | undefined;
configuredModel: ReturnType<typeof findConfiguredProviderModel>;
modelId: string;
}): boolean {
if (params.modelId.startsWith("mock-")) {
return true;
}
if (params.configuredModel) {
return true;
}
const baseUrl = params.providerConfig?.baseUrl?.trim();
return Boolean(baseUrl);
}
function readModelParams(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@@ -893,7 +908,7 @@ function resolveConfiguredFallbackModel(params: {
providerParams: providerConfig?.params,
configuredParams: configuredModel?.params,
});
if (!providerConfig && !modelId.startsWith("mock-")) {
if (!hasConfiguredFallbackSurface({ providerConfig, configuredModel, modelId })) {
return undefined;
}
const fallbackTransport = resolveProviderTransport({

View File

@@ -83,6 +83,60 @@ describe("model provider localService config", () => {
expect(result.success).toBe(true);
});
it("accepts bundled provider timeout overlays without custom provider fields", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
openai: {
timeoutSeconds: 600,
},
},
},
});
expect(result.ok).toBe(true);
});
it("accepts bundled provider alias timeout overlays without custom provider fields", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
"z.ai": {
timeoutSeconds: 600,
},
},
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.config.models?.providers?.["z.ai"]?.models).toEqual([]);
expect(result.config.models?.providers?.["z.ai"]?.baseUrl).toBe("");
}
});
it("still requires baseUrl and models for custom provider declarations", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
custom: {
timeoutSeconds: 600,
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(issuePaths(result.issues)).toEqual(
expect.arrayContaining([
"models.providers.custom.baseUrl",
"models.providers.custom.models",
]),
);
}
});
});
describe("$schema key in config (#14998)", () => {

View File

@@ -44,6 +44,7 @@ import { collectConfiguredModelRefs } from "./model-refs.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
import { OpenClawSchema } from "./zod-schema.js";
import { isBuiltInModelProviderOverlayId } from "./zod-schema.core.js";
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:";
@@ -75,6 +76,38 @@ function stripDeprecatedValidationKeys(raw: unknown): unknown {
};
}
function materializeBundledModelProviderOverlays(config: OpenClawConfig): OpenClawConfig {
const providers = config.models?.providers;
if (!providers) {
return config;
}
let nextProviders: typeof providers | undefined;
for (const [providerId, providerConfig] of Object.entries(providers)) {
if (
!isBuiltInModelProviderOverlayId(providerId) ||
(providerConfig.baseUrl && Array.isArray(providerConfig.models))
) {
continue;
}
nextProviders ??= { ...providers };
nextProviders[providerId] = {
...providerConfig,
baseUrl: providerConfig.baseUrl ?? "",
models: providerConfig.models ?? [],
};
}
if (!nextProviders) {
return config;
}
return {
...config,
models: {
...config.models,
providers: nextProviders,
},
};
}
function stripPreservedLegacyRootKeysForValidation(
raw: unknown,
keys?: readonly string[],
@@ -765,7 +798,7 @@ export function validateConfigObjectRaw(
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
};
}
const validatedConfig = validated.data as OpenClawConfig;
const validatedConfig = materializeBundledModelProviderOverlays(validated.data as OpenClawConfig);
const channelIssues =
policyIssues.length > 0 || opts?.validateBundledChannels
? collectRawBundledChannelConfigIssues(validatedConfig)

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { z } from "zod";
import { normalizeProviderId } from "../agents/provider-id.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js";
import {
formatExecSecretRefIdValidationMessage,
@@ -374,9 +375,77 @@ const ModelProviderLocalServiceSchema = z
.strict()
.optional();
const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([
"amazon-bedrock",
"amazon-bedrock-mantle",
"anthropic",
"anthropic-vertex",
"arcee",
"byteplus",
"byteplus-plan",
"cerebras",
"chutes",
"cloudflare-ai-gateway",
"codex",
"comfy",
"copilot-proxy",
"dashscope",
"deepinfra",
"deepseek",
"fal",
"fireworks",
"github-copilot",
"google",
"google-antigravity",
"google-gemini-cli",
"google-vertex",
"groq",
"huggingface",
"kilocode",
"kimi",
"kimi-coding",
"litellm",
"lmstudio",
"microsoft-foundry",
"minimax",
"minimax-portal",
"mistral",
"modelstudio",
"moonshot",
"nvidia",
"ollama",
"openai",
"openai-codex",
"opencode",
"opencode-go",
"openrouter",
"qianfan",
"qwen",
"qwencloud",
"sglang",
"stepfun",
"stepfun-plan",
"synthetic",
"tencent-tokenhub",
"together",
"venice",
"vercel-ai-gateway",
"vllm",
"volcengine",
"volcengine-plan",
"vydra",
"xai",
"xiaomi",
"zai",
]);
export function isBuiltInModelProviderOverlayId(providerId: string): boolean {
return BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS.has(normalizeProviderId(providerId));
}
const ModelProviderSchema = z
.object({
baseUrl: z.string().min(1),
baseUrl: z.string().min(1).optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
auth: z
.union([z.literal("api-key"), z.literal("aws-sdk"), z.literal("oauth"), z.literal("token")])
@@ -393,10 +462,36 @@ const ModelProviderSchema = z
headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(),
authHeader: z.boolean().optional(),
request: ConfiguredModelProviderRequestSchema,
models: z.array(ModelDefinitionSchema),
models: z.array(ModelDefinitionSchema).optional(),
})
.strict();
const ModelProvidersSchema = z
.record(z.string(), ModelProviderSchema)
.superRefine((providers, ctx) => {
for (const [providerId, provider] of Object.entries(providers)) {
if (isBuiltInModelProviderOverlayId(providerId)) {
continue;
}
if (!provider.baseUrl) {
ctx.addIssue({
code: "custom",
path: [providerId, "baseUrl"],
message:
"custom model providers must declare baseUrl; provider overlays without baseUrl are only supported for bundled providers",
});
}
if (!Array.isArray(provider.models)) {
ctx.addIssue({
code: "custom",
path: [providerId, "models"],
message:
"custom model providers must declare models; provider overlays without models are only supported for bundled providers",
});
}
}
});
const ModelPricingConfigSchema = z
.object({
enabled: z.boolean().optional(),
@@ -407,7 +502,7 @@ const ModelPricingConfigSchema = z
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: z.record(z.string(), ModelProviderSchema).optional(),
providers: ModelProvidersSchema.optional(),
pricing: ModelPricingConfigSchema,
})
.strict()