fix(image): honor generation timeout config

This commit is contained in:
Peter Steinberger
2026-04-25 18:25:13 +01:00
parent 80739731dd
commit 0bbb0eb735
19 changed files with 264 additions and 6 deletions

View File

@@ -478,6 +478,67 @@ describe("createImageGenerateTool", () => {
expect(text).toContain("MEDIA:/tmp/generated-2.png");
});
it("uses configured timeoutMs for image generation and lets calls override it", async () => {
stubImageGenerationProviders();
const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({
provider: "openai",
model: "gpt-image-1",
attempts: [],
ignoredOverrides: [],
images: [
{
buffer: Buffer.from("png-out"),
mimeType: "image/png",
fileName: "cat.png",
},
],
});
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue({
path: "/tmp/generated.png",
id: "generated.png",
size: 7,
contentType: "image/png",
});
const tool = requireImageGenerateTool(
createImageGenerateTool({
config: {
agents: {
defaults: {
imageGenerationModel: {
primary: "openai/gpt-image-1",
timeoutMs: 180_000,
},
},
},
},
}),
);
const defaultResult = await tool.execute("call-timeout-default", {
prompt: "A cat wearing sunglasses",
});
const overrideResult = await tool.execute("call-timeout-override", {
prompt: "A cat wearing sunglasses",
timeoutMs: 12_345,
});
expect(generateImage).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
timeoutMs: 180_000,
}),
);
expect(generateImage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
timeoutMs: 12_345,
}),
);
expect(defaultResult.details).toMatchObject({ timeoutMs: 180_000 });
expect(overrideResult.details).toMatchObject({ timeoutMs: 12_345 });
});
it("forwards output hints and OpenAI provider options", async () => {
const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({
provider: "openai",

View File

@@ -638,7 +638,7 @@ export function createImageGenerateTool(options?: {
const size = readStringParam(params, "size");
const aspectRatio = normalizeAspectRatio(readStringParam(params, "aspectRatio"));
const explicitResolution = normalizeResolution(readStringParam(params, "resolution"));
const timeoutMs = readGenerationTimeoutMs(params);
const timeoutMs = readGenerationTimeoutMs(params) ?? imageGenerationModelConfig.timeoutMs;
const quality = normalizeQuality(readStringParam(params, "quality"));
const outputFormat = normalizeOutputFormat(readStringParam(params, "outputFormat"));
const providerOptions = normalizeProviderOptions(params);

View File

@@ -1,6 +1,7 @@
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
resolveAgentModelTimeoutMsValue,
} from "../../config/model-input.js";
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -13,7 +14,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { resolveEnvApiKey } from "../model-auth.js";
import { resolveConfiguredModelRef } from "../model-selection.js";
export type ToolModelConfig = { primary?: string; fallbacks?: string[] };
export type ToolModelConfig = { primary?: string; fallbacks?: string[]; timeoutMs?: number };
export function hasToolModelConfig(model: ToolModelConfig | undefined): boolean {
return Boolean(
@@ -53,9 +54,11 @@ export function hasAuthForProvider(params: { provider: string; agentDir?: string
export function coerceToolModelConfig(model?: AgentModelConfig): ToolModelConfig {
const primary = resolveAgentModelPrimaryValue(model);
const fallbacks = resolveAgentModelFallbackValues(model);
const timeoutMs = resolveAgentModelTimeoutMsValue(model);
return {
...(primary?.trim() ? { primary: primary.trim() } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
};
}
@@ -94,5 +97,6 @@ export function buildToolModelConfigFromCandidates(params: {
return {
primary: deduped[0],
...(deduped.length > 1 ? { fallbacks: deduped.slice(1) } : {}),
...(params.explicit.timeoutMs !== undefined ? { timeoutMs: params.explicit.timeoutMs } : {}),
};
}

View File

@@ -517,6 +517,42 @@ describe("capability cli", () => {
);
});
it("passes image generation timeout through to runtime", async () => {
mocks.generateImage.mockResolvedValue({
provider: "openai",
model: "gpt-image-1",
attempts: [],
images: [
{
buffer: Buffer.from("png-bytes"),
mimeType: "image/png",
fileName: "provider-output.png",
},
],
});
await runRegisteredCli({
register: registerCapabilityCli as (program: Command) => void,
argv: [
"capability",
"image",
"generate",
"--prompt",
"friendly lobster",
"--timeout-ms",
"180000",
"--json",
],
});
expect(mocks.generateImage).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "friendly lobster",
timeoutMs: 180000,
}),
);
});
it("streams url-only generated videos to --output paths", async () => {
mocks.generateVideo.mockResolvedValue({
provider: "vydra",

View File

@@ -704,6 +704,7 @@ async function runImageGenerate(params: {
resolution?: "1K" | "2K" | "4K";
file?: string[];
output?: string;
timeoutMs?: number;
}) {
const cfg = loadConfig();
const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg));
@@ -727,6 +728,7 @@ async function runImageGenerate(params: {
size: params.size,
aspectRatio: params.aspectRatio,
resolution: params.resolution,
timeoutMs: params.timeoutMs,
inputImages,
});
const outputs = await Promise.all(
@@ -1436,6 +1438,7 @@ export function registerCapabilityCli(program: Command) {
.option("--size <size>", "Size hint like 1024x1024")
.option("--aspect-ratio <ratio>", "Aspect ratio hint like 16:9")
.option("--resolution <value>", "Resolution hint: 1K, 2K, or 4K")
.option("--timeout-ms <ms>", "Provider request timeout in milliseconds")
.option("--output <path>", "Output path")
.option("--json", "Output JSON", false)
.action(async (opts) => {
@@ -1448,6 +1451,7 @@ export function registerCapabilityCli(program: Command) {
size: opts.size as string | undefined,
aspectRatio: opts.aspectRatio as string | undefined,
resolution: opts.resolution as "1K" | "2K" | "4K" | undefined,
timeoutMs: parseOptionalFiniteNumber(opts.timeoutMs, "--timeout-ms"),
output: opts.output as string | undefined,
});
emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText);
@@ -1460,6 +1464,7 @@ export function registerCapabilityCli(program: Command) {
.requiredOption("--file <path>", "Input file", collectOption, [])
.requiredOption("--prompt <text>", "Prompt text")
.option("--model <provider/model>", "Model override")
.option("--timeout-ms <ms>", "Provider request timeout in milliseconds")
.option("--output <path>", "Output path")
.option("--json", "Output JSON", false)
.action(async (opts) => {
@@ -1470,6 +1475,7 @@ export function registerCapabilityCli(program: Command) {
prompt: String(opts.prompt),
model: opts.model as string | undefined,
file: files,
timeoutMs: parseOptionalFiniteNumber(opts.timeoutMs, "--timeout-ms"),
output: opts.output as string | undefined,
});
emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText);

View File

@@ -4,6 +4,7 @@ import type { AgentModelConfig } from "./types.agents-shared.js";
type AgentModelListLike = {
primary?: string;
fallbacks?: string[];
timeoutMs?: number;
};
export function resolveAgentModelPrimaryValue(model?: AgentModelConfig): string | undefined {
@@ -17,6 +18,17 @@ export function resolveAgentModelFallbackValues(model?: AgentModelConfig): strin
return Array.isArray(model.fallbacks) ? model.fallbacks : [];
}
export function resolveAgentModelTimeoutMsValue(model?: AgentModelConfig): number | undefined {
if (!model || typeof model !== "object") {
return undefined;
}
return typeof model.timeoutMs === "number" &&
Number.isFinite(model.timeoutMs) &&
model.timeoutMs > 0
? Math.floor(model.timeoutMs)
: undefined;
}
export function toAgentModelListLike(model?: AgentModelConfig): AgentModelListLike | undefined {
if (typeof model === "string") {
const primary = normalizeOptionalString(model);

View File

@@ -3199,6 +3199,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Ordered fallback models (provider/model). Used when the primary model fails.",
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -3226,6 +3231,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
title: "Image Model Fallbacks",
description: "Ordered fallback image models (provider/model).",
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -3253,6 +3263,14 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
title: "Image Generation Model Fallbacks",
description: "Ordered fallback image-generation models (provider/model).",
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Image Generation Timeout (ms)",
description:
"Default provider request timeout in milliseconds for image_generate calls. Per-call timeoutMs overrides this.",
},
},
additionalProperties: false,
},
@@ -3280,6 +3298,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
title: "Video Generation Model Fallbacks",
description: "Ordered fallback video-generation models (provider/model).",
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -3307,6 +3330,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
title: "Music Generation Model Fallbacks",
description: "Ordered fallback music-generation models (provider/model).",
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -3340,6 +3368,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
title: "PDF Model Fallbacks",
description: "Ordered fallback PDF models (provider/model).",
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -5333,6 +5366,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
type: "string",
},
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -5956,6 +5994,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
type: "string",
},
},
timeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
@@ -26089,6 +26132,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Ordered fallback image-generation models (provider/model).",
tags: ["reliability", "media"],
},
"agents.defaults.imageGenerationModel.timeoutMs": {
label: "Image Generation Timeout (ms)",
help: "Default provider request timeout in milliseconds for image_generate calls. Per-call timeoutMs overrides this.",
tags: ["performance", "media"],
},
"agents.defaults.videoGenerationModel.primary": {
label: "Video Generation Model",
help: "Optional video-generation model (provider/model) used by the shared video generation capability.",

View File

@@ -1209,6 +1209,8 @@ export const FIELD_HELP: Record<string, string> = {
"Optional image-generation model (provider/model) used by the shared image generation capability.",
"agents.defaults.imageGenerationModel.fallbacks":
"Ordered fallback image-generation models (provider/model).",
"agents.defaults.imageGenerationModel.timeoutMs":
"Default provider request timeout in milliseconds for image_generate calls. Per-call timeoutMs overrides this.",
"agents.defaults.videoGenerationModel.primary":
"Optional video-generation model (provider/model) used by the shared video generation capability.",
"agents.defaults.videoGenerationModel.fallbacks":

View File

@@ -549,6 +549,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.imageModel.fallbacks": "Image Model Fallbacks",
"agents.defaults.imageGenerationModel.primary": "Image Generation Model",
"agents.defaults.imageGenerationModel.fallbacks": "Image Generation Model Fallbacks",
"agents.defaults.imageGenerationModel.timeoutMs": "Image Generation Timeout (ms)",
"agents.defaults.videoGenerationModel.primary": "Video Generation Model",
"agents.defaults.videoGenerationModel.fallbacks": "Video Generation Model Fallbacks",
"agents.defaults.musicGenerationModel.primary": "Music Generation Model",

View File

@@ -12,6 +12,8 @@ export type AgentModelConfig =
primary?: string;
/** Per-agent model fallbacks (provider/model). */
fallbacks?: string[];
/** Optional provider request timeout in milliseconds for capabilities that support it. */
timeoutMs?: number;
};
export type AgentEmbeddedHarnessConfig = {

View File

@@ -25,6 +25,28 @@ describe("agent defaults schema", () => {
).not.toThrow();
});
it("accepts imageGenerationModel timeoutMs", () => {
const defaults = AgentDefaultsSchema.parse({
imageGenerationModel: {
primary: "openrouter/openai/gpt-5.4-image-2",
timeoutMs: 180_000,
},
})!;
expect(defaults.imageGenerationModel).toEqual({
primary: "openrouter/openai/gpt-5.4-image-2",
timeoutMs: 180_000,
});
expect(() =>
AgentDefaultsSchema.parse({
imageGenerationModel: {
primary: "openrouter/openai/gpt-5.4-image-2",
timeoutMs: 0,
},
}),
).toThrow();
});
it("accepts mediaGenerationAutoProviderFallback", () => {
expect(() =>
AgentDefaultsSchema.parse({

View File

@@ -6,6 +6,7 @@ export const AgentModelSchema = z.union([
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
timeoutMs: z.number().int().positive().optional(),
})
.strict(),
]);

View File

@@ -80,6 +80,48 @@ describe("image-generation runtime", () => {
expect(result.ignoredOverrides).toEqual([]);
});
it("uses configured image-generation timeout when the call omits timeoutMs", async () => {
let seenTimeoutMs: number | undefined;
mocks.resolveAgentModelPrimaryValue.mockReturnValue("image-plugin/img-v1");
const provider: ImageGenerationProvider = {
id: "image-plugin",
capabilities: {
generate: {},
edit: { enabled: false },
},
async generateImage(req: { timeoutMs?: number }) {
seenTimeoutMs = req.timeoutMs;
return {
images: [
{
buffer: Buffer.from("png-bytes"),
mimeType: "image/png",
fileName: "sample.png",
},
],
model: "img-v1",
};
},
};
mocks.getImageGenerationProvider.mockReturnValue(provider);
await generateImage({
cfg: {
agents: {
defaults: {
imageGenerationModel: {
primary: "image-plugin/img-v1",
timeoutMs: 180_000,
},
},
},
} as OpenClawConfig,
prompt: "draw a cat",
});
expect(seenTimeoutMs).toBe(180_000);
});
it("auto-detects and falls through to another configured image-generation provider by default", async () => {
mocks.getImageGenerationProvider.mockImplementation((providerId: string) => {
if (providerId === "openai") {

View File

@@ -1,5 +1,6 @@
import { describeFailoverError, isFailoverError } from "../agents/failover-error.js";
import type { FallbackAttempt } from "../agents/model-fallback.types.js";
import { resolveAgentModelTimeoutMsValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -34,6 +35,9 @@ export function listRuntimeImageGenerationProviders(params?: { config?: OpenClaw
export async function generateImage(
params: GenerateImageParams,
): Promise<GenerateImageRuntimeResult> {
const timeoutMs =
params.timeoutMs ??
resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.imageGenerationModel);
const candidates = resolveCapabilityModelCandidates({
cfg: params.cfg,
modelConfig: params.cfg.agents?.defaults?.imageGenerationModel,
@@ -89,7 +93,7 @@ export async function generateImage(
quality: sanitized.quality,
outputFormat: sanitized.outputFormat,
inputImages: params.inputImages,
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
providerOptions: params.providerOptions,
});
if (!Array.isArray(result.images) || result.images.length === 0) {