From ff871e162aafc0ff2e52c17b37fb4ba257601075 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Mon, 18 May 2026 21:50:10 -0700 Subject: [PATCH] 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 --- extensions/microsoft-foundry/index.test.ts | 23 +++++ extensions/microsoft-foundry/provider.ts | 30 +++--- src/agents/configured-provider-fallback.ts | 6 +- src/agents/pi-embedded-runner/model.test.ts | 46 +++++++++ src/agents/pi-embedded-runner/model.ts | 17 +++- src/config/config-misc.test.ts | 54 +++++++++++ src/config/validation.ts | 35 ++++++- src/config/zod-schema.core.ts | 101 +++++++++++++++++++- 8 files changed, 292 insertions(+), 20 deletions(-) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index d7e632e4d157..3d5e0ca6e737 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -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"); diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts index fbffb37f0d72..31a27f4a2534 100644 --- a/extensions/microsoft-foundry/provider.ts +++ b/extensions/microsoft-foundry/provider.ts @@ -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, diff --git a/src/agents/configured-provider-fallback.ts b/src/agents/configured-provider-fallback.ts index 04dd32c4443d..96c8d5f4934c 100644 --- a/src/agents/configured-provider-fallback.ts +++ b/src/agents/configured-provider-fallback.ts @@ -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 }; } diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 55c375c3f342..17eb226e096c 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -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", diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index a9239d832d38..3e2ea2a0c0e1 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -447,6 +447,21 @@ function findConfiguredProviderModel( ); } +function hasConfiguredFallbackSurface(params: { + providerConfig: InlineProviderConfig | undefined; + configuredModel: ReturnType; + 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 | 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({ diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index dc7c4717814c..5f0152b39ac1 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -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)", () => { diff --git a/src/config/validation.ts b/src/config/validation.ts index 2a7144d6fc25..d79f4742b803 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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) diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index c4484de78afe..f874d0216e65 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -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()