mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(image): honor generation timeout config
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user