Skip to content

Commit ff871e1

Browse files
authored
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
1 parent f0a8645 commit ff871e1

8 files changed

Lines changed: 292 additions & 20 deletions

File tree

extensions/microsoft-foundry/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,29 @@ describe("microsoft-foundry plugin", () => {
319319
expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:default"]);
320320
});
321321

322+
it("tolerates timeout-only provider overlays when selecting a Foundry model", async () => {
323+
const provider = registerProvider();
324+
const config = {
325+
models: {
326+
providers: {
327+
"microsoft-foundry": {
328+
timeoutSeconds: 120,
329+
},
330+
},
331+
},
332+
} as unknown as OpenClawConfig;
333+
334+
await provider.onModelSelected?.({
335+
config,
336+
model: "microsoft-foundry/gpt-5.4",
337+
prompter: {} as never,
338+
agentDir: defaultFoundryAgentDir,
339+
});
340+
341+
expect(config.models?.providers?.["microsoft-foundry"]?.models?.[0]?.id).toBe("gpt-5.4");
342+
expect(config.models?.providers?.["microsoft-foundry"]?.timeoutSeconds).toBe(120);
343+
});
344+
322345
it("reports malformed Azure CLI token JSON with an owned error", async () => {
323346
mockAzureCliTokenRaw("{not json");
324347

extensions/microsoft-foundry/provider.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
3030
return;
3131
}
3232
const selectedModelId = ctx.model.slice(`${PROVIDER_ID}/`.length);
33-
const existingModel = providerConfig.models.find(
33+
const configuredModels = providerConfig.models ?? [];
34+
const existingModel = configuredModels.find(
3435
(model: { id: string }) => model.id === selectedModelId,
3536
);
3637
const selectedModelCapabilities = resolveFoundryModelCapabilities(
@@ -45,19 +46,20 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
4546
const selectedModelApi = isFoundryProviderApi(existingModel?.api)
4647
? existingModel.api
4748
: providerConfig.api;
48-
const nextModels = providerConfig.models.map((model) =>
49-
model.id === selectedModelId
50-
? {
51-
...model,
52-
name: selectedModelCapabilities.modelName,
53-
api: selectedModelCapabilities.api,
54-
input: selectedModelCapabilities.input,
55-
...(selectedModelCapabilities.compat
56-
? { compat: selectedModelCapabilities.compat }
57-
: {}),
58-
}
59-
: model,
60-
);
49+
const nextModels = configuredModels.map((model) => {
50+
if (model.id !== selectedModelId) {
51+
return model;
52+
}
53+
const nextModel = Object.assign({}, model, {
54+
name: selectedModelCapabilities.modelName,
55+
api: selectedModelCapabilities.api,
56+
input: selectedModelCapabilities.input,
57+
});
58+
if (selectedModelCapabilities.compat) {
59+
nextModel.compat = selectedModelCapabilities.compat;
60+
}
61+
return nextModel;
62+
});
6163
if (!nextModels.some((model) => model.id === selectedModelId)) {
6264
nextModels.push({
6365
id: selectedModelId,

src/agents/configured-provider-fallback.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,9 @@ export function resolveConfiguredProviderFallback(params: {
3535
return null;
3636
}
3737
const [provider, providerCfg] = availableProvider;
38-
return { provider, model: providerCfg.models[0].id };
38+
const models = providerCfg.models;
39+
if (!Array.isArray(models) || !models[0]?.id) {
40+
return null;
41+
}
42+
return { provider, model: models[0].id };
3943
}

src/agents/pi-embedded-runner/model.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,25 @@ describe("resolveModel", () => {
644644
expect(model.api).toBe("openai-completions");
645645
});
646646

647+
it("does not synthesize unknown models from timeout-only provider overlays", () => {
648+
const cfg = {
649+
models: {
650+
providers: {
651+
openai: {
652+
timeoutSeconds: 300,
653+
baseUrl: "",
654+
models: [],
655+
},
656+
},
657+
},
658+
} as unknown as OpenClawConfig;
659+
660+
const result = resolveModelForTest("openai", "typo-model", "/tmp/agent", cfg);
661+
662+
expect(result.model).toBeUndefined();
663+
expect(result.error).toBe("Unknown model: openai/typo-model");
664+
});
665+
647666
it("defaults baseUrl-only local custom fallback models to chat completions", () => {
648667
const cfg = {
649668
agents: {
@@ -1138,6 +1157,33 @@ describe("resolveModel", () => {
11381157
);
11391158
});
11401159

1160+
it("resolves provider request timeout metadata from built-in provider overlays", () => {
1161+
mockDiscoveredModel(discoverModels, {
1162+
provider: "openai",
1163+
modelId: "gpt-5.5",
1164+
templateModel: {
1165+
...makeModel("gpt-5.5"),
1166+
provider: "openai",
1167+
},
1168+
});
1169+
const cfg = {
1170+
models: {
1171+
providers: {
1172+
openai: {
1173+
timeoutSeconds: 600,
1174+
},
1175+
},
1176+
},
1177+
} as unknown as OpenClawConfig;
1178+
1179+
const result = resolveModelForTest("openai", "gpt-5.5", "/tmp/agent", cfg);
1180+
1181+
expect(result.error).toBeUndefined();
1182+
expect((result.model as { requestTimeoutMs?: number } | undefined)?.requestTimeoutMs).toBe(
1183+
600_000,
1184+
);
1185+
});
1186+
11411187
it("uses provider-level context defaults over discovered metadata", () => {
11421188
mockDiscoveredModel(discoverModels, {
11431189
provider: "ollama",

src/agents/pi-embedded-runner/model.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,21 @@ function findConfiguredProviderModel(
447447
);
448448
}
449449

450+
function hasConfiguredFallbackSurface(params: {
451+
providerConfig: InlineProviderConfig | undefined;
452+
configuredModel: ReturnType<typeof findConfiguredProviderModel>;
453+
modelId: string;
454+
}): boolean {
455+
if (params.modelId.startsWith("mock-")) {
456+
return true;
457+
}
458+
if (params.configuredModel) {
459+
return true;
460+
}
461+
const baseUrl = params.providerConfig?.baseUrl?.trim();
462+
return Boolean(baseUrl);
463+
}
464+
450465
function readModelParams(value: unknown): Record<string, unknown> | undefined {
451466
if (!value || typeof value !== "object" || Array.isArray(value)) {
452467
return undefined;
@@ -893,7 +908,7 @@ function resolveConfiguredFallbackModel(params: {
893908
providerParams: providerConfig?.params,
894909
configuredParams: configuredModel?.params,
895910
});
896-
if (!providerConfig && !modelId.startsWith("mock-")) {
911+
if (!hasConfiguredFallbackSurface({ providerConfig, configuredModel, modelId })) {
897912
return undefined;
898913
}
899914
const fallbackTransport = resolveProviderTransport({

src/config/config-misc.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,60 @@ describe("model provider localService config", () => {
8383

8484
expect(result.success).toBe(true);
8585
});
86+
87+
it("accepts bundled provider timeout overlays without custom provider fields", () => {
88+
const result = validateConfigObjectRaw({
89+
models: {
90+
providers: {
91+
openai: {
92+
timeoutSeconds: 600,
93+
},
94+
},
95+
},
96+
});
97+
98+
expect(result.ok).toBe(true);
99+
});
100+
101+
it("accepts bundled provider alias timeout overlays without custom provider fields", () => {
102+
const result = validateConfigObjectRaw({
103+
models: {
104+
providers: {
105+
"z.ai": {
106+
timeoutSeconds: 600,
107+
},
108+
},
109+
},
110+
});
111+
112+
expect(result.ok).toBe(true);
113+
if (result.ok) {
114+
expect(result.config.models?.providers?.["z.ai"]?.models).toEqual([]);
115+
expect(result.config.models?.providers?.["z.ai"]?.baseUrl).toBe("");
116+
}
117+
});
118+
119+
it("still requires baseUrl and models for custom provider declarations", () => {
120+
const result = validateConfigObjectRaw({
121+
models: {
122+
providers: {
123+
custom: {
124+
timeoutSeconds: 600,
125+
},
126+
},
127+
},
128+
});
129+
130+
expect(result.ok).toBe(false);
131+
if (!result.ok) {
132+
expect(issuePaths(result.issues)).toEqual(
133+
expect.arrayContaining([
134+
"models.providers.custom.baseUrl",
135+
"models.providers.custom.models",
136+
]),
137+
);
138+
}
139+
});
86140
});
87141

88142
describe("$schema key in config (#14998)", () => {

src/config/validation.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { collectConfiguredModelRefs } from "./model-refs.js";
4444
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
4545
import { coerceSecretRef } from "./types.secrets.js";
4646
import { OpenClawSchema } from "./zod-schema.js";
47+
import { isBuiltInModelProviderOverlayId } from "./zod-schema.core.js";
4748

4849
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
4950
const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:";
@@ -75,6 +76,38 @@ function stripDeprecatedValidationKeys(raw: unknown): unknown {
7576
};
7677
}
7778

79+
function materializeBundledModelProviderOverlays(config: OpenClawConfig): OpenClawConfig {
80+
const providers = config.models?.providers;
81+
if (!providers) {
82+
return config;
83+
}
84+
let nextProviders: typeof providers | undefined;
85+
for (const [providerId, providerConfig] of Object.entries(providers)) {
86+
if (
87+
!isBuiltInModelProviderOverlayId(providerId) ||
88+
(providerConfig.baseUrl && Array.isArray(providerConfig.models))
89+
) {
90+
continue;
91+
}
92+
nextProviders ??= { ...providers };
93+
nextProviders[providerId] = {
94+
...providerConfig,
95+
baseUrl: providerConfig.baseUrl ?? "",
96+
models: providerConfig.models ?? [],
97+
};
98+
}
99+
if (!nextProviders) {
100+
return config;
101+
}
102+
return {
103+
...config,
104+
models: {
105+
...config.models,
106+
providers: nextProviders,
107+
},
108+
};
109+
}
110+
78111
function stripPreservedLegacyRootKeysForValidation(
79112
raw: unknown,
80113
keys?: readonly string[],
@@ -765,7 +798,7 @@ export function validateConfigObjectRaw(
765798
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
766799
};
767800
}
768-
const validatedConfig = validated.data as OpenClawConfig;
801+
const validatedConfig = materializeBundledModelProviderOverlays(validated.data as OpenClawConfig);
769802
const channelIssues =
770803
policyIssues.length > 0 || opts?.validateBundledChannels
771804
? collectRawBundledChannelConfigIssues(validatedConfig)

0 commit comments

Comments
 (0)