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()