Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 16 additions & 28 deletions src/ai-sdk/providers/fal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import pMap from "p-map";
import type { CacheStorage } from "../cache";
import { fileCache } from "../file-cache";
import type { VideoModelV3, VideoModelV3CallOptions } from "../video-model";
import { normalizeProviderInput } from "./model-rules";

interface PendingRequest {
request_id: string;
Expand Down Expand Up @@ -640,35 +641,22 @@ class FalVideoModel implements VideoModelV3 {
if (input.video_size === undefined) {
input.video_size = "auto";
}
} else if (isKlingV3 || isKlingV26) {
// Duration must be string for Kling v2.6+ and O3 (v3)
input.duration = String(duration ?? 5);
} else if (isGrokImagine) {
// Grok Imagine: duration 1-15 seconds (default 6)
input.duration = duration ?? 6;
// Grok Imagine supports resolution: "480p", "720p" (default "720p")
if (!input.resolution) {
input.resolution = "720p";
}
} else if (isSora2) {
// Sora 2: only supports 4, 8, 12, 16, 20 second durations
const allowedDurations = [4, 8, 12, 16, 20];
const d = duration ?? 4;
if (!allowedDurations.includes(d)) {
warnings.push({
type: "other",
message: `Sora 2 only supports durations: ${allowedDurations.join(", ")}s. Got ${d}s, defaulting to 4s.`,
});
input.duration = 4;
} else {
input.duration = d;
}
// Disable video deletion so generated video URLs remain accessible
if (input.delete_video === undefined) {
input.delete_video = false;
}
} else {
input.duration = duration ?? 5;
// Apply model-specific duration normalization via Zod schemas
// (clamp to valid range, round floats, convert type e.g. number → string for Kling v3)
const normalized = normalizeProviderInput(this.modelId, { duration });
input.duration = normalized.duration;

// Model-specific non-duration defaults
if (isGrokImagine) {
if (!input.resolution) {
input.resolution = "720p";
}
} else if (isSora2) {
if (input.delete_video === undefined) {
input.delete_video = false;
}
}
}

if (hasImageInput && files) {
Expand Down
129 changes: 129 additions & 0 deletions src/ai-sdk/providers/model-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Per-model provider input validation schemas.
*
* Each model that has provider-specific input constraints (duration format,
* allowed ranges, type coercion) gets a Zod schema here. The schemas use
* `.transform()` to auto-fix invalid inputs — rounding floats, clamping to
* valid ranges, and converting types (e.g. number → string for Kling v3).
*
* Usage:
* const fixed = normalizeProviderInput("kling-v3", { duration: 2.34 });
* // → { duration: "3" } (rounded to 2, clamped to min 3, stringified)
*
* NOTE: This file is kept in sync with gateway/packages/schemas/src/model-rules.ts.
* When adding new model rules, update both files.
*/

import { z } from "zod";

// ---------------------------------------------------------------------------
// Duration schema builders
// ---------------------------------------------------------------------------

/** Duration as string integer clamped to [min, max]. Accepts number, outputs string. */
function stringIntDuration(min: number, max: number, defaultVal: number) {
return z
.number()
.optional()
.transform((v) =>
String(Math.max(min, Math.min(max, Math.round(v ?? defaultVal)))),
);
}

/** Duration snapped to nearest allowed value. Accepts number, outputs number. */
function enumDuration(allowed: number[], defaultVal: number) {
return z
.number()
.optional()
.transform((v) => {
const raw = v ?? defaultVal;
return allowed.reduce((prev, curr) =>
Math.abs(curr - raw) < Math.abs(prev - raw) ? curr : prev,
);
});
}

/** Duration as integer clamped to [min, max]. Accepts number, outputs number. */
function rangeDuration(min: number, max: number, defaultVal: number) {
return z
.number()
.optional()
.transform((v) =>
Math.max(min, Math.min(max, Math.round(v ?? defaultVal))),
);
}

/** Passthrough duration rounded to integer. */
function intDuration(defaultVal: number) {
return z
.number()
.optional()
.transform((v) => Math.round(v ?? defaultVal));
}

// ---------------------------------------------------------------------------
// Per-model provider input schemas
// ---------------------------------------------------------------------------

const ModelDurationRules: Record<string, z.ZodType> = {
// Kling O3 (v3): fal expects string integer "3"–"15"
"kling-v3": z.object({ duration: stringIntDuration(3, 15, 5) }),
"kling-v3-standard": z.object({ duration: stringIntDuration(3, 15, 5) }),

// Kling v2.6: same rules as v3
"kling-v2.6": z.object({ duration: stringIntDuration(3, 15, 5) }),

// Kling legacy: exactly 5 or 10
"kling-v2.5": z.object({ duration: enumDuration([5, 10], 5) }),
"kling-v2.1": z.object({ duration: enumDuration([5, 10], 5) }),
"kling-v2": z.object({ duration: enumDuration([5, 10], 5) }),

// Wan: 5 or 10
"wan-2.5": z.object({ duration: enumDuration([5, 10], 5) }),
"wan-2.5-preview": z.object({ duration: enumDuration([5, 10], 5) }),

// Minimax: round to integer
minimax: z.object({ duration: intDuration(5) }),

// Grok Imagine: integer 1–15
"grok-imagine": z.object({ duration: rangeDuration(1, 15, 6) }),

// Sora 2: only 4, 8, 12, 16, 20
"sora-2": z.object({ duration: enumDuration([4, 8, 12, 16, 20], 4) }),
"sora-2-pro": z.object({ duration: enumDuration([4, 8, 12, 16, 20], 4) }),

// Seedance (piapi): 5, 10, or 15
"seedance-2-preview": z.object({ duration: enumDuration([5, 10, 15], 5) }),
"seedance-2-fast-preview": z.object({
duration: enumDuration([5, 10, 15], 5),
}),
};

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Normalize provider input for a given model.
*
* Validates and transforms fields (currently `duration`) to match what the
* provider API expects — correct type, clamped to valid range, rounded to
* integer.
*
* - Unknown models: input returned as-is (passthrough).
* - Parse failures: input returned as-is (defensive — never throws).
*/
export function normalizeProviderInput(
model: string,
input: Record<string, unknown>,
): Record<string, unknown> {
const schema = ModelDurationRules[model];
if (!schema) return input;

const result = schema.safeParse({ duration: input.duration });
if (!result.success) return input;

return { ...input, ...(result.data as Record<string, unknown>) };
}
Comment on lines +116 to +127
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

silent normalization loses observability from previous implementation

the old openai.ts and fal.ts code pushed warnings when duration was invalid or clamped (e.g., "Duration ${duration}s not supported. Using ${validSeconds}s"). this new approach silently transforms values without any indication to the caller.

if someone passes duration: 2.5 to kling-v3, it silently becomes "3" with no warning. might want to consider returning warnings alongside the normalized input for cases where values were adjusted.

💡 possible approach to add observability
+interface NormalizeResult {
+  input: Record<string, unknown>;
+  warnings: string[];
+}
+
 export function normalizeProviderInput(
   model: string,
   input: Record<string, unknown>,
-): Record<string, unknown> {
+): NormalizeResult {
   const schema = ModelInputSchemas[model];
-  if (!schema) return input;
+  if (!schema) return { input, warnings: [] };

   const result = schema.safeParse({ duration: input.duration });
-  if (!result.success) return input;
+  if (!result.success) return { input, warnings: [] };

-  return { ...input, ...(result.data as Record<string, unknown>) };
+  const normalized = { ...input, ...(result.data as Record<string, unknown>) };
+  const warnings: string[] = [];
+  if (input.duration !== undefined && normalized.duration !== input.duration) {
+    warnings.push(`Duration ${input.duration} normalized to ${normalized.duration} for ${model}`);
+  }
+  return { input: normalized, warnings };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai-sdk/providers/model-rules.ts` around lines 116 - 127,
normalizeProviderInput currently silently applies schema coercions via
ModelInputSchemas and schema.safeParse, losing the previous warning behavior;
update normalizeProviderInput to detect when the parsed/normalized value differs
from the original (e.g., compare input.duration to result.data.duration) and
return the normalized input together with a warnings array (or attach a warnings
property) describing adjustments (for example: "Duration Xs not supported. Using
Ys"); ensure the function signature and callers that consume
normalizeProviderInput are updated to accept { input: Record<string, unknown>,
warnings?: string[] } (or similar) and emit a warning only when the schema
clamps or coerces values so callers regain observability of changes.


export { ModelDurationRules };
Loading