@nodaro/shared 2.19.0 → 2.21.0

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.
Files changed (35) hide show
  1. package/dist/index.cjs +548 -31
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1043 -386
  4. package/dist/index.d.ts +1043 -386
  5. package/dist/index.js +529 -32
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/credit-identifiers.test.ts +133 -0
  9. package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
  10. package/src/__tests__/normalize-node-params.test.ts +36 -0
  11. package/src/__tests__/organizations-types.test.ts +29 -1
  12. package/src/__tests__/prompt-length-limits.test.ts +36 -0
  13. package/src/__tests__/safety-retry-policy.test.ts +47 -0
  14. package/src/__tests__/suno-credit-type.test.ts +54 -0
  15. package/src/__tests__/topaz-upscale.test.ts +132 -0
  16. package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
  17. package/src/__tests__/video-analysis-brief.test.ts +61 -0
  18. package/src/__tests__/video-analysis.test.ts +83 -0
  19. package/src/__tests__/video-audio-capability.test.ts +38 -4
  20. package/src/__tests__/video-catalog-totality.test.ts +85 -0
  21. package/src/__tests__/video-collapse-parity.test.ts +76 -0
  22. package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
  23. package/src/__tests__/video-request-normalize.test.ts +239 -0
  24. package/src/credit-identifiers.ts +264 -20
  25. package/src/index.ts +30 -2
  26. package/src/model-catalog.ts +287 -6
  27. package/src/model-constants.ts +185 -13
  28. package/src/node-refs.ts +82 -0
  29. package/src/node-runtime-keys.ts +5 -0
  30. package/src/normalize-node-params.ts +8 -0
  31. package/src/organizations/types.ts +12 -0
  32. package/src/organizations/views.ts +114 -0
  33. package/src/safety-retry-policy.ts +37 -0
  34. package/src/topaz-upscale.ts +163 -0
  35. package/src/video-analysis.ts +115 -5
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ normalizeVideoRequestParams,
4
+ pricedVideoSelection,
5
+ buildVideoCreditModelIdentifier,
6
+ MODEL_CATALOG,
7
+ VIDEO_GEN_PROVIDERS,
8
+ } from "../index.js"
9
+
10
+ describe("normalizeVideoRequestParams", () => {
11
+ // R6: NEAREST, not allowed[0]. A portrait request must not become landscape.
12
+ it("snaps an off-list ratio to the NEAREST member of the model's list", () => {
13
+ const r = normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "9:21" })
14
+ expect(r.aspectRatio).toBe("9:16")
15
+ expect(r.adjustments).toHaveLength(1)
16
+ })
17
+ it("snaps 4:5 and 5:4 the same way the provider adapter does", () => {
18
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "4:5" }).aspectRatio).toBe("3:4")
19
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "5:4" }).aspectRatio).toBe("4:3")
20
+ })
21
+
22
+ it("passes 'Auto' and 'adaptive' through untouched (the provider decides)", () => {
23
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "Auto" }).aspectRatio).toBe("Auto")
24
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "adaptive" }).aspectRatio).toBe("adaptive")
25
+ })
26
+
27
+ // R7: nearest band, not the cheapest. A 4k request on a 1080p-max model is
28
+ // 1080p, never 480p.
29
+ it("snaps an off-list resolution to the NEAREST declared band", () => {
30
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "4k" }).resolution).toBe("1080p")
31
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "2k" }).resolution).toBe("1080p")
32
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: "360p" }).resolution).toBe("480p")
33
+ })
34
+
35
+ // §11.3 log-pull: two t2v rows failed createTask with "resolution is not
36
+ // within the range of allowed options". KIE exposes only 480p/720p/1080p for
37
+ // seedance-2-5 (providers/kie/models.ts:664-680, live probes 2026-08-08 and
38
+ // 08-17) while the route types `resolution` as a bare string. Asserts the
39
+ // exact snapped value, not membership — R7: the nearest band is 1080p, and a
40
+ // `toContain` pin would have hidden a silent downgrade to 480p.
41
+ it("snaps the off-list seedance-2-5 resolutions the t2v route admits, to the NEAREST band", () => {
42
+ for (const bad of ["2k", "4k", "1440p"]) {
43
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: bad }).resolution).toBe("1080p")
44
+ }
45
+ })
46
+
47
+ // R3: the one place a case variant is canonicalised, upstream of every
48
+ // credit identifier (which key their tables case-sensitively).
49
+ it("canonicalises a case variant to the catalog's own spelling", () => {
50
+ expect(normalizeVideoRequestParams("ltx-2.3-fast", { resolution: "4K" }).resolution).toBe("4k")
51
+ expect(normalizeVideoRequestParams("ltx-2.3-fast", { resolution: "1080P" }).resolution).toBe("1080p")
52
+ })
53
+
54
+ it("snaps a resolution onto the model's own list", () => {
55
+ expect(normalizeVideoRequestParams("ltx-2.3-fast", { resolution: "720p" }).resolution).toBe("1080p")
56
+ })
57
+
58
+ it("NEVER drops a resolution for a model that declares none — dropping would lower the reserved tier", () => {
59
+ const r = normalizeVideoRequestParams("kling-turbo", { resolution: "1080p" })
60
+ expect(r.resolution).toBe("1080p")
61
+ expect(r.adjustments).toEqual([])
62
+ })
63
+
64
+ it("leaves an unknown model completely alone", () => {
65
+ const r = normalizeVideoRequestParams("not-a-model", { aspectRatio: "9:21", resolution: "720p" })
66
+ expect(r).toMatchObject({ aspectRatio: "9:21", resolution: "720p", adjustments: [] })
67
+ })
68
+
69
+ it("is idempotent", () => {
70
+ const once = normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "9:21", resolution: "1080p" })
71
+ const twice = normalizeVideoRequestParams("seedance-2-5", once)
72
+ expect(twice.aspectRatio).toBe(once.aspectRatio)
73
+ expect(twice.resolution).toBe(once.resolution)
74
+ expect(twice.adjustments).toEqual([])
75
+ })
76
+
77
+ // The normalizer runs in the creditGuard preHandler (BEFORE the route's Zod
78
+ // parse) and in every buildPayload branch (whose `data` is unvalidated
79
+ // persisted workflow JSON, and whose aspectRatio can be FieldMapping-injected
80
+ // at run time). A non-string lever must therefore coerce, never throw: a throw
81
+ // is a 500 where the route used to return a clean Zod 400, and in the DAG it
82
+ // takes the whole run down after sibling nodes have already reserved.
83
+ it("coerces a NUMERIC lever exactly like its string form, instead of throwing", () => {
84
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: 1080 as never }).resolution)
85
+ .toBe(normalizeVideoRequestParams("seedance-2-5", { resolution: "1080" }).resolution)
86
+ expect(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: 16 as never }).aspectRatio)
87
+ .toBe(normalizeVideoRequestParams("seedance-2-5", { aspectRatio: "16" }).aspectRatio)
88
+ // "1080" is not "1080p", so it snaps to the nearest band rather than matching.
89
+ expect(normalizeVideoRequestParams("seedance-2-5", { resolution: 1080 as never }).resolution).toBe("1080p")
90
+ })
91
+
92
+ it("snaps a non-string lever FAIL-SAFE rather than throwing", () => {
93
+ for (const junk of [{}, [1, 2], true, () => {}]) {
94
+ const r = () => normalizeVideoRequestParams("seedance-2-5", { resolution: junk as never, aspectRatio: junk as never })
95
+ expect(r, `resolution/aspectRatio = ${String(junk)}`).not.toThrow()
96
+ const out = r()
97
+ // Unparseable ⇒ the highest declared band (never the cheapest, R7) and a
98
+ // concrete ratio — always a value the model actually accepts.
99
+ expect(MODEL_CATALOG["seedance-2-5"]!.resolutions).toContain(out.resolution)
100
+ expect(MODEL_CATALOG["seedance-2-5"]!.aspectRatios).toContain(out.aspectRatio)
101
+ }
102
+ })
103
+
104
+ it("reads null / blank as ABSENT, and never hands the raw value back", () => {
105
+ // The return type promises `string | undefined` and its callers feed it
106
+ // straight to the credit identifier and the provider wire, so a `null` or
107
+ // `""` must come back as `undefined` (the priced fill then supplies the band
108
+ // the identifier assumes) rather than as the caller's own value.
109
+ for (const blank of [null, "", " "]) {
110
+ const r = normalizeVideoRequestParams("seedance-2-5", { resolution: blank as never, aspectRatio: blank as never })
111
+ expect(r.resolution, `resolution = ${JSON.stringify(blank)}`).toBeUndefined()
112
+ expect(r.aspectRatio, `aspectRatio = ${JSON.stringify(blank)}`).toBeUndefined()
113
+ expect(r.adjustments).toEqual([])
114
+ }
115
+ })
116
+
117
+ it("never returns a non-string lever, even for an unknown model", () => {
118
+ const r = normalizeVideoRequestParams("not-a-model", { resolution: 1080 as never, aspectRatio: 16 as never })
119
+ expect(r.resolution).toBe("1080")
120
+ expect(r.aspectRatio).toBe("16")
121
+ })
122
+
123
+ it("leaves an omitted lever omitted — the pricing fill is a separate, deliberate step", () => {
124
+ const r = normalizeVideoRequestParams("ltx-2.3-pro", {})
125
+ expect(r.resolution).toBeUndefined()
126
+ expect(r.aspectRatio).toBeUndefined()
127
+ expect(r.adjustments).toEqual([])
128
+ })
129
+ })
130
+
131
+ describe("pricedVideoSelection", () => {
132
+ // (A) The identifier prices an ABSENT resolution as a concrete band. Where
133
+ // that band is the platform's DECLARED provider default, it must also be the
134
+ // value we send — reserving 1080p and sending no key at all let Replicate
135
+ // pick its own undocumented default.
136
+ it("fills the declared default band for LTX", () => {
137
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro" }).resolution).toBe("1080p")
138
+ expect(pricedVideoSelection({ provider: "ltx-2.3-fast" }).resolution).toBe("1080p")
139
+ })
140
+
141
+ it("fills the declared default band for the providers that declare one", () => {
142
+ expect(pricedVideoSelection({ provider: "seedance-2-5" }).resolution).toBe("720p")
143
+ expect(pricedVideoSelection({ provider: "wan-3" }).resolution).toBe("720p")
144
+ expect(pricedVideoSelection({ provider: "wan-3-prime" }).resolution).toBe("720p")
145
+ })
146
+
147
+ it("fills NOTHING for a provider with no declared default — its identifier fallback is a hedge, not a verified provider default", () => {
148
+ // seedance-2 / -fast / -mini pin resolution 720p KIE-side but have no
149
+ // PRICING_DEFAULT_RESOLUTION row, so the identifier prices 480p. Filling
150
+ // 480p on the wire would DOWNGRADE the render to match a known-wrong price.
151
+ expect(pricedVideoSelection({ provider: "seedance-2" }).resolution).toBeUndefined()
152
+ expect(pricedVideoSelection({ provider: "seedance-2-mini" }).resolution).toBeUndefined()
153
+ expect(pricedVideoSelection({ provider: "kling-turbo" }).resolution).toBeUndefined()
154
+ expect(pricedVideoSelection({ provider: "veo3" }).resolution).toBeUndefined()
155
+ })
156
+
157
+ it("never overrides an explicit resolution", () => {
158
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", resolution: "4k" }).resolution).toBe("4k")
159
+ expect(pricedVideoSelection({ provider: "seedance-2-5", resolution: "480p" }).resolution).toBe("480p")
160
+ })
161
+
162
+ // (B) LTX duration: the identifier snaps onto a SEEDED per-band tier, so the
163
+ // wire must carry that tier. 7s prices as 6s — send 6s.
164
+ it("carries the seeded LTX duration tier the identifier priced", () => {
165
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", duration: 7 }).duration).toBe(6)
166
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", resolution: "4k", duration: 20 }).duration).toBe(10)
167
+ expect(pricedVideoSelection({ provider: "ltx-2.3-fast", duration: 20 }).duration).toBe(20)
168
+ // 20s exists only at 1080p — a 2k request snaps back onto that band's ladder.
169
+ expect(pricedVideoSelection({ provider: "ltx-2.3-fast", resolution: "2k", duration: 20 }).duration).toBe(10)
170
+ })
171
+
172
+ it("reports the LTX duration snap as an adjustment, but never the omitted-value fill", () => {
173
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", duration: 7 }).adjustments).toHaveLength(1)
174
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro", duration: 6 }).adjustments).toEqual([])
175
+ expect(pricedVideoSelection({ provider: "ltx-2.3-pro" }).adjustments).toEqual([])
176
+ })
177
+
178
+ it("leaves duration alone for every non-LTX provider (legality is not a flat catalog list)", () => {
179
+ expect(pricedVideoSelection({ provider: "seedance-2-5", duration: 7 }).duration).toBeUndefined()
180
+ expect(pricedVideoSelection({ provider: "kling-3.0", duration: 7 }).duration).toBeUndefined()
181
+ })
182
+ })
183
+
184
+ /**
185
+ * The invariant that makes the FILL safe: carrying the priced selection to the
186
+ * wire can never move the reserved tier. (The catalog snap that runs before it
187
+ * legitimately can — an off-list "4K" on a 1080p-max model is priced at the
188
+ * band we will actually render, which is the whole point. This pins the second
189
+ * step only: given the snapped value, filling what the identifier assumed is a
190
+ * disclosure of the price already being charged, never a repricing.)
191
+ */
192
+ describe("pricedVideoSelection cannot move the credit identifier", () => {
193
+ const RESOLUTIONS = [undefined, "480p", "720p", "1080p", "2k", "4k", "4K", "720P"]
194
+ const DURATIONS = [undefined, 4, 5, 6, 7, 8, 10, 12, 20, 30]
195
+
196
+ it("covers every catalogued video provider", () => {
197
+ expect(VIDEO_GEN_PROVIDERS.length).toBeGreaterThan(20)
198
+ })
199
+
200
+ for (const nodeType of ["text-to-video", "image-to-video"] as const) {
201
+ it(`${nodeType}: id(priced) === id(raw) for every provider × resolution × duration`, () => {
202
+ const drift: string[] = []
203
+ for (const provider of VIDEO_GEN_PROVIDERS) {
204
+ for (const rawRes of RESOLUTIONS) {
205
+ for (const rawDur of DURATIONS) {
206
+ for (const hasVideoRef of [false, true]) {
207
+ const norm = normalizeVideoRequestParams(provider, { resolution: rawRes })
208
+ const priced = pricedVideoSelection({ provider, resolution: norm.resolution, duration: rawDur })
209
+ const rawId = buildVideoCreditModelIdentifier(provider, rawDur, undefined, nodeType, undefined, norm.resolution, hasVideoRef)
210
+ const pricedId = buildVideoCreditModelIdentifier(
211
+ provider,
212
+ priced.duration ?? rawDur,
213
+ undefined,
214
+ nodeType,
215
+ undefined,
216
+ norm.resolution ?? priced.resolution,
217
+ hasVideoRef,
218
+ )
219
+ if (rawId !== pricedId) {
220
+ drift.push(`${provider} res=${rawRes} dur=${rawDur} ref=${hasVideoRef}: ${rawId} → ${pricedId}`)
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ expect(drift, `the normalize+fill pair moved the reserved tier:\n${drift.join("\n")}`).toEqual([])
227
+ })
228
+ }
229
+
230
+ it("only fills a resolution the model's own catalog declares", () => {
231
+ for (const provider of VIDEO_GEN_PROVIDERS) {
232
+ const filled = pricedVideoSelection({ provider }).resolution
233
+ if (filled === undefined) continue
234
+ const declared = MODEL_CATALOG[provider]?.resolutions as readonly string[] | undefined
235
+ expect(declared, `${provider} fills "${filled}" but declares no resolutions`).toBeDefined()
236
+ expect(declared, `${provider} fills "${filled}", which is not in its catalog list`).toContain(filled)
237
+ }
238
+ })
239
+ })
@@ -28,7 +28,7 @@ import {
28
28
  getVideoAudioCapability,
29
29
  } from "./model-constants.js"
30
30
  import { isFlux2Model } from "./flux2-pricing.js"
31
- import { MODEL_CATALOG } from "./model-catalog.js"
31
+ import { MODEL_CATALOG, normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
32
32
 
33
33
  /**
34
34
  * Compute composite model identifier for variable credit pricing.
@@ -80,6 +80,90 @@ export function buildCreditModelIdentifier(
80
80
  return provider
81
81
  }
82
82
 
83
+ /**
84
+ * An image request's catalog-snapped parameters PLUS the credit identifier
85
+ * priced off them.
86
+ *
87
+ * `adjustments` is the disclosure channel: every lever this changed, why, and
88
+ * to what. Routes return it in the 200 body so a caller learns their value was
89
+ * corrected instead of silently getting something else.
90
+ */
91
+ export interface NormalizedImageGen {
92
+ /** Credit identifier, priced off the SNAPPED `resolution` / `quality`. */
93
+ identifier: string
94
+ /** The catalog model id the params were snapped against (post T2I→I2I swap). */
95
+ modelId: string
96
+ aspectRatio?: string
97
+ resolution?: string
98
+ quality?: string
99
+ /** Empty when the caller's values were already valid for `modelId`. */
100
+ adjustments: ModelInputAdjustment[]
101
+ }
102
+
103
+ /**
104
+ * Snap an image request's catalog-governed levers to a combination the model
105
+ * actually accepts, and price the credit identifier off the SNAPPED values.
106
+ *
107
+ * WHY THE SNAP LIVES HERE and not at the call site. Both image routes compute
108
+ * their credit identifier TWICE — once in the `creditGuard` preHandler (the
109
+ * CHECK) and once at the handler's `reserveCreditsForJob` (the DEBIT) — and a
110
+ * dedicated test asserts the two stay byte-identical (see
111
+ * `backend/src/routes/__tests__/generate-image.test.ts`, "CHECK === DEBIT").
112
+ * `resolution` and `quality` are pricing dimensions, so a snap applied at only
113
+ * one of those sites breaks that invariant, and `commit_credits` never collects
114
+ * an upward delta — the reserve IS the charge. Putting the snap inside the
115
+ * primitive makes CHECK, DEBIT and the workflow orchestrator agree by
116
+ * construction rather than by everyone remembering to call a normalizer.
117
+ *
118
+ * Inputs are typed `unknown` on purpose: the preHandler runs BEFORE Zod, so a
119
+ * caller can put a number or an object in `resolution`. Non-strings become
120
+ * `undefined` rather than reaching `normalizeModelInput` as a lie.
121
+ *
122
+ * Unknown model ids pass through untouched (same contract as
123
+ * `normalizeModelInput`) — the route's provider enum is the gate for those.
124
+ */
125
+ export function resolveNormalizedImageGen(opts: {
126
+ provider: string | undefined
127
+ aspectRatio?: unknown
128
+ quality?: unknown
129
+ resolution?: unknown
130
+ renderingSpeed?: unknown
131
+ refCount: number
132
+ swapToI2i?: boolean
133
+ }): NormalizedImageGen {
134
+ const str = (v: unknown): string | undefined =>
135
+ typeof v === "string" && v.length > 0 ? v : undefined
136
+
137
+ const provider = str(opts.provider) ?? "nano-banana"
138
+ // Same swap `resolveEffectiveProvider` applies in the route: refs attached to
139
+ // a bare T2I provider route the run to its i2i sibling, which has its OWN
140
+ // catalog entry and its own lever lists.
141
+ const modelId =
142
+ opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
143
+
144
+ const n = normalizeModelInput(modelId, {
145
+ aspectRatio: str(opts.aspectRatio),
146
+ resolution: str(opts.resolution),
147
+ quality: str(opts.quality),
148
+ })
149
+
150
+ return {
151
+ identifier: buildCreditModelIdentifier(
152
+ modelId,
153
+ n.quality,
154
+ n.resolution,
155
+ str(opts.renderingSpeed),
156
+ undefined,
157
+ opts.refCount,
158
+ ),
159
+ modelId,
160
+ aspectRatio: n.aspectRatio,
161
+ resolution: n.resolution,
162
+ quality: n.quality,
163
+ adjustments: n.adjustments,
164
+ }
165
+ }
166
+
83
167
  /**
84
168
  * Reference-aware image-generation credit identifier — the SINGLE source of
85
169
  * truth shared by the single-node routes (`/v1/generate-image`,
@@ -107,17 +191,7 @@ export function resolveImageGenCreditIdentifier(opts: {
107
191
  refCount: number
108
192
  swapToI2i?: boolean
109
193
  }): string {
110
- const provider = opts.provider || "nano-banana"
111
- const effectiveProvider =
112
- opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
113
- return buildCreditModelIdentifier(
114
- effectiveProvider,
115
- opts.quality,
116
- opts.resolution,
117
- opts.renderingSpeed,
118
- undefined,
119
- opts.refCount,
120
- )
194
+ return resolveNormalizedImageGen(opts).identifier
121
195
  }
122
196
 
123
197
  // T2V-specific credit overrides: some providers have different costs for T2V
@@ -161,6 +235,32 @@ const LTX_DURATION_TIERS: Record<string, Record<string, number[]>> = {
161
235
  "ltx-2.3-fast": { "1080p": [6, 8, 10, 12, 14, 16, 18, 20], "2k": [6, 8, 10], "4k": [6, 8, 10] },
162
236
  }
163
237
 
238
+ /**
239
+ * The (band, duration) tier the LTX pricing ladder charges for a request, or
240
+ * `undefined` for a non-LTX provider. Extracted so
241
+ * `buildVideoCreditModelIdentifier` and `pricedVideoSelection` read the ladder
242
+ * ONCE — the routes must send the tier they reserved, and a second copy of this
243
+ * math is exactly how the two drift apart.
244
+ *
245
+ * An unknown/absent band falls back to 1080p (LTX's own default, and the band
246
+ * `snapLtxInput` snaps to at the Replicate call), and an off-tier duration
247
+ * snaps to the NEAREST seeded one so the emitted composite always prices.
248
+ */
249
+ function ltxPricedTier(
250
+ provider: string,
251
+ resolution?: string,
252
+ duration?: number | string,
253
+ ): { band: string; duration: number } | undefined {
254
+ const bands = LTX_DURATION_TIERS[provider]
255
+ if (!bands) return undefined
256
+ const band = bands[String(resolution)] ? String(resolution) : "1080p"
257
+ const allowed = bands[band]!
258
+ const raw = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? allowed[0]!)
259
+ const want = Number.isNaN(raw) ? allowed[0]! : raw
260
+ const dur = allowed.reduce((b, a) => (Math.abs(a - want) < Math.abs(b - want) ? a : b))
261
+ return { band, duration: dur }
262
+ }
263
+
164
264
  export function buildVideoCreditModelIdentifier(
165
265
  provider: string,
166
266
  duration?: number | string,
@@ -218,14 +318,9 @@ export function buildVideoCreditModelIdentifier(
218
318
  // (actual < reserved) and NEVER collects an upward delta, so an under-reserved
219
319
  // LTX run (the bare-id default = cheapest 1080p:6s tier) stays under-charged
220
320
  // even with meteredCost:true. Snap to a seeded tier so the id always prices.
221
- if (effectiveProvider === "ltx-2.3-pro" || effectiveProvider === "ltx-2.3-fast") {
222
- const bands = LTX_DURATION_TIERS[effectiveProvider]
223
- const band = bands[String(resolution)] ? String(resolution) : "1080p"
224
- const allowed = bands[band]
225
- const raw = typeof duration === "string" ? parseInt(duration, 10) : (duration ?? allowed[0])
226
- const want = Number.isNaN(raw) ? allowed[0] : raw
227
- const dur = allowed.reduce((b, a) => (Math.abs(a - want) < Math.abs(b - want) ? a : b))
228
- return `${effectiveProvider}:${band}:${dur}s`
321
+ const ltxTier = ltxPricedTier(effectiveProvider, resolution, duration)
322
+ if (ltxTier) {
323
+ return `${effectiveProvider}:${ltxTier.band}:${ltxTier.duration}s`
229
324
  }
230
325
 
231
326
  if (!DURATION_PRICED_PROVIDERS.has(effectiveProvider)) {
@@ -331,6 +426,92 @@ export function buildVideoCreditModelIdentifier(
331
426
  return identifier
332
427
  }
333
428
 
429
+ /** What the video credit identifier PRICES for a request, for the levers whose
430
+ * priced value must also be the value we SEND. */
431
+ export interface PricedVideoSelection {
432
+ /** The resolution band the reservation is priced at when the request omitted
433
+ * one — and only where the platform DECLARES that band as the provider's own
434
+ * default. `undefined` means "leave `resolution` exactly as the request had
435
+ * it": either the caller supplied one, or the provider's real default is not
436
+ * known to be the band the identifier assumes. */
437
+ resolution?: string
438
+ /** The seeded duration tier the reservation is priced at (LTX only — the one
439
+ * family whose duration ladder is per-band and case-sensitively seeded).
440
+ * `undefined` for every other provider: their duration passes through. */
441
+ duration?: number
442
+ /** Non-empty only for a lever the caller ASKED for and did not get (an LTX
443
+ * 7s snapped to the 6s tier). Filling an omitted lever is a disclosure of
444
+ * the price already being charged, not a correction, so it reports nothing. */
445
+ adjustments: ModelInputAdjustment[]
446
+ }
447
+
448
+ /**
449
+ * The other half of the video money path: `normalizeVideoRequestParams` snaps a
450
+ * value the CATALOG governs; this returns the value the PRICE ladder governs.
451
+ *
452
+ * Two defects it closes, both of the same shape — the identifier prices one
453
+ * thing and the wire carries another, and `commit_credits` (migration 176) only
454
+ * ever refunds a surplus, so the reservation is the final charge:
455
+ *
456
+ * 1. **Absent resolution.** `buildVideoCreditModelIdentifier` prices an omitted
457
+ * `resolution` as a concrete band (LTX → 1080p; a
458
+ * {@link PRICING_DEFAULT_RESOLUTION} member → its declared default). Leaving
459
+ * the key unset then lets the provider pick — documented for the KIE members
460
+ * (their `extraParams` pin the same band) but UNDOCUMENTED for LTX on
461
+ * Replicate. Sending the band we priced makes the two agree by construction.
462
+ *
463
+ * The fill is deliberately limited to providers with a DECLARED default.
464
+ * `buildVideoCreditModelIdentifier` also has un-declared fallbacks — the
465
+ * cheapest tier for a resolution-priced provider with no
466
+ * `PRICING_DEFAULT_RESOLUTION` row — and those are a HEDGE, not a verified
467
+ * provider default: seedance-2 / -fast / -mini pin `resolution: "720p"`
468
+ * KIE-side while the identifier prices 480p, so filling 480p would downgrade
469
+ * the render to match a price we already know is wrong. That mismatch is a
470
+ * pre-existing identifier bug and is fixed by seeding the row, not here.
471
+ *
472
+ * 2. **LTX duration.** The LTX ladder is seeded per (band × seconds), so the
473
+ * identifier snaps 7s onto the 6s tier. Sending 7s bills six and renders
474
+ * seven. Most other providers' durations pass through untouched: their
475
+ * legality is a flat catalog list the caller already sees, and their tiers
476
+ * are ranges (`durationSec <= maxSeconds`), not seeded points. Gemini Omni
477
+ * is the one other seeded ladder — `GEMINI_OMNI_DURATIONS` ([4, 6, 8, 10])
478
+ * is nearest-snapped by `buildVideoCreditModelIdentifier`, the same shape
479
+ * as LTX — but this function does not carry it yet: a 7s request prices
480
+ * `:6` while 7s is still what gets sent. Pre-existing, out of scope here,
481
+ * and ticketed as a follow-up (`geminiOmniPricedTier`, mirroring
482
+ * `ltxPricedTier`).
483
+ *
484
+ * Pure, and IDEMPOTENT against the identifier: feeding its output back in
485
+ * cannot move the reserved tier. `video-request-normalize.test.ts` proves that
486
+ * over every provider × resolution × duration.
487
+ */
488
+ export function pricedVideoSelection(opts: {
489
+ provider: string
490
+ resolution?: string
491
+ duration?: number | string
492
+ }): PricedVideoSelection {
493
+ const adjustments: ModelInputAdjustment[] = []
494
+ const ltx = ltxPricedTier(opts.provider, opts.resolution, opts.duration)
495
+
496
+ // The band the request will be priced AND rendered at. An explicit value
497
+ // always wins — this only ever fills an omission.
498
+ const resolution = opts.resolution
499
+ ?? (ltx ? ltx.band : PRICING_DEFAULT_RESOLUTION[opts.provider])
500
+
501
+ if (!ltx) return { resolution, adjustments }
502
+
503
+ const requested = typeof opts.duration === "string" ? parseInt(opts.duration, 10) : opts.duration
504
+ if (requested !== undefined && !Number.isNaN(requested) && requested !== ltx.duration) {
505
+ adjustments.push({
506
+ field: "duration",
507
+ from: requested,
508
+ to: ltx.duration,
509
+ reason: `LTX renders ${ltx.band} in ${ltx.duration}s steps — using ${ltx.duration}s instead of ${requested}s.`,
510
+ })
511
+ }
512
+ return { resolution, duration: ltx.duration, adjustments }
513
+ }
514
+
334
515
  /**
335
516
  * Compute composite model identifier for motion control with duration-tiered pricing.
336
517
  * Examples: "kling-3.0-motion:10s", "kling-3.0-motion:1080p:15s", "motion-transfer:5s"
@@ -365,3 +546,66 @@ export function buildMotionCreditModelIdentifier(
365
546
  const resSuffix = resolution === "1080p" ? ":1080p" : ""
366
547
  return `${base}${resSuffix}:${tier.suffix}`
367
548
  }
549
+
550
+ /**
551
+ * The Suno operations whose credit key depends on the model VERSION.
552
+ *
553
+ * `/v1/suno/generate`, `/cover` and `/extend` resolve their creditGuard
554
+ * identifier through `sunoCreditType(model, <operation>)` (routes/suno.ts
555
+ * :247-249, :335-337, :407-409). EVERY other Suno route charges a flat
556
+ * per-operation key and ignores the version the node carries — mashup (:648),
557
+ * add-instrumental (:852), add-vocals (:907), upload-extend (:1016),
558
+ * replace-section (:710), style-boost (:771), convert-wav (:962),
559
+ * lyrics (:482), music-video (:594), voice (:1218).
560
+ *
561
+ * Anything that QUOTES a Suno price — a dropdown row, a node badge, the
562
+ * workflow-credit estimator — must follow the same split, or it displays a
563
+ * credit key the route never charges.
564
+ */
565
+ export const SUNO_VERSION_PRICED_OPERATIONS = [
566
+ "suno-generate",
567
+ "suno-cover",
568
+ "suno-extend",
569
+ ] as const
570
+
571
+ /**
572
+ * The seven Suno operations that appear behind a select/dropdown UI (model
573
+ * pickers, node badges, the credit estimator) — the three version-priced
574
+ * operations above, plus the four flat-key operations exercised by
575
+ * `sunoCreditType`'s own test suite. A readonly tuple so later tasks (the
576
+ * model dropdowns, node badges) can iterate or count it without redeclaring
577
+ * the list and drifting from this one.
578
+ */
579
+ export const SUNO_SELECT_OPERATIONS = [
580
+ "suno-generate",
581
+ "suno-cover",
582
+ "suno-extend",
583
+ "suno-mashup",
584
+ "suno-add-instrumental",
585
+ "suno-add-vocals",
586
+ "suno-upload-extend",
587
+ ] as const
588
+
589
+ /**
590
+ * OUR Nodaro credit key for a Suno operation, given the model version and the
591
+ * operation (which is also the node type and the BullMQ job name).
592
+ *
593
+ * This is the single source of truth for the Suno pricing contract, shared by
594
+ * the reservation path (routes/suno.ts creditGuard), the egress seam
595
+ * (workers/handlers/suno.ts `modelKey`, which must match what was reserved),
596
+ * both workflow-credit estimators (ee/billing/credits.ts and the frontend
597
+ * config-panels/helpers.ts), the seven Suno model dropdowns and the three Suno
598
+ * node badges. It is a billing key — NEVER a KIE provider id — which is what
599
+ * satisfies the B3 egress invariant.
600
+ *
601
+ * V5_5 → "suno-v5_5", V5 → "suno-v5", any other version → the operation key.
602
+ * A non-version-priced operation is returned unchanged.
603
+ */
604
+ export function sunoCreditType(model: string | undefined, operation: string): string {
605
+ if (!(SUNO_VERSION_PRICED_OPERATIONS as readonly string[]).includes(operation)) {
606
+ return operation
607
+ }
608
+ if (model === "V5_5") return "suno-v5_5"
609
+ if (model === "V5") return "suno-v5"
610
+ return operation
611
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export { usdToCredits, creditsToUsd, CREDIT_ROUNDING_RESOLUTION } from "./credit
18
18
  export {
19
19
  CREDIT_BASE_USD,
20
20
  IMAGE_PROMPT_MAX,
21
+ IMAGE_ASPECT_RATIO_VALUES,
21
22
  MAX_IMAGE_PROMPT_CHARS_BY_PROVIDER,
22
23
  getMaxImagePromptChars,
23
24
  PROMPT_HARD_CEILING,
@@ -35,6 +36,7 @@ export {
35
36
  getMaxSunoStyleChars,
36
37
  VIDEO_PROMPT_MAX,
37
38
  SUNO_TEXT_MAX,
39
+ SUNO_HARD_CEILING,
38
40
  NATIVE_NEGATIVE_PROMPT_MODELS,
39
41
  NATIVE_NEGATIVE_VIDEO_PROVIDERS,
40
42
  applyVideoNegativePrompt,
@@ -106,6 +108,8 @@ export {
106
108
  NATIVE_ADAPTIVE_ASPECT,
107
109
  FRAME_MODE_ADAPTIVE_ONLY_ASPECT,
108
110
  VIDEO_REF_LIMITS_BY_PROVIDER,
111
+ VIDEO_REF_VIDEO_DURATION_LIMITS,
112
+ checkRefVideoDurations,
109
113
  VIDEO_PROVIDERS_REQUIRING_IMAGE,
110
114
  videoProviderRequiresImage,
111
115
  VIDEO_MODE_ALIASES,
@@ -173,6 +177,7 @@ export { FEATURED_ENTITIES, getFeaturedEntities } from "./featured-entities.js"
173
177
  export type { FeaturedEntity } from "./featured-entities.js"
174
178
 
175
179
  export type {
180
+ ImageAspectRatio,
176
181
  ImageGenProvider,
177
182
  ImageMaskMode,
178
183
  ImageI2IProvider,
@@ -209,6 +214,7 @@ export type {
209
214
  VideoAudioCapability,
210
215
  GvpAnchorChoice,
211
216
  GvpAnchorWireMode,
217
+ RefVideoDurationLimit,
212
218
  } from "./model-constants.js"
213
219
 
214
220
 
@@ -234,13 +240,28 @@ export type {
234
240
  export {
235
241
  buildCreditModelIdentifier,
236
242
  resolveImageGenCreditIdentifier,
243
+ resolveNormalizedImageGen,
237
244
  buildVideoCreditModelIdentifier,
245
+ pricedVideoSelection,
238
246
  buildMotionCreditModelIdentifier,
247
+ sunoCreditType,
248
+ SUNO_VERSION_PRICED_OPERATIONS,
249
+ SUNO_SELECT_OPERATIONS,
239
250
  } from "./credit-identifiers.js"
251
+ export type { NormalizedImageGen, PricedVideoSelection } from "./credit-identifiers.js"
240
252
 
241
253
  export * from "./credit-estimators/index.js"
242
254
  export { extractVideoDurationFromNode } from "./video-duration.js"
243
255
 
256
+ export {
257
+ resolveTopazUpscale,
258
+ TOPAZ_UPSCALE_FACTORS,
259
+ TOPAZ_DEFAULT_UPSCALE_FACTOR,
260
+ type TopazUpscaleFactor,
261
+ type TopazUpscaleAdjustment,
262
+ type TopazUpscaleResolution,
263
+ } from "./topaz-upscale.js"
264
+
244
265
 
245
266
 
246
267
 
@@ -617,8 +638,8 @@ export type { LottieOverlayCatalogEntry } from "./lottie-overlay-catalog.js"
617
638
 
618
639
  export { resolveFieldMappings, resolveLocationFields } from "./resolve-field-mappings.js"
619
640
 
620
- export { resolveNodeRefs, parseNodeRef, canonicalVarName, NODE_REF_PATTERN, RESERVED_TEMPLATE_VARS, extractReferencedLabels, combineSameLabelRefs, refHandleCategory, REF_HANDLE_CATEGORY, REFERENCE_HANDLE_MAP, referenceModalityForHandle, FRAME_TARGET_HANDLES, countRefModalityEdges } from "./node-refs.js"
621
- export type { RefCandidate, ReferenceModality, RefModalityEdge } from "./node-refs.js"
641
+ export { resolveNodeRefs, parseNodeRef, canonicalVarName, NODE_REF_PATTERN, RESERVED_TEMPLATE_VARS, extractReferencedLabels, combineSameLabelRefs, refHandleCategory, REF_HANDLE_CATEGORY, REFERENCE_HANDLE_MAP, referenceModalityForHandle, FRAME_TARGET_HANDLES, countRefModalityEdges, REF_TOKEN_NAMESPACE_PREFIXES, classifyRefToken, unresolvedRefTokens } from "./node-refs.js"
642
+ export type { RefCandidate, ReferenceModality, RefModalityEdge, RefTokenKind } from "./node-refs.js"
622
643
 
623
644
 
624
645
  export { resolveSourceThroughConnectedList } from "./list-source-resolver.js"
@@ -719,9 +740,11 @@ export {
719
740
  modelIdsByKindMode,
720
741
  buildModelMenu,
721
742
  normalizeModelInput,
743
+ normalizeVideoRequestParams,
722
744
  defaultResolutionFor,
723
745
  } from "./model-catalog.js"
724
746
  export type {
747
+ NormalizedVideoRequest,
725
748
  ModelCatalogEntry,
726
749
  ModelKind,
727
750
  ModelMode,
@@ -735,6 +758,11 @@ export type {
735
758
  NormalizedModelInput,
736
759
  } from "./model-catalog.js"
737
760
 
761
+ // Per-model safety-filter retry/fallback policy (derives from
762
+ // `ModelCatalogEntry.safetyFilter` above).
763
+ export { safetyRetryPolicy } from "./safety-retry-policy.js"
764
+ export type { SafetyRetryPolicy } from "./safety-retry-policy.js"
765
+
738
766
  export {
739
767
  STATIC_CAPTION_STYLES,
740
768
  KINETIC_CAPTION_STYLES,