From bc9e601491ba8b0d6abbb0fbbd59fd0be454a70f Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 21 May 2026 17:10:32 -0700 Subject: [PATCH] fix: allow provider timeout overlays (#83990) * fix: allow provider timeout overlays * test: fix provider overlay fixture types --- extensions/microsoft-foundry/index.test.ts | 4 +- extensions/microsoft-foundry/provider.ts | 7 +- src/agents/pi-embedded-runner/model.test.ts | 61 +++++++++++++++-- src/config/config-misc.test.ts | 76 +++++++++++++++++++++ src/config/schema.help.ts | 4 +- src/config/types.models.ts | 10 +++ src/config/types.openclaw.ts | 6 +- src/plugin-sdk/provider-model-shared.ts | 5 +- 8 files changed, 162 insertions(+), 11 deletions(-) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index 3d5e0ca6e73..f5eecdf3d2e 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -325,6 +325,8 @@ describe("microsoft-foundry plugin", () => { models: { providers: { "microsoft-foundry": { + baseUrl: "", + models: [], timeoutSeconds: 120, }, }, @@ -338,7 +340,7 @@ describe("microsoft-foundry plugin", () => { agentDir: defaultFoundryAgentDir, }); - expect(config.models?.providers?.["microsoft-foundry"]?.models?.[0]?.id).toBe("gpt-5.4"); + expect(config.models?.providers?.["microsoft-foundry"]?.models).toEqual([]); expect(config.models?.providers?.["microsoft-foundry"]?.timeoutSeconds).toBe(120); }); diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts index 31a27f4a253..b2ea77f5e3f 100644 --- a/extensions/microsoft-foundry/provider.ts +++ b/extensions/microsoft-foundry/provider.ts @@ -26,7 +26,12 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { auth: [entraIdAuthMethod, apiKeyAuthMethod], onModelSelected: async (ctx) => { const providerConfig = ctx.config.models?.providers?.[PROVIDER_ID]; - if (!providerConfig || !ctx.model.startsWith(`${PROVIDER_ID}/`)) { + if ( + !providerConfig || + !providerConfig.baseUrl?.trim() || + !Array.isArray(providerConfig.models) || + !ctx.model.startsWith(`${PROVIDER_ID}/`) + ) { return; } const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length); diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 17eb226e096..0c8959bc5d8 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -146,7 +146,7 @@ vi.mock("./openrouter-model-capabilities.js", () => ({ mockLoadOpenRouterModelCapabilities(modelId), })); -import type { OpenClawConfig } from "../../config/config.js"; +import type { OpenClawConfig, OpenClawConfigInput } from "../../config/config.js"; import { COPILOT_INTEGRATION_ID, buildCopilotIdeHeaders } from "../copilot-dynamic-headers.js"; import { getModelProviderLocalService } from "../provider-local-service.js"; import { getModelProviderRequestTransport } from "../provider-request-config.js"; @@ -593,10 +593,11 @@ describe("resolveModel", () => { }); it("defaults missing model cost before handing models to PI", () => { - const cfg = { + const cfg: OpenClawConfig = { models: { providers: { openai: { + baseUrl: "", api: "openai-responses", models: [ { @@ -605,6 +606,7 @@ describe("resolveModel", () => { api: "openai-responses", reasoning: true, input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 400_000, maxTokens: 128_000, }, @@ -612,7 +614,7 @@ describe("resolveModel", () => { }, }, }, - } as unknown as OpenClawConfig; + }; const result = resolveModelForTest("openai", "gpt-5.5", "/tmp/agent", cfg); @@ -663,6 +665,50 @@ describe("resolveModel", () => { expect(result.error).toBe("Unknown model: openai/typo-model"); }); + it("does not create fallback models from provider overlays alone", () => { + const cfg = { + models: { + providers: { + typoProvider: { + timeoutSeconds: 600, + }, + }, + }, + } satisfies OpenClawConfigInput; + + const result = resolveModelForTest( + "typoProvider", + "typoed-model", + "/tmp/agent", + cfg as unknown as OpenClawConfig, + ); + + expect(result.model).toBeUndefined(); + expect(result.error).toBe("Unknown model: typoProvider/typoed-model"); + }); + + it("does not create fallback models from built-in provider api overlays", () => { + const cfg = { + models: { + providers: { + openai: { + api: "openai-responses", + }, + }, + }, + } satisfies OpenClawConfigInput; + + const result = resolveModelForTest( + "openai", + "typoed-model", + "/tmp/agent", + cfg as unknown as OpenClawConfig, + ); + + expect(result.model).toBeUndefined(); + expect(result.error).toBe("Unknown model: openai/typoed-model"); + }); + it("defaults baseUrl-only local custom fallback models to chat completions", () => { const cfg = { agents: { @@ -1174,9 +1220,14 @@ describe("resolveModel", () => { }, }, }, - } as unknown as OpenClawConfig; + } satisfies OpenClawConfigInput; - const result = resolveModelForTest("openai", "gpt-5.5", "/tmp/agent", cfg); + const result = resolveModelForTest( + "openai", + "gpt-5.5", + "/tmp/agent", + cfg as unknown as OpenClawConfig, + ); expect(result.error).toBeUndefined(); expect((result.model as { requestTimeoutMs?: number } | undefined)?.requestTimeoutMs).toBe( diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 5f0152b39ac..60d047e143c 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -59,6 +59,82 @@ describe("boolean config validation", () => { }); describe("model provider localService config", () => { + it("accepts standalone timeout overlays for bundled model providers", () => { + const result = OpenClawSchema.safeParse({ + models: { + providers: { + openai: { + timeoutSeconds: 600, + }, + }, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.models?.providers?.openai?.timeoutSeconds).toBe(600); + } + }); + + it("rejects standalone timeout overlays for unknown model providers", () => { + const result = OpenClawSchema.safeParse({ + models: { + providers: { + anyManifestProvider: { + timeoutSeconds: 600, + }, + }, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((issue) => issue.path.join(".")); + expect(paths).toEqual( + expect.arrayContaining([ + "models.providers.anyManifestProvider.baseUrl", + "models.providers.anyManifestProvider.models", + ]), + ); + } + }); + + it("requires models when a model provider declaration sets baseUrl", () => { + const result = OpenClawSchema.safeParse({ + models: { + providers: { + custom: { + baseUrl: "https://example.test/v1", + }, + }, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((issue) => issue.path.join(".")); + expect(paths).toContain("models.providers.custom.models"); + } + }); + + it("requires baseUrl when a model provider declaration sets models", () => { + const result = OpenClawSchema.safeParse({ + models: { + providers: { + custom: { + models: [{ id: "custom-model", name: "Custom model", api: "openai-completions" }], + }, + }, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((issue) => issue.path.join(".")); + expect(paths).toContain("models.providers.custom.baseUrl"); + } + }); + it("accepts on-demand local provider service settings", () => { const result = OpenClawSchema.safeParse({ models: { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 1295c6d13a8..ed348a1114d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -932,7 +932,7 @@ export const FIELD_HELP: Record = { "models.mode": 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', "models.providers": - "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", + "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Built-in providers may be tuned with provider-level overlays; custom providers must include baseUrl and models. Use stable provider keys so references from agents and tooling remain portable across environments.", "models.pricing": "Controls the optional background model-pricing bootstrap that fetches remote per-token cost catalogs.", "models.pricing.enabled": @@ -952,7 +952,7 @@ export const FIELD_HELP: Record = { "models.providers.*.maxTokens": "Default maximum output token budget applied to models under this provider when a model entry does not set maxTokens.", "models.providers.*.timeoutSeconds": - "Optional per-provider model request timeout in seconds. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.", + "Optional per-provider model request timeout in seconds. For built-in providers, this can be set as a standalone overlay. For custom providers, set it alongside the provider baseUrl and models. Applies to provider HTTP fetches, including connect, headers, body, and total request abort handling, and also raises the LLM idle/stream watchdog ceiling for this provider above the implicit ~120s default. Use this for slow local or self-hosted model servers, or for cloud providers that buffer reasoning tokens silently on the wire (Gemini preview, large-tool-payload Claude/Opus), instead of changing global agent timeouts.", "models.providers.*.injectNumCtxForOpenAICompat": "Controls whether OpenClaw injects `options.num_ctx` for Ollama providers configured with the OpenAI-compatible adapter (`openai-completions`). Default is true. Set false only if your proxy/upstream rejects unknown `options` payload fields.", "models.providers.*.params": diff --git a/src/config/types.models.ts b/src/config/types.models.ts index e4f6dc19006..e449244af85 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -165,6 +165,12 @@ export type ModelProviderConfig = { models: ModelDefinitionConfig[]; }; +export type ModelProviderDeclarationConfig = ModelProviderConfig; + +export type ModelProviderConfigInput = Omit, "models"> & { + models?: ModelDefinitionConfig[]; +}; + export type BedrockDiscoveryConfig = { enabled?: boolean; region?: string; @@ -207,3 +213,7 @@ export type ModelsConfig = { */ ollamaDiscovery?: DiscoveryToggleConfig; }; + +export type ModelsConfigInput = Omit & { + providers?: Record; +}; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 9580f94c3df..304bf38e84d 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -21,7 +21,7 @@ import type { CommandsConfig, MessagesConfig, } from "./types.messages.js"; -import type { ModelsConfig } from "./types.models.js"; +import type { ModelsConfig, ModelsConfigInput } from "./types.models.js"; import type { NodeHostConfig } from "./types.node-host.js"; import type { PluginsConfig } from "./types.plugins.js"; import type { SecretsConfig } from "./types.secrets.js"; @@ -153,6 +153,10 @@ export type OpenClawConfig = { proxy?: ProxyConfig; }; +export type OpenClawConfigInput = Omit & { + models?: ModelsConfigInput; +}; + declare const openClawConfigStateBrand: unique symbol; type BrandedConfigState = OpenClawConfig & { diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index 308c7a085c4..f6212395563 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -26,7 +26,10 @@ import { normalizeGooglePreviewModelId, } from "./provider-model-id-normalize.js"; -export type { ModelApi, ModelProviderConfig } from "../config/types.models.js"; +export type { + ModelApi, + ModelProviderDeclarationConfig as ModelProviderConfig, +} from "../config/types.models.js"; export type { UnifiedModelCatalogEntry, UnifiedModelCatalogKind,