@nodaro/shared 2.18.0 → 2.20.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 (83) hide show
  1. package/dist/index.cjs +350 -23
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +955 -564
  4. package/dist/index.d.ts +955 -564
  5. package/dist/index.js +334 -24
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/credit-identifiers.test.ts +180 -0
  9. package/src/__tests__/gvp-supported-providers.test.ts +22 -1
  10. package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
  11. package/src/__tests__/normalize-node-params.test.ts +36 -0
  12. package/src/__tests__/organizations-types.test.ts +29 -1
  13. package/src/__tests__/prompt-length-limits.test.ts +40 -0
  14. package/src/__tests__/suno-credit-type.test.ts +54 -0
  15. package/src/__tests__/video-analysis-brief.test.ts +61 -0
  16. package/src/__tests__/video-audio-capability.test.ts +43 -0
  17. package/src/__tests__/video-mode-for-inputs.test.ts +1 -1
  18. package/src/__tests__/wan-3-catalog.test.ts +243 -0
  19. package/src/credit-identifiers.ts +191 -21
  20. package/src/i18n/held-prop.ar.ts +2 -0
  21. package/src/i18n/held-prop.de.ts +3 -0
  22. package/src/i18n/held-prop.es.ts +3 -0
  23. package/src/i18n/held-prop.fr.ts +2 -0
  24. package/src/i18n/held-prop.he.ts +3 -0
  25. package/src/i18n/held-prop.hi.ts +2 -0
  26. package/src/i18n/held-prop.ja.ts +3 -0
  27. package/src/i18n/held-prop.ko.ts +3 -0
  28. package/src/i18n/held-prop.pt-BR.ts +3 -0
  29. package/src/i18n/held-prop.ru.ts +2 -0
  30. package/src/i18n/held-prop.zh-CN.ts +3 -0
  31. package/src/i18n/person.ar.ts +3 -0
  32. package/src/i18n/person.de.ts +4 -0
  33. package/src/i18n/person.es.ts +4 -0
  34. package/src/i18n/person.fr.ts +3 -0
  35. package/src/i18n/person.he.ts +4 -0
  36. package/src/i18n/person.hi.ts +3 -0
  37. package/src/i18n/person.ja.ts +4 -0
  38. package/src/i18n/person.ko.ts +4 -0
  39. package/src/i18n/person.pt-BR.ts +4 -0
  40. package/src/i18n/person.ru.ts +3 -0
  41. package/src/i18n/person.zh-CN.ts +4 -0
  42. package/src/i18n/setting.ar.ts +2 -0
  43. package/src/i18n/setting.de.ts +3 -0
  44. package/src/i18n/setting.es.ts +3 -0
  45. package/src/i18n/setting.fr.ts +2 -0
  46. package/src/i18n/setting.he.ts +3 -0
  47. package/src/i18n/setting.hi.ts +2 -0
  48. package/src/i18n/setting.ja.ts +3 -0
  49. package/src/i18n/setting.ko.ts +3 -0
  50. package/src/i18n/setting.pt-BR.ts +3 -0
  51. package/src/i18n/setting.ru.ts +2 -0
  52. package/src/i18n/setting.zh-CN.ts +3 -0
  53. package/src/i18n/style.ar.ts +2 -0
  54. package/src/i18n/style.de.ts +3 -0
  55. package/src/i18n/style.es.ts +3 -0
  56. package/src/i18n/style.fr.ts +2 -0
  57. package/src/i18n/style.he.ts +3 -0
  58. package/src/i18n/style.hi.ts +2 -0
  59. package/src/i18n/style.ja.ts +3 -0
  60. package/src/i18n/style.ko.ts +3 -0
  61. package/src/i18n/style.pt-BR.ts +3 -0
  62. package/src/i18n/style.ru.ts +2 -0
  63. package/src/i18n/style.zh-CN.ts +3 -0
  64. package/src/i18n/styling.ar.ts +4 -0
  65. package/src/i18n/styling.de.ts +5 -0
  66. package/src/i18n/styling.es.ts +5 -0
  67. package/src/i18n/styling.fr.ts +4 -0
  68. package/src/i18n/styling.he.ts +5 -0
  69. package/src/i18n/styling.hi.ts +4 -0
  70. package/src/i18n/styling.ja.ts +5 -0
  71. package/src/i18n/styling.ko.ts +5 -0
  72. package/src/i18n/styling.pt-BR.ts +5 -0
  73. package/src/i18n/styling.ru.ts +4 -0
  74. package/src/i18n/styling.zh-CN.ts +5 -0
  75. package/src/index.ts +17 -0
  76. package/src/model-catalog.ts +101 -0
  77. package/src/model-constants.ts +251 -16
  78. package/src/node-default-mappings.ts +5 -0
  79. package/src/normalize-node-params.ts +8 -0
  80. package/src/organizations/types.ts +12 -0
  81. package/src/organizations/views.ts +114 -0
  82. package/src/video-analysis.ts +45 -0
  83. package/src/video-ui-defaults.ts +66 -0
@@ -63,6 +63,21 @@ describe("getVideoAudioCapability", () => {
63
63
  expect(cap.field).toBe("generateAudio")
64
64
  })
65
65
 
66
+ it("returns ambient with the `audio` field for the Wan 3.0 family", () => {
67
+ // Wan 3.0's lever is literally `input.audio` — neither `sound` nor
68
+ // `generate_audio`. Classified "ambient", NOT audio_driven: the KIE schema
69
+ // documents no dialogue guarantee, and reference audio is a generic
70
+ // conditioning array rather than a verified lip-sync transport.
71
+ for (const m of ["wan-3", "wan-3-prime"]) {
72
+ const cap = getVideoAudioCapability(m)
73
+ expect(cap.mode, m).toBe("ambient")
74
+ expect(cap.field, m).toBe("audio")
75
+ expect(cap.defaultOn, m).toBe(true)
76
+ // Audio is priced into the uniform per-second rate — no `:audio` composite.
77
+ expect(cap.affectsCost, m).toBeUndefined()
78
+ }
79
+ })
80
+
66
81
  it("defaults to none for silent / unknown / undefined models", () => {
67
82
  for (const m of [
68
83
  "minimax",
@@ -70,6 +85,9 @@ describe("getVideoAudioCapability", () => {
70
85
  "wan-i2v",
71
86
  "grok-i2v",
72
87
  "gemini-omni-video",
88
+ // Gemini Omni Flash mirrors its sibling: deliberately unlisted, so both
89
+ // Omni SKUs report the same audio capability.
90
+ "gemini-omni-flash",
73
91
  "runway",
74
92
  "pika",
75
93
  "totally-unknown-model",
@@ -192,6 +210,31 @@ describe("applyVideoAudioToggle — neutral audio intent → per-model KIE field
192
210
  expect(v1.generate_audio).toBe(true)
193
211
  })
194
212
 
213
+ it("maps the neutral toggle onto Wan 3.0's `audio` field", () => {
214
+ // The dispatcher used to be a two-way if/else whose `else` wrote
215
+ // `input.sound` — a field the Wan 3.0 contract does not have, so the
216
+ // toggle would have been silently dropped.
217
+ const on: Record<string, unknown> = {}
218
+ applyVideoAudioToggle(on, "wan-3", { sound: true })
219
+ expect(on.audio).toBe(true)
220
+ expect(on.sound).toBeUndefined()
221
+ expect(on.generate_audio).toBeUndefined()
222
+
223
+ const off: Record<string, unknown> = {}
224
+ applyVideoAudioToggle(off, "wan-3-prime", { sound: false })
225
+ expect(off.audio).toBe(false)
226
+
227
+ // Not cost-affecting, so the legacy alias is honoured too.
228
+ const alias: Record<string, unknown> = {}
229
+ applyVideoAudioToggle(alias, "wan-3", { generateAudio: true })
230
+ expect(alias.audio).toBe(true)
231
+
232
+ // No intent → the model's own default (KIE `audio: true`) is left alone.
233
+ const neutral: Record<string, unknown> = {}
234
+ applyVideoAudioToggle(neutral, "wan-3", {})
235
+ expect(neutral).toEqual({})
236
+ })
237
+
195
238
  it("accepts `generateAudio` as a legacy alias on FREE models (Seedance)", () => {
196
239
  const input: Record<string, unknown> = {}
197
240
  applyVideoAudioToggle(input, "seedance", { generateAudio: true })
@@ -43,7 +43,7 @@ describe("resolveVideoModeForInputs", () => {
43
43
  // (generate-video.md mode table). kling-3-omni is i2v-only and keeps its
44
44
  // documented orchestrator behavior (image_required on the t2v path) —
45
45
  // whether refs alone should satisfy it is a separate question from #861.
46
- for (const id of ["seedance-2", "gemini-omni-video", "veo3.1", "kling-3-omni"]) {
46
+ for (const id of ["seedance-2", "gemini-omni-video", "gemini-omni-flash", "veo3.1", "kling-3-omni", "wan-3", "wan-3-prime"]) {
47
47
  expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
48
48
  }
49
49
  })
@@ -0,0 +1,243 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { MODEL_CATALOG } from "../model-catalog.js"
3
+ import {
4
+ IMAGE_TO_VIDEO_PROVIDERS,
5
+ TEXT_TO_VIDEO_PROVIDERS,
6
+ VIDEO_REF_LIMITS_BY_PROVIDER,
7
+ VIDEO_PROVIDERS_REQUIRING_IMAGE,
8
+ getVideoAudioCapability,
9
+ defaultVideoAspectRatio,
10
+ getMaxVideoPromptChars,
11
+ WAN_3_PROVIDERS,
12
+ isWan3Provider,
13
+ WAN_3_DEFAULT_RESOLUTION,
14
+ normalizeWan3Resolution,
15
+ isSeedance2Provider,
16
+ isMinimaxH3Provider,
17
+ SEEDANCE_2_PROVIDERS,
18
+ DURATION_PRICED_PROVIDERS,
19
+ VIDEO_DURATION_TIERS,
20
+ RESOLUTION_DURATION_PRICING,
21
+ PRICING_DEFAULT_RESOLUTION,
22
+ NATIVE_ADAPTIVE_ASPECT,
23
+ SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER,
24
+ GVP_SUPPORTED_PROVIDERS,
25
+ GVP_EXTEND_PROVIDERS,
26
+ } from "../model-constants.js"
27
+ import { buildVideoCreditModelIdentifier } from "../credit-identifiers.js"
28
+
29
+ const WAN_3_IDS = ["wan-3", "wan-3-prime"] as const
30
+ const DURATIONS_2_TO_30 = Array.from({ length: 29 }, (_, i) => i + 2)
31
+
32
+ describe("wan-3 / wan-3-prime catalog", () => {
33
+ for (const id of WAN_3_IDS) {
34
+ describe(id, () => {
35
+ const entry = MODEL_CATALOG[id]
36
+
37
+ it("exists with both video modes under ONE id (no t2v twin, no alias)", () => {
38
+ expect(entry).toBeDefined()
39
+ expect(entry.kind).toBe("video")
40
+ expect([...entry.modes].sort()).toEqual(["i2v", "t2v"])
41
+ expect(entry.series).toBe("Wan")
42
+ expect(entry.family).toBe("Alibaba")
43
+ })
44
+
45
+ it("lists resolutions ASCENDING — the default is declared, not smuggled in at index 0", () => {
46
+ // Every video entry in the catalog is ascending, and three unrelated
47
+ // consumers read index 0 (the frontend fail-safe snap, payload-builder's
48
+ // resolution fill, the GVP display order). The 720p billing/render
49
+ // default lives in PRICING_DEFAULT_RESOLUTION instead.
50
+ expect(entry.resolutions).toEqual(["480p", "720p", "1080p"])
51
+ expect(PRICING_DEFAULT_RESOLUTION[id]).toBe("720p")
52
+ })
53
+
54
+ it("offers every integer second 2-30 (`-1` model-chosen duration is NOT exposed)", () => {
55
+ expect(entry.durations).toEqual(DURATIONS_2_TO_30)
56
+ expect(entry.durations).not.toContain(-1)
57
+ })
58
+
59
+ it("uses the Wan 3.0 six-ratio set: adaptive first, NO 21:9", () => {
60
+ expect(entry.aspectRatios).toEqual(["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16"])
61
+ // Reusing VIDEO_RATIOS_SEEDANCE_2 would have offered 21:9, which the
62
+ // Wan 3.0 enum rejects at request time.
63
+ expect(entry.aspectRatios).not.toContain("21:9")
64
+ })
65
+
66
+ it("declares end-frame + audio + reference-image, and deliberately NOT video-reference", () => {
67
+ expect(entry.features).toEqual(expect.arrayContaining(["end-frame", "audio", "reference-image"]))
68
+ // `video-reference` would derive wan into GVP_EXTEND_PROVIDERS, but the
69
+ // r2v forwarding path is unwired and KIE caps input-video + output at
70
+ // 30s — a bound the segment splitter cannot express.
71
+ expect(entry.features).not.toContain("video-reference")
72
+ })
73
+ })
74
+ }
75
+ })
76
+
77
+ describe("wan-3 provider wiring", () => {
78
+ for (const id of WAN_3_IDS) {
79
+ it(`${id} is registered for BOTH t2v and i2v and is NOT image-required`, () => {
80
+ expect(IMAGE_TO_VIDEO_PROVIDERS).toContain(id)
81
+ expect(TEXT_TO_VIDEO_PROVIDERS).toContain(id)
82
+ expect(VIDEO_PROVIDERS_REQUIRING_IMAGE.has(id)).toBe(false)
83
+ })
84
+
85
+ it(`${id} carries the 10/5/5 multimodal reference caps`, () => {
86
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER[id]).toEqual({ images: 10, videos: 5, audio: 5 })
87
+ expect(SEEDANCE_2_R2V_MAX_AUDIO_SEC_BY_PROVIDER[id]).toBe(15)
88
+ })
89
+
90
+ it(`${id} audio capability: ambient behind the model's own \`audio\` boolean, default on`, () => {
91
+ const cap = getVideoAudioCapability(id)
92
+ expect(cap.mode).toBe("ambient")
93
+ expect(cap.field).toBe("audio")
94
+ expect(cap.defaultOn).toBe(true)
95
+ expect(cap.affectsCost).toBeUndefined()
96
+ })
97
+
98
+ it(`${id} defaults to the adaptive aspect (the KIE default, first in its enum)`, () => {
99
+ expect(defaultVideoAspectRatio(id)).toBe("adaptive")
100
+ expect(NATIVE_ADAPTIVE_ASPECT[id]).toBe("adaptive")
101
+ })
102
+
103
+ it(`${id} prompt cap is the documented 20000 chars`, () => {
104
+ expect(getMaxVideoPromptChars(id)).toBe(20000)
105
+ })
106
+
107
+ it(`${id} prices per second: one duration tier per allowed second 2-30`, () => {
108
+ expect(DURATION_PRICED_PROVIDERS.has(id)).toBe(true)
109
+ const tiers = VIDEO_DURATION_TIERS[id]
110
+ expect(tiers.map((t) => t.maxSeconds)).toEqual(DURATIONS_2_TO_30)
111
+ expect(RESOLUTION_DURATION_PRICING[id]).toEqual(["480p", "720p", "1080p"])
112
+ })
113
+ }
114
+ })
115
+
116
+ describe("wan-3 family predicate — exact membership, never prefix-matched", () => {
117
+ it("matches exactly the two Wan 3.0 SKUs", () => {
118
+ expect([...WAN_3_PROVIDERS].sort()).toEqual(["wan-3", "wan-3-prime"])
119
+ expect(isWan3Provider("wan-3")).toBe(true)
120
+ expect(isWan3Provider("wan-3-prime")).toBe(true)
121
+ })
122
+
123
+ it("does NOT match any Wan 2.x id (the prefix trap)", () => {
124
+ for (const other of ["wan", "wan-i2v", "wan-turbo", "wan-flash", "wan-videoedit", "wan-2.7-i2v", "wan-2.7-t2v"]) {
125
+ expect(isWan3Provider(other), other).toBe(false)
126
+ }
127
+ expect(isWan3Provider(undefined)).toBe(false)
128
+ expect(isWan3Provider("")).toBe(false)
129
+ })
130
+
131
+ it("stays OUT of the Seedance 2 and MiniMax H3 family sets", () => {
132
+ for (const id of WAN_3_IDS) {
133
+ expect(isSeedance2Provider(id), id).toBe(false)
134
+ expect(SEEDANCE_2_PROVIDERS.has(id), id).toBe(false)
135
+ expect(isMinimaxH3Provider(id), id).toBe(false)
136
+ }
137
+ })
138
+
139
+ it("IS a blessed GVP SKU (derived) but NOT extend-eligible", () => {
140
+ for (const id of WAN_3_IDS) {
141
+ expect([...GVP_SUPPORTED_PROVIDERS], id).toContain(id)
142
+ expect([...GVP_EXTEND_PROVIDERS], id).not.toContain(id)
143
+ }
144
+ })
145
+ })
146
+
147
+ describe("normalizeWan3Resolution — the ONE producer of the uppercase wire form", () => {
148
+ it("uppercases a supported tier, case-insensitively", () => {
149
+ expect(normalizeWan3Resolution("480p")).toBe("480P")
150
+ expect(normalizeWan3Resolution("480P")).toBe("480P")
151
+ expect(normalizeWan3Resolution("720p")).toBe("720P")
152
+ expect(normalizeWan3Resolution("1080P")).toBe("1080P")
153
+ expect(normalizeWan3Resolution(" 1080p ")).toBe("1080P")
154
+ })
155
+
156
+ it("collapses undefined / garbage / an off-menu tier to the 720P platform default", () => {
157
+ expect(WAN_3_DEFAULT_RESOLUTION).toBe("720P")
158
+ // 720P, not KIE's own 1080P default: the bare credit identifier prices the
159
+ // 720p tier, so billing can never undercut the render.
160
+ expect(normalizeWan3Resolution(undefined)).toBe("720P")
161
+ expect(normalizeWan3Resolution("")).toBe("720P")
162
+ expect(normalizeWan3Resolution("4k")).toBe("720P")
163
+ expect(normalizeWan3Resolution("2K")).toBe("720P")
164
+ })
165
+ })
166
+
167
+ describe("wan-3 credit identifiers — totality over the whole (duration × resolution) space", () => {
168
+ const build = (provider: string, duration?: number, resolution?: string) =>
169
+ buildVideoCreditModelIdentifier(provider, duration, undefined, "image-to-video", undefined, resolution, undefined)
170
+
171
+ for (const id of WAN_3_IDS) {
172
+ it(`${id} emits exactly one composite per (duration, resolution) pair — 87 distinct ids`, () => {
173
+ const emitted = new Set<string>()
174
+ for (const d of DURATIONS_2_TO_30) {
175
+ for (const res of ["480p", "720p", "1080p"]) {
176
+ const identifier = build(id, d, res)
177
+ expect(identifier, `${id} ${d}s ${res}`).toBe(`${id}:${d}s:${res}`)
178
+ emitted.add(identifier)
179
+ }
180
+ }
181
+ expect(emitted.size).toBe(87)
182
+ })
183
+
184
+ it(`${id} prices an OMITTED resolution at the declared 720p default, not the cheapest tier`, () => {
185
+ // The tier list is ascending, so a bare resTiers[0] fallback would have
186
+ // reserved 480p against a 720p render — and commit_credits (refund-only)
187
+ // can never collect the shortfall.
188
+ expect(build(id, 5)).toBe(`${id}:5s:720p`)
189
+ expect(build(id, 8)).toBe(`${id}:8s:720p`)
190
+ })
191
+
192
+ it(`${id} collapses an UNSUPPORTED resolution to the same 720p default`, () => {
193
+ for (const bogus of ["4k", "2k", "360p", "768P", "not-a-resolution"]) {
194
+ expect(build(id, 5, bogus), bogus).toBe(`${id}:5s:720p`)
195
+ }
196
+ })
197
+
198
+ it(`${id} bills the tier it RENDERS for every spelling of the resolution`, () => {
199
+ // The render path is `normalizeWan3Resolution` (case-insensitive, trims),
200
+ // so the billing path must collapse through the SAME normalizer. KIE's own
201
+ // OpenAPI enum is UPPERCASE ("1080P"), which is the natural value for an
202
+ // integrator reading the provider docs and reaches both consumers raw:
203
+ // the route Zod is `z.string().optional()` and neither the credit builder
204
+ // nor the queue payload canonicalises it. A case-sensitive `includes`
205
+ // rendered 1080P while billing the 720p row — a 2x shortfall that
206
+ // commit_credits (refund-only) can never collect.
207
+ for (const spelling of ["1080P", "480P", "720P", " 720p ", "1080p", "480p", "720p", "4K", "garbage"]) {
208
+ const rendered = normalizeWan3Resolution(spelling).toLowerCase()
209
+ expect(build(id, 5, spelling), `${id} 5s "${spelling}"`).toBe(`${id}:5s:${rendered}`)
210
+ expect(build(id, 30, spelling), `${id} 30s "${spelling}"`).toBe(`${id}:30s:${rendered}`)
211
+ }
212
+ })
213
+
214
+ it(`${id} snaps an off-menu duration into a seeded tier rather than the bare id`, () => {
215
+ // Below the floor → the first tier; above the ceiling → the last one.
216
+ expect(build(id, 1, "720p")).toBe(`${id}:2s:720p`)
217
+ expect(build(id, 45, "720p")).toBe(`${id}:30s:720p`)
218
+ // An omitted duration renders the KIE default of 5s.
219
+ expect(build(id, undefined, "720p")).toBe(`${id}:5s:720p`)
220
+ })
221
+
222
+ it(`${id} never emits the bare id from the generate path`, () => {
223
+ for (const d of [undefined, ...DURATIONS_2_TO_30]) {
224
+ for (const res of [undefined, "480p", "720p", "1080p", "4k"]) {
225
+ expect(build(id, d, res)).not.toBe(id)
226
+ }
227
+ }
228
+ })
229
+ }
230
+
231
+ it("every catalog pricing row for the family is an id the builder can actually emit", () => {
232
+ for (const id of WAN_3_IDS) {
233
+ for (const row of MODEL_CATALOG[id].pricing ?? []) {
234
+ // The bare row is the reservation-time default; every other row is a
235
+ // duration×resolution composite the builder must be able to produce.
236
+ if (row.identifier === id) continue
237
+ const m = /^(.+):(\d+)s:(\d+p)$/.exec(row.identifier)
238
+ expect(m, row.identifier).not.toBeNull()
239
+ expect(build(m![1]!, Number(m![2]), m![3])).toBe(row.identifier)
240
+ }
241
+ }
242
+ })
243
+ })
@@ -21,11 +21,14 @@ import {
21
21
  T2I_TO_I2I_VARIANT,
22
22
  isVeoProvider,
23
23
  isMinimaxH3Provider,
24
+ isGeminiOmniProvider,
25
+ isWan3Provider,
24
26
  normalizeMinimaxH3Resolution,
27
+ normalizeWan3Resolution,
25
28
  getVideoAudioCapability,
26
29
  } from "./model-constants.js"
27
30
  import { isFlux2Model } from "./flux2-pricing.js"
28
- import { MODEL_CATALOG } from "./model-catalog.js"
31
+ import { MODEL_CATALOG, normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
29
32
 
30
33
  /**
31
34
  * Compute composite model identifier for variable credit pricing.
@@ -77,6 +80,90 @@ export function buildCreditModelIdentifier(
77
80
  return provider
78
81
  }
79
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
+
80
167
  /**
81
168
  * Reference-aware image-generation credit identifier — the SINGLE source of
82
169
  * truth shared by the single-node routes (`/v1/generate-image`,
@@ -104,17 +191,7 @@ export function resolveImageGenCreditIdentifier(opts: {
104
191
  refCount: number
105
192
  swapToI2i?: boolean
106
193
  }): string {
107
- const provider = opts.provider || "nano-banana"
108
- const effectiveProvider =
109
- opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
110
- return buildCreditModelIdentifier(
111
- effectiveProvider,
112
- opts.quality,
113
- opts.resolution,
114
- opts.renderingSpeed,
115
- undefined,
116
- opts.refCount,
117
- )
194
+ return resolveNormalizedImageGen(opts).identifier
118
195
  }
119
196
 
120
197
  // T2V-specific credit overrides: some providers have different costs for T2V
@@ -137,9 +214,11 @@ const T2V_CREDIT_OVERRIDES: Record<string, string> = {
137
214
  * @param resolution - Output resolution (used by Seedance 2 for 480p/720p pricing)
138
215
  * @param hasVideoRef - Whether a reference video is connected (Seedance 2 uses a lower per-second rate when true)
139
216
  */
140
- /** Gemini Omni Video duration tiers (seconds). Mirrors `MODEL_CATALOG["gemini-omni-video"].durations`
141
- * and `KIE_VIDEO_MODELS["gemini-omni-video"].allowedDurations`. Hoisted to module scope so the
142
- * nearest-tier snap below doesn't reallocate on every (hot-path) call. */
217
+ /** Gemini Omni duration tiers (seconds). Mirrors the `durations` of BOTH
218
+ * `MODEL_CATALOG["gemini-omni-video"]` and `MODEL_CATALOG["gemini-omni-flash"]`
219
+ * (and their `KIE_VIDEO_MODELS[*].allowedDurations`) — the family shares one
220
+ * ladder. Hoisted to module scope so the nearest-tier snap below doesn't
221
+ * reallocate on every (hot-path) call. */
143
222
  const GEMINI_OMNI_DURATIONS = [4, 6, 8, 10]
144
223
 
145
224
  /**
@@ -191,17 +270,21 @@ export function buildVideoCreditModelIdentifier(
191
270
  return effectiveProvider
192
271
  }
193
272
 
194
- // Gemini Omni Video: priced by (resolution-band × duration), with a flat
195
- // per-generation rate when a source video is supplied (V2V). Lowercase "4k".
196
- if (effectiveProvider === "gemini-omni-video") {
273
+ // Gemini Omni family (gemini-omni-video + gemini-omni-flash): priced by
274
+ // (resolution-band × duration), with a flat per-generation rate when a source
275
+ // video is supplied (V2V). Lowercase "4k". The prefix is TEMPLATED from the
276
+ // provider id, so a further Omni SKU needs no edit here — it only has to join
277
+ // GEMINI_OMNI_PROVIDERS and seed its rows. These models are deliberately NOT
278
+ // in DURATION_PRICED_PROVIDERS; this branch early-returns before that gate.
279
+ if (isGeminiOmniProvider(effectiveProvider)) {
197
280
  if (hasVideoRef) {
198
- return resolution === "4k" ? "gemini-omni-video:4k:vref" : "gemini-omni-video:vref"
281
+ return resolution === "4k" ? `${effectiveProvider}:4k:vref` : `${effectiveProvider}:vref`
199
282
  }
200
283
  // Snap to nearest allowed tier (NOT a min/max clamp) so off-tier durations
201
284
  // map to a SEEDED composite; default 8 when unset.
202
285
  const raw = parseInt(String(duration ?? 8), 10)
203
286
  const d = Number.isNaN(raw) ? 8 : GEMINI_OMNI_DURATIONS.reduce((b, a) => (Math.abs(a - raw) < Math.abs(b - raw) ? a : b))
204
- return resolution === "4k" ? `gemini-omni-video:4k:${d}` : `gemini-omni-video:${d}`
287
+ return resolution === "4k" ? `${effectiveProvider}:4k:${d}` : `${effectiveProvider}:${d}`
205
288
  }
206
289
 
207
290
  // LTX 2.3: priced by (resolution-band × duration-seconds). The RESERVE must be
@@ -281,7 +364,31 @@ export function buildVideoCreditModelIdentifier(
281
364
  // guard fuzzes the whole resolution space.
282
365
  const resTiers = RESOLUTION_DURATION_PRICING[effectiveProvider]
283
366
  if (resTiers) {
284
- const res = resolution && resTiers.includes(resolution) ? resolution : resTiers[0]!
367
+ // The "default tier" is the provider's DECLARED default when it has one
368
+ // (PRICING_DEFAULT_RESOLUTION — wan-3 renders 720p, not the cheapest tier),
369
+ // otherwise the first listed tier. Declaring it explicitly means a cosmetic
370
+ // reorder of the tier list can never reprice a live model, and an OMITTED
371
+ // *or* unsupported resolution both land on what actually renders instead of
372
+ // on the cheapest row (commit_credits refunds a surplus but can never
373
+ // collect a shortfall). Behaviour-neutral for every member without a
374
+ // declared default.
375
+ const declaredDefault = PRICING_DEFAULT_RESOLUTION[effectiveProvider]
376
+ const fallback = declaredDefault && resTiers.includes(declaredDefault) ? declaredDefault : resTiers[0]!
377
+ // Wan 3.0 renders through `normalizeWan3Resolution`, which is CASE-INSENSITIVE
378
+ // ("1080P" — KIE's own OpenAPI enum spelling, and the natural value for an
379
+ // integrator reading the provider docs — renders 1080P). The tier list here is
380
+ // lowercase and `Array.includes` is case-sensitive, so without this the render
381
+ // path and the billing path disagree: "1080P" rendered the top tier and billed
382
+ // the 720p row, a shortfall `commit_credits` (refund-only) can never collect.
383
+ // Collapsing through the SAME normalizer the runner uses makes render == billed
384
+ // by construction for every spelling, including garbage (→ the declared 720p).
385
+ // Scoped to the wan family on purpose: the other RESOLUTION_DURATION_PRICING
386
+ // members pass `resolution` to their wire verbatim, so case-folding their
387
+ // billing without probing their wire would reprice live models.
388
+ const requested = isWan3Provider(effectiveProvider)
389
+ ? normalizeWan3Resolution(resolution).toLowerCase()
390
+ : resolution
391
+ const res = requested && resTiers.includes(requested) ? requested : fallback
285
392
  identifier += `:${res}`
286
393
  }
287
394
 
@@ -332,3 +439,66 @@ export function buildMotionCreditModelIdentifier(
332
439
  const resSuffix = resolution === "1080p" ? ":1080p" : ""
333
440
  return `${base}${resSuffix}:${tier.suffix}`
334
441
  }
442
+
443
+ /**
444
+ * The Suno operations whose credit key depends on the model VERSION.
445
+ *
446
+ * `/v1/suno/generate`, `/cover` and `/extend` resolve their creditGuard
447
+ * identifier through `sunoCreditType(model, <operation>)` (routes/suno.ts
448
+ * :247-249, :335-337, :407-409). EVERY other Suno route charges a flat
449
+ * per-operation key and ignores the version the node carries — mashup (:648),
450
+ * add-instrumental (:852), add-vocals (:907), upload-extend (:1016),
451
+ * replace-section (:710), style-boost (:771), convert-wav (:962),
452
+ * lyrics (:482), music-video (:594), voice (:1218).
453
+ *
454
+ * Anything that QUOTES a Suno price — a dropdown row, a node badge, the
455
+ * workflow-credit estimator — must follow the same split, or it displays a
456
+ * credit key the route never charges.
457
+ */
458
+ export const SUNO_VERSION_PRICED_OPERATIONS = [
459
+ "suno-generate",
460
+ "suno-cover",
461
+ "suno-extend",
462
+ ] as const
463
+
464
+ /**
465
+ * The seven Suno operations that appear behind a select/dropdown UI (model
466
+ * pickers, node badges, the credit estimator) — the three version-priced
467
+ * operations above, plus the four flat-key operations exercised by
468
+ * `sunoCreditType`'s own test suite. A readonly tuple so later tasks (the
469
+ * model dropdowns, node badges) can iterate or count it without redeclaring
470
+ * the list and drifting from this one.
471
+ */
472
+ export const SUNO_SELECT_OPERATIONS = [
473
+ "suno-generate",
474
+ "suno-cover",
475
+ "suno-extend",
476
+ "suno-mashup",
477
+ "suno-add-instrumental",
478
+ "suno-add-vocals",
479
+ "suno-upload-extend",
480
+ ] as const
481
+
482
+ /**
483
+ * OUR Nodaro credit key for a Suno operation, given the model version and the
484
+ * operation (which is also the node type and the BullMQ job name).
485
+ *
486
+ * This is the single source of truth for the Suno pricing contract, shared by
487
+ * the reservation path (routes/suno.ts creditGuard), the egress seam
488
+ * (workers/handlers/suno.ts `modelKey`, which must match what was reserved),
489
+ * both workflow-credit estimators (ee/billing/credits.ts and the frontend
490
+ * config-panels/helpers.ts), the seven Suno model dropdowns and the three Suno
491
+ * node badges. It is a billing key — NEVER a KIE provider id — which is what
492
+ * satisfies the B3 egress invariant.
493
+ *
494
+ * V5_5 → "suno-v5_5", V5 → "suno-v5", any other version → the operation key.
495
+ * A non-version-priced operation is returned unchanged.
496
+ */
497
+ export function sunoCreditType(model: string | undefined, operation: string): string {
498
+ if (!(SUNO_VERSION_PRICED_OPERATIONS as readonly string[]).includes(operation)) {
499
+ return operation
500
+ }
501
+ if (model === "V5_5") return "suno-v5_5"
502
+ if (model === "V5") return "suno-v5"
503
+ return operation
504
+ }
@@ -69,6 +69,8 @@ const map: LocaleCatalogMap = {
69
69
  "compass": { label: "بوصلة", description: "بوصلة بحرية محمولة، استكشاف" },
70
70
  "bow-and-arrow": { label: "قوس وسهم", description: "قوس مشدود بسهم متأهب" },
71
71
  "shield": { label: "درع", description: "درع محمول، عصور وسطى / خيال" },
72
+ // --- picker-gaps 2026-09-01 ---
73
+ "work-gloves": { label: "قفازات عمل", description: "قفازات عمل جلدية بالية ممسوكة باليد" },
72
74
  }
73
75
 
74
76
  export default map
@@ -60,6 +60,9 @@ const map: LocaleCatalogMap = {
60
60
  "compass": { label: "Kompass", description: "Handgehaltener nautischer Kompass, Erkundung" },
61
61
  "bow-and-arrow": { label: "Pfeil und Bogen", description: "Gespannter Bogen mit eingelegtem Pfeil" },
62
62
  "shield": { label: "Schild", description: "Handgehaltenes Schild, mittelalterlich / Fantasy" },
63
+
64
+ // --- picker-gaps 2026-09-01 ---
65
+ "work-gloves": { label: "Arbeitshandschuhe", description: "Abgetragene lederne Arbeitshandschuhe in der Hand gehalten" },
63
66
  }
64
67
 
65
68
  export default map
@@ -78,6 +78,9 @@ const map: LocaleCatalogMap = {
78
78
  "compass": { label: "Brújula", description: "Brújula náutica de mano, exploración" },
79
79
  "bow-and-arrow": { label: "Arco y Flecha", description: "Arco de tiro tensado con flecha encajada" },
80
80
  "shield": { label: "Escudo", description: "Escudo de mano, medieval / fantasía" },
81
+
82
+ // --- picker-gaps 2026-09-01 ---
83
+ "work-gloves": { label: "Guantes de Trabajo", description: "Guantes de cuero de trabajo desgastados sostenidos en la mano" },
81
84
  }
82
85
 
83
86
  export default map
@@ -69,6 +69,8 @@ const map: LocaleCatalogMap = {
69
69
  "compass": { label: "Boussole", description: "Boussole nautique à main, exploration" },
70
70
  "bow-and-arrow": { label: "Arc et flèche", description: "Arc de tir à l'arc tendu avec flèche encochée" },
71
71
  "shield": { label: "Bouclier", description: "Bouclier porté à la main, médiéval / fantasy" },
72
+ // --- picker-gaps 2026-09-01 ---
73
+ "work-gloves": { label: "Gants de travail", description: "Gants de travail en cuir usés tenus à la main" },
72
74
  }
73
75
 
74
76
  export default map
@@ -68,6 +68,9 @@ const map: LocaleCatalogMap = {
68
68
  "compass": { label: "מצפן", description: "מצפן ימי מוחזק ביד, חקירה" },
69
69
  "bow-and-arrow": { label: "קשת וחץ", description: "קשת קשתות דרוכה עם חץ על המיתר" },
70
70
  "shield": { label: "מגן", description: "מגן מוחזק ביד, ימי הביניים / פנטזיה" },
71
+
72
+ // --- picker-gaps 2026-09-01 ---
73
+ "work-gloves": { label: "כפפות עבודה", description: "כפפות עבודה שחוקות מעור, מוחזקות ביד" },
71
74
  }
72
75
 
73
76
  export default map
@@ -77,6 +77,8 @@ const map: LocaleCatalogMap = {
77
77
  "compass": { label: "कम्पास", description: "हाथ का nautical कम्पास, अन्वेषण" },
78
78
  "bow-and-arrow": { label: "धनुष-बाण", description: "तीर लगाया हुआ खींचा हुआ archery धनुष" },
79
79
  "shield": { label: "ढाल", description: "हाथ की ढाल, मध्यकालीन / fantasy" },
80
+ // --- picker-gaps 2026-09-01 ---
81
+ "work-gloves": { label: "काम के दस्ताने", description: "हाथ में पकड़े हुए घिसे चमड़े के काम के दस्ताने" },
80
82
  }
81
83
 
82
84
  export default map
@@ -79,6 +79,9 @@ const map: LocaleCatalogMap = {
79
79
  "compass": { label: "コンパス", description: "手持ちの航海用コンパス、探検" },
80
80
  "bow-and-arrow": { label: "弓矢", description: "矢をつがえて引き絞った弓" },
81
81
  "shield": { label: "盾", description: "手持ちの盾、中世/ファンタジー" },
82
+
83
+ // --- picker-gaps 2026-09-01 ---
84
+ "work-gloves": { label: "作業用手袋", description: "手に持った使い込まれた革の作業用手袋" },
82
85
  }
83
86
 
84
87
  export default map