mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 05:16:07 +00:00
* fix(fal): route grok-imagine and nano-banana-2-lite edits to correct endpoints
The fal image-generation provider appends '/image-to-image' to any model
that isn't 'openai/gpt-image-*' or 'fal-ai/nano-banana-*' when reference
images are supplied. That's wrong for two models fal serves:
- `xai/grok-imagine-image`: fal 404s on '/image-to-image'. The real edit
endpoint is '/quality/edit'. The endpoint also expects lowercase
resolution values ('1k'/'2k' only) and a distinct aspect_ratio enum.
- `google/nano-banana-2-lite`: fal 404s on '/image-to-image'. The real
edit endpoint is '/edit'. The endpoint does not accept a 'resolution'
parameter.
Add schema entries for both models so ensureFalModelPath and
applyFalImageGeometry pick the right suffix and body shape. Introduce
resolution allowlist support ('resolutions: readonly string[]') and
lowercase transform ('resolutionCase: "lower"') on the schema; existing
schemas keep their behaviour (nb2 still forwards uppercase resolution
unchanged; flux/gpt-image/nb2/krea untouched). Refactor
ensureFalModelPath to consult schema.appendEditPath instead of hardcoded
prefix checks so future models only need a schema entry.
Tested:
- Existing 49 fal unit tests still pass; added 9 new tests covering the
two new endpoints and their guard conditions (32 -> 34 tests in the
image-generation-provider suite).
- Live fal.ai calls confirm both endpoints return 200 with real
reference images; the buggy old URLs still return 404.
* fix(fal): preserve standard edit routing
* fix(image): apply inferred resolution per model
* fix(image): preserve provider reference limits
* fix(image): resolve reference limits per model
* fix(fal): preserve nano banana family limits
* test(ios): stub generated file list helper
---------
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
209 lines
8.2 KiB
TypeScript
209 lines
8.2 KiB
TypeScript
/** Runtime entrypoint for image generation with provider fallback and override normalization. */
|
|
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";
|
|
import {
|
|
buildMediaGenerationNormalizationMetadata,
|
|
buildNoCapabilityModelConfiguredMessage,
|
|
resolveCapabilityModelCandidates,
|
|
resolveMediaProviderRequestTimeoutMs,
|
|
throwCapabilityGenerationFailure,
|
|
} from "../media-generation/runtime-shared.js";
|
|
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
|
|
import { resolveImageGenerationMaxInputImages } from "./capabilities.js";
|
|
import { parseImageGenerationModelRef } from "./model-ref.js";
|
|
import { resolveImageGenerationOverrides } from "./normalization.js";
|
|
import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js";
|
|
import type { GenerateImageParams, GenerateImageRuntimeResult } from "./runtime-types.js";
|
|
import type { ImageGenerationResult } from "./types.js";
|
|
|
|
const log = createSubsystemLogger("image-generation");
|
|
|
|
// Runtime dependency seam for tests and plugin-host callers. Production uses
|
|
// the plugin registry and provider-env helpers by default.
|
|
/** Dependency seam used by image-generation runtime tests and plugin host callers. */
|
|
export type ImageGenerationRuntimeDeps = {
|
|
getProvider?: typeof getImageGenerationProvider;
|
|
listProviders?: typeof listImageGenerationProviders;
|
|
getProviderEnvVars?: typeof getProviderEnvVars;
|
|
log?: Pick<typeof log, "warn">;
|
|
};
|
|
|
|
export type { GenerateImageParams, GenerateImageRuntimeResult } from "./runtime-types.js";
|
|
|
|
function buildNoImageGenerationModelConfiguredMessage(
|
|
cfg: OpenClawConfig,
|
|
deps: ImageGenerationRuntimeDeps,
|
|
): string {
|
|
const listProviders = deps.listProviders ?? listImageGenerationProviders;
|
|
return buildNoCapabilityModelConfiguredMessage({
|
|
capabilityLabel: "image-generation",
|
|
modelConfigKey: "imageGenerationModel",
|
|
providers: listProviders(cfg),
|
|
getProviderEnvVars: deps.getProviderEnvVars,
|
|
});
|
|
}
|
|
|
|
/** Lists image-generation providers visible for the current config. */
|
|
export function listRuntimeImageGenerationProviders(
|
|
params?: { config?: OpenClawConfig },
|
|
deps: ImageGenerationRuntimeDeps = {},
|
|
) {
|
|
return (deps.listProviders ?? listImageGenerationProviders)(params?.config);
|
|
}
|
|
|
|
export async function generateImage(
|
|
params: GenerateImageParams,
|
|
deps: ImageGenerationRuntimeDeps = {},
|
|
): Promise<GenerateImageRuntimeResult> {
|
|
const getProvider = deps.getProvider ?? getImageGenerationProvider;
|
|
const listProviders = deps.listProviders ?? listImageGenerationProviders;
|
|
const logger = deps.log ?? log;
|
|
const requestedTimeoutMs =
|
|
params.timeoutMs ??
|
|
resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.imageGenerationModel);
|
|
const candidates = resolveCapabilityModelCandidates({
|
|
cfg: params.cfg,
|
|
modelConfig: params.cfg.agents?.defaults?.imageGenerationModel,
|
|
modelOverride: params.modelOverride,
|
|
parseModelRef: parseImageGenerationModelRef,
|
|
agentDir: params.agentDir,
|
|
listProviders,
|
|
autoProviderFallback: params.autoProviderFallback,
|
|
});
|
|
if (candidates.length === 0) {
|
|
throw new Error(buildNoImageGenerationModelConfiguredMessage(params.cfg, deps));
|
|
}
|
|
|
|
const attempts: FallbackAttempt[] = [];
|
|
let lastError: unknown;
|
|
|
|
// Try configured/fallback models in order and return the first provider that
|
|
// yields at least one image; failed attempts are preserved for diagnostics.
|
|
for (const candidate of candidates) {
|
|
const provider = getProvider(candidate.provider, params.cfg);
|
|
if (!provider) {
|
|
const error = `No image-generation provider registered for ${candidate.provider}`;
|
|
attempts.push({
|
|
provider: candidate.provider,
|
|
model: candidate.model,
|
|
error,
|
|
});
|
|
lastError = new Error(error);
|
|
logger.warn(
|
|
`image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${error}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const inputImageCount = params.inputImages?.length ?? 0;
|
|
const maxInputImages = resolveImageGenerationMaxInputImages({
|
|
provider,
|
|
model: candidate.model,
|
|
});
|
|
if (maxInputImages !== undefined && inputImageCount > maxInputImages) {
|
|
const error = `${candidate.provider}/${candidate.model} supports at most ${maxInputImages} reference image${maxInputImages === 1 ? "" : "s"}, ${inputImageCount} requested`;
|
|
attempts.push({
|
|
provider: candidate.provider,
|
|
model: candidate.model,
|
|
error,
|
|
});
|
|
lastError = new Error(error);
|
|
logger.warn(`image-generation candidate skipped: ${error}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const timeoutMs = resolveMediaProviderRequestTimeoutMs({
|
|
timeoutMs: requestedTimeoutMs,
|
|
providerDefaultTimeoutMs: provider.defaultTimeoutMs,
|
|
});
|
|
const modelResolutions =
|
|
provider.capabilities.geometry?.resolutionsByModel?.[candidate.model];
|
|
const modeCapabilities = params.inputImages?.length
|
|
? provider.capabilities.edit
|
|
: provider.capabilities.generate;
|
|
const inferredResolution =
|
|
modeCapabilities.supportsResolution === false || modelResolutions?.length === 0
|
|
? undefined
|
|
: params.inferredResolution;
|
|
const sanitized = resolveImageGenerationOverrides({
|
|
provider,
|
|
model: candidate.model,
|
|
size: params.size,
|
|
aspectRatio: params.aspectRatio,
|
|
resolution: params.resolution ?? inferredResolution,
|
|
quality: params.quality,
|
|
outputFormat: params.outputFormat,
|
|
background: params.background,
|
|
inputImages: params.inputImages,
|
|
});
|
|
// Providers receive only supported overrides. Ignored/normalized values
|
|
// are returned to callers so user-facing replies can explain adjustments.
|
|
const result: ImageGenerationResult = await provider.generateImage({
|
|
provider: candidate.provider,
|
|
model: candidate.model,
|
|
prompt: params.prompt,
|
|
cfg: params.cfg,
|
|
agentDir: params.agentDir,
|
|
authStore: params.authStore,
|
|
count: params.count,
|
|
size: sanitized.size,
|
|
aspectRatio: sanitized.aspectRatio,
|
|
resolution: sanitized.resolution,
|
|
quality: sanitized.quality,
|
|
outputFormat: sanitized.outputFormat,
|
|
background: sanitized.background,
|
|
inputImages: params.inputImages,
|
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
providerOptions: params.providerOptions,
|
|
ssrfPolicy: params.ssrfPolicy,
|
|
});
|
|
if (!Array.isArray(result.images) || result.images.length === 0) {
|
|
throw new Error("Image generation provider returned no images.");
|
|
}
|
|
return {
|
|
images: result.images,
|
|
provider: candidate.provider,
|
|
model: result.model ?? candidate.model,
|
|
attempts,
|
|
...(sanitized.resolution ? { appliedResolution: sanitized.resolution } : {}),
|
|
normalization: sanitized.normalization,
|
|
metadata: {
|
|
...result.metadata,
|
|
...buildMediaGenerationNormalizationMetadata({
|
|
normalization: sanitized.normalization,
|
|
requestedSizeForDerivedAspectRatio: params.size,
|
|
}),
|
|
},
|
|
ignoredOverrides: sanitized.ignoredOverrides,
|
|
};
|
|
} catch (err) {
|
|
lastError = err;
|
|
const described = isFailoverError(err) ? describeFailoverError(err) : undefined;
|
|
attempts.push({
|
|
provider: candidate.provider,
|
|
model: candidate.model,
|
|
error: described?.message ?? formatErrorMessage(err),
|
|
reason: described?.reason,
|
|
status: described?.status,
|
|
code: described?.code,
|
|
});
|
|
logger.warn(
|
|
`image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${
|
|
described?.message ?? formatErrorMessage(err)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return throwCapabilityGenerationFailure({
|
|
capabilityLabel: "image generation",
|
|
attempts,
|
|
lastError,
|
|
});
|
|
}
|