@nodaro/shared 2.20.0 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +458 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +368 -8
- package/dist/index.d.ts +368 -8
- package/dist/index.js +447 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/safety-retry-policy.test.ts +62 -0
- package/src/__tests__/topaz-upscale.test.ts +132 -0
- package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
- package/src/__tests__/video-analysis.test.ts +83 -0
- package/src/__tests__/video-audio-capability.test.ts +38 -4
- package/src/__tests__/video-catalog-totality.test.ts +85 -0
- package/src/__tests__/video-collapse-parity.test.ts +76 -0
- package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
- package/src/__tests__/video-request-normalize.test.ts +239 -0
- package/src/credit-identifiers.ts +115 -8
- package/src/index.ts +23 -3
- package/src/model-catalog.ts +288 -6
- package/src/model-constants.ts +116 -4
- package/src/node-refs.ts +82 -0
- package/src/node-runtime-keys.ts +13 -0
- package/src/safety-retry-policy.ts +37 -0
- package/src/topaz-upscale.ts +163 -0
- package/src/video-analysis.ts +70 -5
package/src/model-catalog.ts
CHANGED
|
@@ -133,6 +133,24 @@ export interface ModelCatalogEntry {
|
|
|
133
133
|
features?: readonly string[]
|
|
134
134
|
aspectRatios?: readonly string[]
|
|
135
135
|
resolutions?: readonly string[]
|
|
136
|
+
/**
|
|
137
|
+
* What this model actually RENDERS for a resolution outside `resolutions`.
|
|
138
|
+
*
|
|
139
|
+
* Most providers honour whatever band they are sent, so an off-list value is
|
|
140
|
+
* best snapped to the NEAREST supported one — a 4k request on a 1080p-max
|
|
141
|
+
* model renders 1080p. A few instead COLLAPSE anything unrecognised to a
|
|
142
|
+
* fixed default (MiniMax H3 → 2K, Wan 3.0 → 720p), and for those the nearest
|
|
143
|
+
* band is the wrong answer in the most expensive direction: snapping a stale
|
|
144
|
+
* "720p" to H3's cheap 768P would send a value the provider ignores, so we
|
|
145
|
+
* would bill the 768P tier against a 2K render, and `commit_credits` never
|
|
146
|
+
* collects the shortfall.
|
|
147
|
+
*
|
|
148
|
+
* Declare it in the catalog's own spelling. `normalizeVideoRequestParams`
|
|
149
|
+
* uses it in place of the nearest-band snap; `video-collapse-parity.test.ts`
|
|
150
|
+
* pins each declaration to what the provider normalizer actually returns, so
|
|
151
|
+
* the two cannot drift.
|
|
152
|
+
*/
|
|
153
|
+
unlistedResolutionRendersAs?: string
|
|
136
154
|
qualities?: readonly string[]
|
|
137
155
|
durations?: readonly number[]
|
|
138
156
|
pricing: readonly PriceVariant[]
|
|
@@ -155,6 +173,12 @@ export interface ModelCatalogEntry {
|
|
|
155
173
|
* Frontend pickers ignore this flag.
|
|
156
174
|
*/
|
|
157
175
|
mcpHidden?: boolean
|
|
176
|
+
/**
|
|
177
|
+
* The provider's safety filter is known to be non-deterministic on this
|
|
178
|
+
* model — the platform retries a blocked request once; `fallback` names
|
|
179
|
+
* the catalog model to offer when it blocks again.
|
|
180
|
+
*/
|
|
181
|
+
safetyFilter?: { stochastic: true; fallback?: string }
|
|
158
182
|
}
|
|
159
183
|
|
|
160
184
|
/**
|
|
@@ -177,7 +201,7 @@ export const MODEL_RECOMMENDATIONS: readonly ModelRecommendation[] = [
|
|
|
177
201
|
{ intent: "cheapest realistic image", modelIds: ["z-image", "qwen", "imagen4-fast"], note: "Z-Image is the cheapest. Qwen / Imagen4 Fast for slightly higher quality." },
|
|
178
202
|
{ intent: "highest fidelity image", modelIds: ["nano-banana-pro", "imagen4-ultra", "flux-flex"], note: "Pick by family preference; all three are premium tiers." },
|
|
179
203
|
{ intent: "image edit / restyle", modelIds: ["flux-kontext", "ideogram-remix", "seedream-5-pro-i2i"], note: "Flux Kontext preserves identity; Ideogram Remix is character-aware; Seedream 5 Pro for instruction-based edits (5 Lite is the budget option)." },
|
|
180
|
-
{ intent: "highest-resolution image
|
|
204
|
+
{ intent: "highest-resolution image", modelIds: ["topaz-image-upscale", "nano-banana-pro", "gpt-image-2"], note: "Generate at the model's top tier, then Topaz upscale 4x (Topaz's only lever is the 1x/2x/4x factor)." },
|
|
181
205
|
{ intent: "background removal / cutout", modelIds: ["recraft-remove-bg"], note: "Cheap, no prompt needed." },
|
|
182
206
|
// video
|
|
183
207
|
{ intent: "best cinematic video", modelIds: ["veo3", "kling-3.0", "seedance-2"], note: "VEO 3.1 Quality for premium narrative; Kling 3.0 for music-synced motion; Seedance 2 for reference-driven consistency." },
|
|
@@ -545,6 +569,7 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
545
569
|
{ identifier: "gpt-image-2:2K", credits: 30, note: "2K" },
|
|
546
570
|
{ identifier: "gpt-image-2:4K", credits: 60, note: "4K" },
|
|
547
571
|
],
|
|
572
|
+
safetyFilter: { stochastic: true, fallback: "nano-banana-pro" },
|
|
548
573
|
},
|
|
549
574
|
"gpt-image-2-i2i": {
|
|
550
575
|
id: "gpt-image-2-i2i",
|
|
@@ -554,6 +579,7 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
554
579
|
label: "GPT Image 2 (I2I)",
|
|
555
580
|
series: "GPT Image",
|
|
556
581
|
description: "Image-to-image with GPT Image 2.",
|
|
582
|
+
safetyFilter: { stochastic: true, fallback: "nano-banana-pro" },
|
|
557
583
|
useCases: ["edit", "high-res"],
|
|
558
584
|
features: ["reference-image"],
|
|
559
585
|
aspectRatios: GPT_IMAGE_2_RATIOS,
|
|
@@ -975,14 +1001,15 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
975
1001
|
family: "Topaz",
|
|
976
1002
|
label: "Topaz Image Upscale",
|
|
977
1003
|
series: "Topaz",
|
|
978
|
-
description: "High-quality image upscale
|
|
1004
|
+
description: "High-quality image upscale at 1x (enhance only), 2x or 4x. Best for production-ready output.",
|
|
979
1005
|
useCases: ["upscale", "high-res", "premium"],
|
|
980
1006
|
features: ["reference-image"],
|
|
981
|
-
resolutions
|
|
1007
|
+
// No `resolutions`: the provider's only quality lever is `upscale_factor`
|
|
1008
|
+
// (1/2/4) — see resolveTopazUpscale. The 2K/4K/8K menu this used to
|
|
1009
|
+
// advertise had no provider parameter behind it.
|
|
982
1010
|
pricing: [
|
|
983
|
-
{ identifier: "topaz-image-upscale", credits: 25, note: "
|
|
984
|
-
{ identifier: "topaz-image-upscale:4K", credits: 50, note: "
|
|
985
|
-
{ identifier: "topaz-image-upscale:8K", credits: 100, note: "8K" },
|
|
1011
|
+
{ identifier: "topaz-image-upscale", credits: 25, note: "1x / 2x" },
|
|
1012
|
+
{ identifier: "topaz-image-upscale:4K", credits: 50, note: "4x (legacy identifier)" },
|
|
986
1013
|
],
|
|
987
1014
|
},
|
|
988
1015
|
}
|
|
@@ -1017,6 +1044,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1017
1044
|
// docs.kie.ai/market/minimax-h3/{text,image,reference}-to-video.
|
|
1018
1045
|
"minimax-h3": {
|
|
1019
1046
|
id: "minimax-h3",
|
|
1047
|
+
unlistedResolutionRendersAs: "2K",
|
|
1020
1048
|
kind: "video",
|
|
1021
1049
|
modes: ["i2v", "t2v"] as const,
|
|
1022
1050
|
family: "MiniMax",
|
|
@@ -1505,6 +1533,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1505
1533
|
// declared explicitly in PRICING_DEFAULT_RESOLUTION, never by array position.
|
|
1506
1534
|
"wan-3": {
|
|
1507
1535
|
id: "wan-3",
|
|
1536
|
+
unlistedResolutionRendersAs: "720p",
|
|
1508
1537
|
kind: "video",
|
|
1509
1538
|
modes: ["i2v", "t2v"] as const,
|
|
1510
1539
|
family: "Alibaba",
|
|
@@ -1529,6 +1558,7 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1529
1558
|
},
|
|
1530
1559
|
"wan-3-prime": {
|
|
1531
1560
|
id: "wan-3-prime",
|
|
1561
|
+
unlistedResolutionRendersAs: "720p",
|
|
1532
1562
|
kind: "video",
|
|
1533
1563
|
modes: ["i2v", "t2v"] as const,
|
|
1534
1564
|
family: "Alibaba",
|
|
@@ -1697,6 +1727,76 @@ const VIDEO_MODELS: Record<string, ModelCatalogEntry> = {
|
|
|
1697
1727
|
{ identifier: "wan-2.7-t2v", credits: 188, note: "5s 720p default" },
|
|
1698
1728
|
],
|
|
1699
1729
|
},
|
|
1730
|
+
// Lightricks LTX 2.3 (Replicate, not KIE). Both variants run one endpoint per
|
|
1731
|
+
// variant and switch behaviour with a `task` discriminator, so t2v and i2v are
|
|
1732
|
+
// the SAME id — no VIDEO_MODE_ALIASES row. `extend` / `retake` are separate
|
|
1733
|
+
// priced ops on the same model (ids below) but are NOT listed in `modes`:
|
|
1734
|
+
// they are driven by the Extend Video / Video Retake nodes' own pickers, and
|
|
1735
|
+
// adding the mode here would duplicate LTX in those menus.
|
|
1736
|
+
// Bands are lowercase to match LTX_DURATION_TIERS' keys in credit-identifiers.ts
|
|
1737
|
+
// and QUALITY_MAP in node-default-mappings.ts.
|
|
1738
|
+
"ltx-2.3-pro": {
|
|
1739
|
+
id: "ltx-2.3-pro",
|
|
1740
|
+
kind: "video",
|
|
1741
|
+
modes: ["i2v", "t2v"] as const,
|
|
1742
|
+
family: "Lightricks",
|
|
1743
|
+
label: "LTX 2.3 Pro",
|
|
1744
|
+
series: "LTX",
|
|
1745
|
+
description: "Lightricks LTX 2.3 Pro — text/image/audio→video up to 4K, 6/8/10s, end-frame interpolation.",
|
|
1746
|
+
useCases: ["premium", "high-res", "narrative"],
|
|
1747
|
+
features: ["end-frame"],
|
|
1748
|
+
aspectRatios: ["16:9", "9:16"] as const,
|
|
1749
|
+
resolutions: ["1080p", "2k", "4k"] as const,
|
|
1750
|
+
durations: [6, 8, 10],
|
|
1751
|
+
pricing: [
|
|
1752
|
+
{ identifier: "ltx-2.3-pro", credits: 240, note: "default 1080p 6s" },
|
|
1753
|
+
{ identifier: "ltx-2.3-pro:1080p:6s", credits: 240 },
|
|
1754
|
+
{ identifier: "ltx-2.3-pro:1080p:8s", credits: 320 },
|
|
1755
|
+
{ identifier: "ltx-2.3-pro:1080p:10s", credits: 400 },
|
|
1756
|
+
{ identifier: "ltx-2.3-pro:2k:6s", credits: 480 },
|
|
1757
|
+
{ identifier: "ltx-2.3-pro:2k:8s", credits: 640 },
|
|
1758
|
+
{ identifier: "ltx-2.3-pro:2k:10s", credits: 800 },
|
|
1759
|
+
{ identifier: "ltx-2.3-pro:4k:6s", credits: 960 },
|
|
1760
|
+
{ identifier: "ltx-2.3-pro:4k:8s", credits: 1280 },
|
|
1761
|
+
{ identifier: "ltx-2.3-pro:4k:10s", credits: 1600 },
|
|
1762
|
+
{ identifier: "ltx-2.3-pro-extend:per-second", credits: 40, note: "extend, per second of new footage" },
|
|
1763
|
+
{ identifier: "ltx-2.3-pro-retake:per-second", credits: 40, note: "retake, per second re-rendered" },
|
|
1764
|
+
],
|
|
1765
|
+
},
|
|
1766
|
+
"ltx-2.3-fast": {
|
|
1767
|
+
id: "ltx-2.3-fast",
|
|
1768
|
+
kind: "video",
|
|
1769
|
+
modes: ["i2v", "t2v"] as const,
|
|
1770
|
+
family: "Lightricks",
|
|
1771
|
+
label: "LTX 2.3 Fast",
|
|
1772
|
+
series: "LTX",
|
|
1773
|
+
description: "Lightricks LTX 2.3 Fast — text/image→video up to 20s at 1080p (6/8/10s at 2K and 4K). No audio input, no extend.",
|
|
1774
|
+
useCases: ["long-form", "fast", "narrative"],
|
|
1775
|
+
features: ["end-frame"],
|
|
1776
|
+
aspectRatios: ["16:9", "9:16"] as const,
|
|
1777
|
+
resolutions: ["1080p", "2k", "4k"] as const,
|
|
1778
|
+
// Flat union across bands — 12–20s exist only at 1080p (LTX_DURATION_TIERS
|
|
1779
|
+
// in credit-identifiers.ts is the per-band authority and snaps a 2k/4k
|
|
1780
|
+
// request back onto 6/8/10s, so the reservation is always a real tier).
|
|
1781
|
+
durations: [6, 8, 10, 12, 14, 16, 18, 20],
|
|
1782
|
+
pricing: [
|
|
1783
|
+
{ identifier: "ltx-2.3-fast", credits: 180, note: "default 1080p 6s" },
|
|
1784
|
+
{ identifier: "ltx-2.3-fast:1080p:6s", credits: 180 },
|
|
1785
|
+
{ identifier: "ltx-2.3-fast:1080p:8s", credits: 240 },
|
|
1786
|
+
{ identifier: "ltx-2.3-fast:1080p:10s", credits: 300 },
|
|
1787
|
+
{ identifier: "ltx-2.3-fast:1080p:12s", credits: 360 },
|
|
1788
|
+
{ identifier: "ltx-2.3-fast:1080p:14s", credits: 420 },
|
|
1789
|
+
{ identifier: "ltx-2.3-fast:1080p:16s", credits: 480 },
|
|
1790
|
+
{ identifier: "ltx-2.3-fast:1080p:18s", credits: 540 },
|
|
1791
|
+
{ identifier: "ltx-2.3-fast:1080p:20s", credits: 600 },
|
|
1792
|
+
{ identifier: "ltx-2.3-fast:2k:6s", credits: 360 },
|
|
1793
|
+
{ identifier: "ltx-2.3-fast:2k:8s", credits: 480 },
|
|
1794
|
+
{ identifier: "ltx-2.3-fast:2k:10s", credits: 600 },
|
|
1795
|
+
{ identifier: "ltx-2.3-fast:4k:6s", credits: 720 },
|
|
1796
|
+
{ identifier: "ltx-2.3-fast:4k:8s", credits: 960 },
|
|
1797
|
+
{ identifier: "ltx-2.3-fast:4k:10s", credits: 1200 },
|
|
1798
|
+
],
|
|
1799
|
+
},
|
|
1700
1800
|
// ── HappyHorse (1.1 — ids kept version-less; repointed in place when KIE
|
|
1701
1801
|
// delisted 1.0, same param surface, so saved workflows keep working) ──
|
|
1702
1802
|
"happyhorse": {
|
|
@@ -2714,6 +2814,187 @@ export function normalizeModelInput(
|
|
|
2714
2814
|
return out
|
|
2715
2815
|
}
|
|
2716
2816
|
|
|
2817
|
+
/** Aspect tokens that mean "match the input" or "let the provider decide". They
|
|
2818
|
+
* are NOT concrete ratios, so snapping them onto one is always wrong — the
|
|
2819
|
+
* provider adapter (applySeedance2Params, applyMinimaxH3Params) resolves them
|
|
2820
|
+
* once it knows the run's mode. */
|
|
2821
|
+
const PASSTHROUGH_ASPECT_TOKENS = new Set(["auto", "adaptive"])
|
|
2822
|
+
|
|
2823
|
+
/** Approximate vertical pixel count per band token, for nearest-band matching.
|
|
2824
|
+
* Anything of the form `<N>p` is parsed directly; these are the named bands
|
|
2825
|
+
* the catalogs use that do not follow that form. */
|
|
2826
|
+
const RESOLUTION_BAND_PIXELS: Record<string, number> = {
|
|
2827
|
+
"2k": 1440,
|
|
2828
|
+
"4k": 2160,
|
|
2829
|
+
"8k": 4320,
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
function bandPixels(token: string): number | undefined {
|
|
2833
|
+
const t = token.trim().toLowerCase()
|
|
2834
|
+
if (RESOLUTION_BAND_PIXELS[t] !== undefined) return RESOLUTION_BAND_PIXELS[t]
|
|
2835
|
+
const m = /^(\d+)p$/.exec(t)
|
|
2836
|
+
return m ? Number(m[1]) : undefined
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
/** Nearest member of `allowed` to `token` by vertical pixels. An UNPARSEABLE
|
|
2840
|
+
* request falls back to the highest declared band — never the cheapest, which
|
|
2841
|
+
* is what a catalog-order fallback would give (R7). Highest is computed from
|
|
2842
|
+
* the pixel counts, not from the array position: most video catalogs list
|
|
2843
|
+
* ascending but not all do (minimax-h3 is `["2K", "768P"]`), and an ordering
|
|
2844
|
+
* convention is not something a money path should depend on. */
|
|
2845
|
+
function nearestResolutionBand(token: string, allowed: readonly string[]): string {
|
|
2846
|
+
let highest = allowed[allowed.length - 1]!
|
|
2847
|
+
let highestPx = -Infinity
|
|
2848
|
+
for (const a of allowed) {
|
|
2849
|
+
const px = bandPixels(a)
|
|
2850
|
+
if (px !== undefined && px > highestPx) { highestPx = px; highest = a }
|
|
2851
|
+
}
|
|
2852
|
+
const want = bandPixels(token)
|
|
2853
|
+
if (want === undefined) return highest
|
|
2854
|
+
let best = highest
|
|
2855
|
+
let bestDist = Infinity
|
|
2856
|
+
for (const a of allowed) {
|
|
2857
|
+
const px = bandPixels(a)
|
|
2858
|
+
if (px === undefined) continue
|
|
2859
|
+
const d = Math.abs(px - want)
|
|
2860
|
+
if (d < bestDist) { bestDist = d; best = a }
|
|
2861
|
+
}
|
|
2862
|
+
return best
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
/** Nearest aspect ratio in log space. Mirrors
|
|
2866
|
+
* `backend/src/providers/video/aspect-ratio.ts` exactly — that helper cannot
|
|
2867
|
+
* be imported here (wrong package), and the totality test in
|
|
2868
|
+
* `backend/src/lib/mcp/__tests__/video-normalizer-totality.test.ts` pins the
|
|
2869
|
+
* two implementations to the same answer for every catalogued model. */
|
|
2870
|
+
function nearestAspectRatio(token: string, allowed: readonly string[]): string | undefined {
|
|
2871
|
+
const [w, h] = token.split(":").map(Number)
|
|
2872
|
+
if (!w || !h) return undefined
|
|
2873
|
+
const target = Math.log(w / h)
|
|
2874
|
+
let best: string | undefined
|
|
2875
|
+
let bestDist = Infinity
|
|
2876
|
+
for (const c of allowed) {
|
|
2877
|
+
const [cw, ch] = c.split(":").map(Number)
|
|
2878
|
+
if (!cw || !ch) continue
|
|
2879
|
+
const d = Math.abs(Math.log(cw / ch) - target)
|
|
2880
|
+
if (d < bestDist) { bestDist = d; best = c }
|
|
2881
|
+
}
|
|
2882
|
+
return best
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
/**
|
|
2886
|
+
* Read a catalog lever off an UNVALIDATED input as a trimmed string.
|
|
2887
|
+
*
|
|
2888
|
+
* `normalizeVideoRequestParams` runs in both routes' creditGuard preHandler —
|
|
2889
|
+
* which fires BEFORE the route's Zod parse — and in all four `buildPayload`
|
|
2890
|
+
* branches, whose `data` is persisted workflow JSON that an agent/import/
|
|
2891
|
+
* FieldMapping can write any JSON type into. A bare `.trim()` therefore throws
|
|
2892
|
+
* `TypeError` on `{"resolution": 1080}`: a 500 where the route used to return a
|
|
2893
|
+
* clean Zod 400, and in the DAG a thrown lever takes the WHOLE run down after
|
|
2894
|
+
* sibling nodes have already reserved and generated. So coerce rather than
|
|
2895
|
+
* assume — same posture as `sameOptionValue`, which has always used `String(v)`.
|
|
2896
|
+
*
|
|
2897
|
+
* `null`/`undefined`/blank mean "absent" (there is nothing to snap) and read as
|
|
2898
|
+
* `undefined`, so the priced fill downstream supplies the band the identifier
|
|
2899
|
+
* assumes — price and wire still agree. Everything else is stringified: a
|
|
2900
|
+
* numeric `1080` becomes `"1080"` and snaps like its string form.
|
|
2901
|
+
*/
|
|
2902
|
+
function readOptionToken(value: unknown): string | undefined {
|
|
2903
|
+
if (value === undefined || value === null) return undefined
|
|
2904
|
+
const s = (typeof value === "string" ? value : String(value)).trim()
|
|
2905
|
+
return s === "" ? undefined : s
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
export interface NormalizedVideoRequest {
|
|
2909
|
+
aspectRatio?: string
|
|
2910
|
+
resolution?: string
|
|
2911
|
+
adjustments: ModelInputAdjustment[]
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
/**
|
|
2915
|
+
* The video lane's LIMITED normalizer: `aspectRatio` + `resolution` only, and
|
|
2916
|
+
* only when the catalog actually declares the option list.
|
|
2917
|
+
*
|
|
2918
|
+
* Three deliberate differences from `normalizeModelInput`:
|
|
2919
|
+
* 1. It never DROPS a lever. `normalizeModelInput` removes a value whose list
|
|
2920
|
+
* the catalog doesn't declare; on the video lane `resolution` is a pricing
|
|
2921
|
+
* input, so dropping it would lower the reserved tier and `commit_credits`
|
|
2922
|
+
* never collects an upward delta — a params fix would become a money bug.
|
|
2923
|
+
* 2. "Auto" / "adaptive" pass through (see PASSTHROUGH_ASPECT_TOKENS).
|
|
2924
|
+
* 3. It snaps to the NEAREST option, not to `allowed[0]`. `normalizeModelInput`
|
|
2925
|
+
* would turn a portrait 9:21 into landscape 16:9 and a 4k request into the
|
|
2926
|
+
* cheapest 480p; nearest matches what the provider adapters and the MCP
|
|
2927
|
+
* normalizer already do, so all three give ONE answer. Its snap policy is
|
|
2928
|
+
* deliberately left alone — it is shared with the image lane and the
|
|
2929
|
+
* copilot, and PR 4 defers changing it.
|
|
2930
|
+
*
|
|
2931
|
+
* `duration` is deliberately NOT normalized here: the LTX bands make duration
|
|
2932
|
+
* legality resolution-dependent, which a flat catalog list can't express, and
|
|
2933
|
+
* `buildVideoCreditModelIdentifier` already snaps duration onto a seeded tier.
|
|
2934
|
+
* Carrying THAT tier to the wire is `pricedVideoSelection`'s job
|
|
2935
|
+
* (`credit-identifiers.ts`), which runs after this one, once resolution is known.
|
|
2936
|
+
*
|
|
2937
|
+
* An OMITTED lever stays omitted here — filling it is a pricing decision, not a
|
|
2938
|
+
* catalog one, and likewise belongs to `pricedVideoSelection`.
|
|
2939
|
+
*
|
|
2940
|
+
* Case is canonicalised to the catalog's own spelling — the credit identifiers
|
|
2941
|
+
* key their tables case-SENSITIVELY (LTX_DURATION_TIERS), so this is the single
|
|
2942
|
+
* place a "4K" becomes "4k", and it runs before every identifier site.
|
|
2943
|
+
*
|
|
2944
|
+
* Pure — so the credit-identifier preHandler, `computeCredits`, the reservation
|
|
2945
|
+
* site and the payload build can all call it and cannot disagree (spec §4).
|
|
2946
|
+
*/
|
|
2947
|
+
export function normalizeVideoRequestParams(
|
|
2948
|
+
modelId: string,
|
|
2949
|
+
input: { aspectRatio?: string; resolution?: string },
|
|
2950
|
+
): NormalizedVideoRequest {
|
|
2951
|
+
const m = MODEL_CATALOG[modelId]
|
|
2952
|
+
// Seeded from the COERCED tokens, never the raw input: this function's own
|
|
2953
|
+
// return type promises `string | undefined`, and its callers feed it straight
|
|
2954
|
+
// to the credit identifier and the provider wire. Handing back the caller's
|
|
2955
|
+
// `1080` or `[]` verbatim would break that promise at exactly the sites that
|
|
2956
|
+
// must agree on one value.
|
|
2957
|
+
const aspect = readOptionToken(input.aspectRatio)
|
|
2958
|
+
const res = readOptionToken(input.resolution)
|
|
2959
|
+
const out: NormalizedVideoRequest = { aspectRatio: aspect, resolution: res, adjustments: [] }
|
|
2960
|
+
if (!m) return out
|
|
2961
|
+
|
|
2962
|
+
if (m.aspectRatios?.length && aspect && !PASSTHROUGH_ASPECT_TOKENS.has(aspect.toLowerCase())) {
|
|
2963
|
+
const allowed = m.aspectRatios
|
|
2964
|
+
const exact = allowed.find((a) => a === aspect) ?? allowed.find((a) => a.toLowerCase() === aspect.toLowerCase())
|
|
2965
|
+
if (exact !== undefined) {
|
|
2966
|
+
out.aspectRatio = exact
|
|
2967
|
+
} else {
|
|
2968
|
+
const next = nearestAspectRatio(aspect, allowed.filter((a) => !PASSTHROUGH_ASPECT_TOKENS.has(a.toLowerCase()))) ?? allowed[0]!
|
|
2969
|
+
out.adjustments.push({
|
|
2970
|
+
field: "aspectRatio", from: aspect, to: next,
|
|
2971
|
+
reason: `${m.label} does not support aspect ratio "${aspect}" — using "${next}" instead. Supported: ${allowed.join(", ")}.`,
|
|
2972
|
+
})
|
|
2973
|
+
out.aspectRatio = next
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
if (m.resolutions?.length && res) {
|
|
2978
|
+
const allowed = m.resolutions
|
|
2979
|
+
const exact = allowed.find((a) => a === res) ?? allowed.find((a) => a.toLowerCase() === res.toLowerCase())
|
|
2980
|
+
if (exact !== undefined) {
|
|
2981
|
+
out.resolution = exact
|
|
2982
|
+
} else {
|
|
2983
|
+
// A provider that COLLAPSES an unrecognised band renders its declared
|
|
2984
|
+
// default no matter what we send, so that default — not the nearest band
|
|
2985
|
+
// — is the value the wire and the price must both carry.
|
|
2986
|
+
const next = m.unlistedResolutionRendersAs ?? nearestResolutionBand(res, allowed)
|
|
2987
|
+
out.adjustments.push({
|
|
2988
|
+
field: "resolution", from: res, to: next,
|
|
2989
|
+
reason: `${m.label} does not support resolution "${res}" — using "${next}" instead. Supported: ${allowed.join(", ")}.`,
|
|
2990
|
+
})
|
|
2991
|
+
out.resolution = next
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
return out
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2717
2998
|
// =============================================================================
|
|
2718
2999
|
// Frontend picker helpers — return `{value, label}[]` shapes that the
|
|
2719
3000
|
// existing config-panel components expect, derived from the catalog so we
|
|
@@ -2742,6 +3023,7 @@ const MODEL_VALUE_LABELS: Record<string, string> = {
|
|
|
2742
3023
|
"2K": "2K (High)",
|
|
2743
3024
|
"4K": "4K (Ultra)",
|
|
2744
3025
|
"4k": "4K",
|
|
3026
|
+
"2k": "2K",
|
|
2745
3027
|
"8K": "8K (Ultra)",
|
|
2746
3028
|
// qualities
|
|
2747
3029
|
"medium": "Medium (Balanced)",
|
package/src/model-constants.ts
CHANGED
|
@@ -1900,6 +1900,89 @@ export const VIDEO_REF_LIMITS_BY_PROVIDER: Record<
|
|
|
1900
1900
|
// verified provider path + the catalog `reference-image` feature.
|
|
1901
1901
|
}
|
|
1902
1902
|
|
|
1903
|
+
/** Per-reference-video duration bounds, in seconds. */
|
|
1904
|
+
export interface RefVideoDurationLimit {
|
|
1905
|
+
minSec: number
|
|
1906
|
+
maxSec: number
|
|
1907
|
+
/** Cap on the SUM of all reference-video durations, when the provider has one. */
|
|
1908
|
+
maxTotalSec?: number
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Reference-video duration limits, provider-declared.
|
|
1913
|
+
*
|
|
1914
|
+
* `VIDEO_REF_LIMITS_BY_PROVIDER` above caps the reference COUNT; these cap the
|
|
1915
|
+
* duration of each clip. Both routes already ffprobe every reference video to
|
|
1916
|
+
* price the run (ee/billing/seedance2-ref-video-credits.ts and
|
|
1917
|
+
* minimax-h3-credits.ts), so the numbers are in hand before the job exists —
|
|
1918
|
+
* and until 2026-09-02 they were discarded, which is why "Each reference video
|
|
1919
|
+
* must be between 2 and 30 seconds" and "video duration 52838 ms, expected
|
|
1920
|
+
* [2000, 15000] ms" were reaching users as post-payment provider rejects
|
|
1921
|
+
* (app-reports §11.3).
|
|
1922
|
+
*
|
|
1923
|
+
* Data-driven, exactly like SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER above:
|
|
1924
|
+
* only providers with a VERIFIED documented limit are listed, so an unknown
|
|
1925
|
+
* provider is never false-rejected.
|
|
1926
|
+
*
|
|
1927
|
+
* Sources: docs.kie.ai/market/bytedance/seedance-2-5 ("Single video duration:
|
|
1928
|
+
* [2, 30] seconds"; "Total duration of reference videos must not exceed 30
|
|
1929
|
+
* seconds") and docs.kie.ai/market/minimax-h3/reference-to-video (reference
|
|
1930
|
+
* videos are "2 to 15 seconds" each and "the total duration of all reference
|
|
1931
|
+
* videos cannot exceed 15 seconds", up to 3 videos) — both fetched 2026-09-02.
|
|
1932
|
+
*/
|
|
1933
|
+
export const VIDEO_REF_VIDEO_DURATION_LIMITS: Record<string, RefVideoDurationLimit | undefined> = {
|
|
1934
|
+
"seedance-2-5": { minSec: 2, maxSec: 30, maxTotalSec: 30 },
|
|
1935
|
+
// MiniMax Hailuo 3 — the provider states the per-clip bound in its own reject
|
|
1936
|
+
// text: "content[1].video_url: invalid param: video duration 52838 ms,
|
|
1937
|
+
// expected [2000, 15000] ms" (app-reports P4, 2 rows, the same clip retried).
|
|
1938
|
+
// The KIE reference-to-video doc adds the COMBINED cap — the same shape the
|
|
1939
|
+
// audio side already declares in SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER
|
|
1940
|
+
// above, whose h3 row cites the same page.
|
|
1941
|
+
"minimax-h3": { minSec: 2, maxSec: 15, maxTotalSec: 15 },
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Validate probed reference-video durations against the provider's limits.
|
|
1946
|
+
*
|
|
1947
|
+
* Non-finite / non-positive entries are IGNORED, never rejected: the probe
|
|
1948
|
+
* helpers treat an unreadable clip as a worst-case duration for pricing, and
|
|
1949
|
+
* turning a failed ffprobe into a user-facing 400 would block legitimate runs
|
|
1950
|
+
* on a transient probe failure. Callers therefore pass the RAW per-URL probe
|
|
1951
|
+
* outcomes (a rejected ffprobe arrives here as NaN) — an ignored entry also
|
|
1952
|
+
* contributes nothing to the `maxTotalSec` sum, so a probe blip can never push
|
|
1953
|
+
* an otherwise-legal set over the total cap either.
|
|
1954
|
+
*/
|
|
1955
|
+
export function checkRefVideoDurations(
|
|
1956
|
+
provider: string,
|
|
1957
|
+
durationsSec: readonly number[],
|
|
1958
|
+
): { ok: true } | { ok: false; message: string } {
|
|
1959
|
+
const limit = VIDEO_REF_VIDEO_DURATION_LIMITS[provider]
|
|
1960
|
+
if (!limit) return { ok: true }
|
|
1961
|
+
|
|
1962
|
+
const usable = durationsSec.filter((d) => Number.isFinite(d) && d > 0)
|
|
1963
|
+
if (usable.length === 0) return { ok: true }
|
|
1964
|
+
|
|
1965
|
+
const offender = usable.find((d) => d < limit.minSec || d > limit.maxSec)
|
|
1966
|
+
if (offender !== undefined) {
|
|
1967
|
+
return {
|
|
1968
|
+
ok: false,
|
|
1969
|
+
message: `Each reference video must be between ${limit.minSec} and ${limit.maxSec} seconds — one is ${offender.toFixed(1)}s. Trim it (a Trim Video node upstream works) and run again.`,
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
if (limit.maxTotalSec !== undefined) {
|
|
1974
|
+
const total = usable.reduce((a, b) => a + b, 0)
|
|
1975
|
+
if (total > limit.maxTotalSec) {
|
|
1976
|
+
return {
|
|
1977
|
+
ok: false,
|
|
1978
|
+
message: `Reference videos must not exceed ${limit.maxTotalSec} seconds in total — these add up to ${total.toFixed(1)}s. Remove one or trim them and run again.`,
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
return { ok: true }
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1903
1986
|
/**
|
|
1904
1987
|
* Video models where credit cost depends on resolution AND whether a video
|
|
1905
1988
|
* reference is connected. Identifier suffix: `:{resolution}[-ref]`.
|
|
@@ -2087,8 +2170,8 @@ export interface VideoAudioCapability {
|
|
|
2087
2170
|
/**
|
|
2088
2171
|
* Per-model audio capability. Only models that produce SOME audio are listed;
|
|
2089
2172
|
* anything absent defaults to `{ mode: "none" }` via `getVideoAudioCapability`,
|
|
2090
|
-
* so silent models (minimax, hailuo, wan, grok-i2v,
|
|
2091
|
-
*
|
|
2173
|
+
* so silent models (minimax, hailuo, wan-i2v, grok-i2v, runway, pika, …) need
|
|
2174
|
+
* no entry. New audio-capable models MUST be added here — the
|
|
2092
2175
|
* `video-audio-capability` guard test cross-checks this map against the model
|
|
2093
2176
|
* configs' `extraParams.sound` / `generate_audio` + VEO/Seedance-2 sets so a
|
|
2094
2177
|
* forgotten entry fails CI rather than silently disabling audio.
|
|
@@ -2137,10 +2220,39 @@ export const VIDEO_AUDIO_CAPABILITY: Record<string, VideoAudioCapability> = {
|
|
|
2137
2220
|
// and skip the lip-sync pass. `defaultOn` mirrors the KIE default so an
|
|
2138
2221
|
// intent-less request is described honestly; audio is priced into the uniform
|
|
2139
2222
|
// per-second rate, so NOT cost-affecting (no `:audio` composite).
|
|
2140
|
-
// gemini-omni-flash is deliberately ABSENT — gemini-omni-video is absent too
|
|
2141
|
-
// (mode "none"), and the siblings must not disagree.
|
|
2142
2223
|
"wan-3": { mode: "ambient", field: "audio", defaultOn: true },
|
|
2143
2224
|
"wan-3-prime": { mode: "ambient", field: "audio", defaultOn: true },
|
|
2225
|
+
// Gemini Omni (both SKUs — the pro `gemini-omni-video` and the faster
|
|
2226
|
+
// `gemini-omni-flash`; one model at two speeds, so their rows are identical
|
|
2227
|
+
// and a test pins that). Settled 2026-09-03 from Google's own documentation
|
|
2228
|
+
// at https://ai.google.dev/gemini-api/docs/omni, having been unlisted — and
|
|
2229
|
+
// therefore reported as SILENT — while the catalog described both as "native
|
|
2230
|
+
// audio".
|
|
2231
|
+
//
|
|
2232
|
+
// "ambient", and alwaysOn with no toggle field, on three sentences from that
|
|
2233
|
+
// page:
|
|
2234
|
+
// - "By default the model will try to generate an appropriate audio track
|
|
2235
|
+
// for a video." Audio on every render, and the KIE input schema
|
|
2236
|
+
// (prompt / image_urls / first+last_frame_url / audio_ids / video_list /
|
|
2237
|
+
// character_ids / duration / aspect_ratio / seed / resolution — see
|
|
2238
|
+
// docs.kie.ai/market/gemini-omni-video and
|
|
2239
|
+
// docs.kie.ai/market/google/gemini-omni-flash-1-1) carries no on/off
|
|
2240
|
+
// lever, so there is nothing for applyVideoAudioToggle to write.
|
|
2241
|
+
// - NOT native_speech: "Multi-turn voice extension: Generating spoken
|
|
2242
|
+
// dialogue or speech is supported when extending previously generated
|
|
2243
|
+
// videos via multi-turn (`previous_interaction_id`)" — a field KIE's
|
|
2244
|
+
// createTask schema does not expose, so the dialogue path is unreachable
|
|
2245
|
+
// on our transport. Held to the Wan 3.0 bar: no documented dialogue
|
|
2246
|
+
// guarantee on the path we actually drive ⇒ ambient, and upgrade only on
|
|
2247
|
+
// a live probe (the kling-3.0 standard). Classifying it native_speech
|
|
2248
|
+
// would reroute the Story→Video dialogue pipeline past the lip-sync pass.
|
|
2249
|
+
// - NOT audio_driven either: "Uploading audio references is unsupported in
|
|
2250
|
+
// the current version of the API", and "any audio in a video reference is
|
|
2251
|
+
// ignored" — there is no reference-audio transport to be driven by, and
|
|
2252
|
+
// runGeminiOmni never sends `audio_ids`.
|
|
2253
|
+
// Audio is priced into the per-tier rate, so NOT cost-affecting.
|
|
2254
|
+
"gemini-omni-video": { mode: "ambient", alwaysOn: true },
|
|
2255
|
+
"gemini-omni-flash": { mode: "ambient", alwaysOn: true },
|
|
2144
2256
|
}
|
|
2145
2257
|
|
|
2146
2258
|
const VIDEO_AUDIO_NONE: VideoAudioCapability = { mode: "none" }
|
package/src/node-refs.ts
CHANGED
|
@@ -50,6 +50,10 @@ export function canonicalVarName(label: string): string {
|
|
|
50
50
|
* SUPPRESS auto-injection of a connected node the author already placed
|
|
51
51
|
* explicitly via `{label}` (so it isn't injected twice). `matchAll` over the
|
|
52
52
|
* global pattern does not mutate its lastIndex, so sharing the constant is safe.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately NOT `REF_TOKEN_NAMESPACE_PREFIXES`: widening this changes
|
|
55
|
+
* run-time auto-injection on BOTH engines (follow-up ticket), not just the
|
|
56
|
+
* editor.
|
|
53
57
|
*/
|
|
54
58
|
export function extractReferencedLabels(
|
|
55
59
|
...texts: ReadonlyArray<string | undefined | null>
|
|
@@ -251,3 +255,81 @@ export function resolveNodeRefs(
|
|
|
251
255
|
}
|
|
252
256
|
return result
|
|
253
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Token namespaces that are NOT node-label references. `{image:1:face}` is the
|
|
261
|
+
* unified-reference grammar, `{slot:x}` is recast's, `{video:N}`/`{audio:N}`
|
|
262
|
+
* are reference handles, and `{ref:<id>}` is the id-addressed reference form —
|
|
263
|
+
* all five are substituted by their own resolvers (`resolveReferenceTokens`,
|
|
264
|
+
* `resolveRefIdTokens`, the recast slot pass), not by resolveNodeRefs. Before
|
|
265
|
+
* this list existed, only `image:` was excluded, so `{video:1}` classified as a
|
|
266
|
+
* MISSING node ref.
|
|
267
|
+
*
|
|
268
|
+
* Compared against the LOWERCASED token name: `REFERENCE_TOKEN_RE` is `/gi` and
|
|
269
|
+
* `REF_ID_TOKEN_RE` spells `[rR][eE][fF]`, so `{Image:1}` / `{Ref:x}` really do
|
|
270
|
+
* resolve downstream and must never read as a missing node ref.
|
|
271
|
+
*/
|
|
272
|
+
export const REF_TOKEN_NAMESPACE_PREFIXES: readonly string[] = [
|
|
273
|
+
"image:", "video:", "audio:", "slot:", "ref:",
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
export type RefTokenKind = "wired" | "reserved" | "missing" | "skip" | "unknown"
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Classify a parsed `{...}` token name against the resolvable label set.
|
|
280
|
+
* `resolvable === null` means the caller has no ref data at all — such tokens
|
|
281
|
+
* classify `unknown` and must render like wired, so "no data" never
|
|
282
|
+
* masquerades as "nothing wired".
|
|
283
|
+
*
|
|
284
|
+
* Single source of truth for the editor decoration, the missing-refs chip
|
|
285
|
+
* (frontend/src/lib/prompt-ref-scan.ts delegates here) and the execution
|
|
286
|
+
* engine's dispatch guard.
|
|
287
|
+
*/
|
|
288
|
+
export function classifyRefToken(
|
|
289
|
+
name: string,
|
|
290
|
+
resolvable: ReadonlySet<string> | null,
|
|
291
|
+
): RefTokenKind {
|
|
292
|
+
const lower = name.toLowerCase()
|
|
293
|
+
if (name === "" || REF_TOKEN_NAMESPACE_PREFIXES.some((p) => lower.startsWith(p))) return "skip"
|
|
294
|
+
if (RESERVED_TEMPLATE_VARS.has(name)) return "reserved"
|
|
295
|
+
if (resolvable === null) return "unknown"
|
|
296
|
+
return resolvable.has(canonicalVarName(name)) ? "wired" : "missing"
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The `{Label}` tokens in `text` that `resolveNodeRefs` would leave LITERAL and
|
|
301
|
+
* that no upstream node can explain — i.e. exactly what would reach a provider
|
|
302
|
+
* as the characters `{Label}`. Evidence for why this matters: `{Describe Image}`
|
|
303
|
+
* ×2 (gpt-image-2) and `{gravity flip}` / `{rewind}` (seedance-2-5) in the
|
|
304
|
+
* 2026-09-01 app-reports export.
|
|
305
|
+
*
|
|
306
|
+
* `resolvable` — labels with a value in the ref map (canonical/lowercase).
|
|
307
|
+
* `known` — canonical labels of the nodes the caller considers to EXIST
|
|
308
|
+
* (no state requirement; the backend passes every node in the
|
|
309
|
+
* run's graph).
|
|
310
|
+
*
|
|
311
|
+
* A token whose label is in `known` but not `resolvable` PASSES: the node
|
|
312
|
+
* exists and simply produced nothing, which is the caller's to substitute (the
|
|
313
|
+
* backend resolves it to empty text), not this function's to refuse. A token
|
|
314
|
+
* with an explicit `|| fallback` passes too — the fallback is substituted.
|
|
315
|
+
* Returns original-cased names, de-duplicated by canonical form, for the
|
|
316
|
+
* user-facing message.
|
|
317
|
+
*/
|
|
318
|
+
export function unresolvedRefTokens(
|
|
319
|
+
text: string,
|
|
320
|
+
opts: { resolvable: ReadonlySet<string>; known: ReadonlySet<string> },
|
|
321
|
+
): string[] {
|
|
322
|
+
if (typeof text !== "string" || text.length === 0) return []
|
|
323
|
+
const seen = new Set<string>()
|
|
324
|
+
const out: string[] = []
|
|
325
|
+
for (const m of text.matchAll(NODE_REF_PATTERN)) {
|
|
326
|
+
const { name, fallback } = parseNodeRef(m[1] ?? "")
|
|
327
|
+
if (fallback !== null) continue
|
|
328
|
+
if (classifyRefToken(name, opts.resolvable) !== "missing") continue
|
|
329
|
+
const canon = canonicalVarName(name)
|
|
330
|
+
if (opts.known.has(canon) || seen.has(canon)) continue
|
|
331
|
+
seen.add(canon)
|
|
332
|
+
out.push(name)
|
|
333
|
+
}
|
|
334
|
+
return out
|
|
335
|
+
}
|
package/src/node-runtime-keys.ts
CHANGED
|
@@ -16,6 +16,18 @@ export const EXECUTION_DATA_KEYS: ReadonlySet<string> = new Set([
|
|
|
16
16
|
"currentJobId",
|
|
17
17
|
"currentJobProgress",
|
|
18
18
|
"errorMessage",
|
|
19
|
+
// Structured detail alongside errorMessage for a safety-filter block
|
|
20
|
+
// (see `JobErrorHint` in the app's frontend/src/types/nodes.ts). Same
|
|
21
|
+
// lifecycle as errorMessage: a RESULT the user expects to survive reload,
|
|
22
|
+
// never user-edited config.
|
|
23
|
+
"errorHint",
|
|
24
|
+
// A job policy registered by the deployment held this node's result for a
|
|
25
|
+
// human reviewer (`jobs.status = "pending_review"`). Pure run state, like
|
|
26
|
+
// executionStatus: the node is still "running" and the flag disappears the
|
|
27
|
+
// moment the review resolves — so it is ALSO in TRANSIENT_RUNTIME_KEYS
|
|
28
|
+
// below. Without the transient half, a flip into review marks a passive tab
|
|
29
|
+
// dirty and a preset captures "awaiting review".
|
|
30
|
+
"jobAwaitingReview",
|
|
19
31
|
"isStreaming",
|
|
20
32
|
"generatedImageUrl",
|
|
21
33
|
"generatedVideoUrl",
|
|
@@ -80,6 +92,7 @@ export const TRANSIENT_RUNTIME_KEYS: ReadonlySet<string> = new Set([
|
|
|
80
92
|
"executionStatus",
|
|
81
93
|
"currentJobId",
|
|
82
94
|
"currentJobProgress",
|
|
95
|
+
"jobAwaitingReview",
|
|
83
96
|
"isStreaming",
|
|
84
97
|
"subWorkflowProgress",
|
|
85
98
|
"__listTotal",
|