@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  buildCreditModelIdentifier,
3
+ resolveImageGenCreditIdentifier,
4
+ resolveNormalizedImageGen,
3
5
  buildVideoCreditModelIdentifier,
4
6
  buildMotionCreditModelIdentifier,
5
7
  } from "../credit-identifiers.js"
@@ -823,3 +825,181 @@ describe("gemini-omni-video", () => {
823
825
  expect(id({ duration: 6, resolution: "720p", nodeType: "text-to-video" })).toBe("gemini-omni-video:6")
824
826
  })
825
827
  })
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // gemini-omni-flash credit identifier — the family branch reaches the sibling
831
+ // ---------------------------------------------------------------------------
832
+ // The Omni branch used to compare against the literal "gemini-omni-video" and
833
+ // hardcode that prefix in every returned string. It now asks
834
+ // isGeminiOmniProvider() and TEMPLATES the prefix, so this block is the proof
835
+ // that the refactor actually reaches the second SKU (the block above proves it
836
+ // stayed behaviour-neutral for the first). A flash run that fell through to the
837
+ // DURATION_PRICED gate would return the BARE id and reserve 270 credits for a
838
+ // 4K 10s render.
839
+ describe("gemini-omni-flash", () => {
840
+ const id = (opts: { duration?: number | string; resolution?: string; hasVideoRef?: boolean; nodeType?: "image-to-video" | "text-to-video" }) =>
841
+ buildVideoCreditModelIdentifier("gemini-omni-flash", opts.duration, undefined, opts.nodeType ?? "image-to-video", undefined, opts.resolution, opts.hasVideoRef)
842
+
843
+ it("prices 720p/1080p by duration tier", () => {
844
+ expect(id({ duration: 4, resolution: "720p" })).toBe("gemini-omni-flash:4")
845
+ expect(id({ duration: 6, resolution: "1080p" })).toBe("gemini-omni-flash:6")
846
+ expect(id({ duration: 8, resolution: "720p" })).toBe("gemini-omni-flash:8")
847
+ expect(id({ duration: 10, resolution: "1080p" })).toBe("gemini-omni-flash:10")
848
+ })
849
+ it("prices 4k by duration tier", () => {
850
+ expect(id({ duration: 4, resolution: "4k" })).toBe("gemini-omni-flash:4k:4")
851
+ expect(id({ duration: 10, resolution: "4k" })).toBe("gemini-omni-flash:4k:10")
852
+ })
853
+ it("prices V2V flat, ignoring duration", () => {
854
+ expect(id({ duration: 8, resolution: "1080p", hasVideoRef: true })).toBe("gemini-omni-flash:vref")
855
+ expect(id({ duration: 8, resolution: "4k", hasVideoRef: true })).toBe("gemini-omni-flash:4k:vref")
856
+ })
857
+ it("snaps off-tier durations to nearest allowed tier (never :5)", () => {
858
+ expect(id({ duration: 5, resolution: "720p" })).toBe("gemini-omni-flash:4")
859
+ expect(id({ duration: 12, resolution: "720p" })).toBe("gemini-omni-flash:10")
860
+ })
861
+ it("defaults to tier 8 when duration is undefined", () => {
862
+ expect(id({ resolution: "720p" })).toBe("gemini-omni-flash:8")
863
+ })
864
+ it("works on the text-to-video path too", () => {
865
+ expect(id({ duration: 6, resolution: "720p", nodeType: "text-to-video" })).toBe("gemini-omni-flash:6")
866
+ })
867
+ it("never falls through to the bare id (the pre-refactor failure mode)", () => {
868
+ for (const d of [undefined, 4, 6, 8, 10, 5, 12]) {
869
+ for (const r of [undefined, "720p", "1080p", "4k"]) {
870
+ expect(id({ duration: d, resolution: r })).not.toBe("gemini-omni-flash")
871
+ }
872
+ }
873
+ })
874
+ })
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // resolveNormalizedImageGen
878
+ // ---------------------------------------------------------------------------
879
+ describe("resolveNormalizedImageGen", () => {
880
+ it("snaps an off-list aspect ratio and reports it", () => {
881
+ const out = resolveNormalizedImageGen({
882
+ provider: "gpt-image-2",
883
+ aspectRatio: "3:2",
884
+ resolution: "4K",
885
+ refCount: 0,
886
+ swapToI2i: true,
887
+ })
888
+ // 3:2 is not in GPT_IMAGE_2_RATIOS -> allowed[0] ("auto"), which then
889
+ // cascades resolution to 1K via the model's cross-field rule.
890
+ expect(out.aspectRatio).toBe("auto")
891
+ expect(out.resolution).toBe("1K")
892
+ expect(out.identifier).toBe("gpt-image-2")
893
+ expect(out.adjustments.map((a) => a.field)).toEqual(["aspectRatio", "resolution"])
894
+ })
895
+
896
+ it("prices the SNAPPED resolution — gpt-image-2 auto + 2K reserves the 1K tier", () => {
897
+ const out = resolveNormalizedImageGen({
898
+ provider: "gpt-image-2",
899
+ aspectRatio: "auto",
900
+ resolution: "2K",
901
+ refCount: 0,
902
+ swapToI2i: true,
903
+ })
904
+ expect(out.resolution).toBe("1K")
905
+ expect(out.identifier).toBe("gpt-image-2")
906
+ })
907
+
908
+ it("drops a lever the model does not have, and reports it", () => {
909
+ const out = resolveNormalizedImageGen({
910
+ provider: "recraft-upscale",
911
+ aspectRatio: "16:9",
912
+ refCount: 0,
913
+ })
914
+ expect(out.aspectRatio).toBeUndefined()
915
+ expect(out.adjustments).toHaveLength(1)
916
+ expect(out.adjustments[0].field).toBe("aspectRatio")
917
+ expect(out.adjustments[0].to).toBeUndefined()
918
+ })
919
+
920
+ // grok-i2i deliberately has NO `aspectRatios` on its catalog entry, and that
921
+ // is the HONEST declaration: KIE's grok-imagine/image-to-image schema accepts
922
+ // only prompt / image_urls / nsfw_checker — there is no aspect_ratio param
923
+ // (https://docs.kie.ai/market/grok-imagine/image-to-image.md). That is why
924
+ // kie/models.ts gives it `extraParams: {}` while its t2i sibling gets
925
+ // `{ aspect_ratio: "16:9" }`, and why kie/CLAUDE.md lists grok-i2i under
926
+ // "No aspect ratio control". Declaring the t2i ratios here to "preserve" a
927
+ // caller's value would put a lie in the catalog and keep forwarding a param
928
+ // the endpoint ignores; dropping it and disclosing that via `adjustments` is
929
+ // the correct behaviour. Do not "fix" this by adding GROK_RATIOS.
930
+ it("drops the aspect ratio when grok auto-swaps to its ratio-less i2i sibling", () => {
931
+ const out = resolveNormalizedImageGen({
932
+ provider: "grok",
933
+ aspectRatio: "16:9",
934
+ refCount: 1,
935
+ swapToI2i: true,
936
+ })
937
+ expect(out.modelId).toBe("grok-i2i")
938
+ expect(out.aspectRatio).toBeUndefined()
939
+ expect(out.adjustments).toHaveLength(1)
940
+ expect(out.adjustments[0].field).toBe("aspectRatio")
941
+ expect(out.adjustments[0].from).toBe("16:9")
942
+ expect(out.adjustments[0].to).toBeUndefined()
943
+ expect(out.identifier).toBe("grok-i2i")
944
+ })
945
+
946
+ it("snaps against the i2i variant when the T2I provider auto-swaps", () => {
947
+ const out = resolveNormalizedImageGen({
948
+ provider: "gpt-image-2",
949
+ aspectRatio: "3:4",
950
+ refCount: 2,
951
+ swapToI2i: true,
952
+ })
953
+ expect(out.modelId).toBe("gpt-image-2-i2i")
954
+ expect(out.aspectRatio).toBe("3:4")
955
+ expect(out.adjustments).toEqual([])
956
+ })
957
+
958
+ it("coerces non-string levers to undefined (the preHandler runs pre-Zod)", () => {
959
+ const out = resolveNormalizedImageGen({
960
+ provider: "gpt-image-2",
961
+ aspectRatio: 169,
962
+ resolution: null,
963
+ quality: {},
964
+ refCount: 0,
965
+ })
966
+ expect(out.aspectRatio).toBeUndefined()
967
+ expect(out.resolution).toBeUndefined()
968
+ expect(out.quality).toBeUndefined()
969
+ expect(out.adjustments).toEqual([])
970
+ expect(out.identifier).toBe("gpt-image-2")
971
+ })
972
+
973
+ it("passes an unknown model id through untouched", () => {
974
+ const out = resolveNormalizedImageGen({
975
+ provider: "not-a-model",
976
+ aspectRatio: "13:7",
977
+ resolution: "9K",
978
+ refCount: 0,
979
+ })
980
+ expect(out.aspectRatio).toBe("13:7")
981
+ expect(out.resolution).toBe("9K")
982
+ expect(out.adjustments).toEqual([])
983
+ expect(out.identifier).toBe("not-a-model")
984
+ })
985
+ })
986
+
987
+ // Regression pins: identifiers that are correct TODAY must be byte-identical
988
+ // once resolveImageGenCreditIdentifier delegates through the normalizer.
989
+ describe("resolveImageGenCreditIdentifier is unchanged for already-valid input", () => {
990
+ it.each([
991
+ [{ provider: "flux-2-max", refCount: 3, swapToI2i: true }, "flux-2-max:1MP:3ref"],
992
+ [{ provider: "flux-2-max", resolution: "4 MP", refCount: 8, swapToI2i: true }, "flux-2-max:4MP:8ref"],
993
+ [{ provider: "flux-2-pro", refCount: 0, swapToI2i: true }, "flux-2-pro:1MP:0ref"],
994
+ [{ provider: "seedream-5-lite", refCount: 1, swapToI2i: true }, "seedream-5-lite-i2i"],
995
+ [{ provider: "seedream", quality: "high", refCount: 0, swapToI2i: true }, "seedream:high"],
996
+ [{ provider: "gpt-image", quality: "high", refCount: 0, swapToI2i: true }, "gpt-image:high"],
997
+ [{ provider: "flux", resolution: "2K", refCount: 0, swapToI2i: true }, "flux:2K"],
998
+ [{ provider: "nano-banana-pro", resolution: "4K", refCount: 0, swapToI2i: true }, "nano-banana-pro:4K"],
999
+ [{ provider: "gpt-image-2", resolution: "4K", aspectRatio: "16:9", refCount: 0, swapToI2i: true }, "gpt-image-2:4K"],
1000
+ [{ provider: "ideogram-v3", renderingSpeed: "TURBO", refCount: 0, swapToI2i: true }, "ideogram-v3:TURBO"],
1001
+ [{ provider: undefined, refCount: 0, swapToI2i: true }, "nano-banana"],
1002
+ ] as const)("%o -> %s", (opts, expected) => {
1003
+ expect(resolveImageGenCreditIdentifier({ ...opts } as Parameters<typeof resolveImageGenCreditIdentifier>[0])).toBe(expected)
1004
+ })
1005
+ })
@@ -51,7 +51,10 @@ describe("GVP_SUPPORTED_PROVIDERS", () => {
51
51
  "veo3.1",
52
52
  "veo3_lite",
53
53
  "gemini-omni-video",
54
+ "gemini-omni-flash",
54
55
  "grok-i2v",
56
+ "wan-3",
57
+ "wan-3-prime",
55
58
  "happyhorse-ref2v",
56
59
  ])
57
60
  })
@@ -154,8 +157,20 @@ describe("GVP_EXTEND_PROVIDERS", () => {
154
157
  expect(supportsExtendRender("gemini-omni-video")).toBe(false)
155
158
  })
156
159
 
160
+ it("excludes wan-3 — reference videos are accepted but the extend chain is unwired", () => {
161
+ // Wan 3.0 takes reference_video_urls, but (a) there is no wan-3 r2v
162
+ // forwarding path yet and (b) KIE caps input-video seconds + output
163
+ // duration at 30, which the segment bounds cannot express. So the catalog
164
+ // deliberately withholds the `video-reference` feature: blessed for
165
+ // keyframes, not for extend. Same precedent as gemini-omni-video.
166
+ for (const p of ["wan-3", "wan-3-prime"]) {
167
+ expect(isGvpSupportedProvider(p)).toBe(true)
168
+ expect(supportsExtendRender(p)).toBe(false)
169
+ }
170
+ })
171
+
157
172
  it("keyframes-only SKUs reject extend", () => {
158
- for (const p of ["veo3", "veo3.1", "veo3_lite", "gemini-omni-video", "grok-i2v", "happyhorse-ref2v"]) {
173
+ for (const p of ["veo3", "veo3.1", "veo3_lite", "gemini-omni-video", "gemini-omni-flash", "grok-i2v", "happyhorse-ref2v", "wan-3", "wan-3-prime"]) {
159
174
  expect(isGvpSupportedProvider(p)).toBe(true)
160
175
  expect(supportsExtendRender(p)).toBe(false)
161
176
  }
@@ -174,6 +189,8 @@ describe("GVP_END_FRAME_PROVIDERS", () => {
174
189
  "veo3",
175
190
  "veo3.1",
176
191
  "veo3_lite",
192
+ "wan-3",
193
+ "wan-3-prime",
177
194
  ])
178
195
  })
179
196
 
@@ -186,6 +203,10 @@ describe("GVP_END_FRAME_PROVIDERS", () => {
186
203
 
187
204
  it("excludes SKUs with no closing-frame support", () => {
188
205
  expect(supportsEndAnchor("gemini-omni-video")).toBe(false)
206
+ // Gemini Omni Flash mirrors its sibling exactly — the KIE schema exposes
207
+ // first/last frame but the path is unwired for BOTH Omni SKUs, so neither
208
+ // declares the catalog `end-frame` feature.
209
+ expect(supportsEndAnchor("gemini-omni-flash")).toBe(false)
189
210
  expect(supportsEndAnchor("grok-i2v")).toBe(false)
190
211
  expect(supportsEndAnchor("happyhorse-ref2v")).toBe(false)
191
212
  expect(supportsEndAnchor(undefined)).toBe(false)
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ MODEL_CATALOG,
4
+ normalizeModelInput,
5
+ } from "../model-catalog.js"
6
+ import {
7
+ HIGH_QUALITY_PROVIDERS,
8
+ IMAGE_ASPECT_RATIO_VALUES,
9
+ TWO_K_RESOLUTION_PROVIDERS,
10
+ RESOLUTION_2K_4K_TIERED_PROVIDERS,
11
+ } from "../model-constants.js"
12
+ import { FLUX2_RES_MP, isFlux2Model } from "../flux2-pricing.js"
13
+
14
+ /**
15
+ * `resolveNormalizedImageGen` prices the credit identifier off the SNAPPED
16
+ * lever values (see credit-identifiers.ts). That is only safe while every
17
+ * value a pricing set keys on is also DECLARED on that model's catalog entry:
18
+ * a priced value the catalog omits would be snapped away or rewritten, and the
19
+ * reserve would silently move to a different tier.
20
+ *
21
+ * A model that is priced on a lever but ABSENT from MODEL_CATALOG is fine —
22
+ * `normalizeModelInput` passes unknown ids through untouched.
23
+ */
24
+ describe("composite image pricing ⊆ catalog-declared levers", () => {
25
+ it("every HIGH_QUALITY_PROVIDERS member in the catalog declares quality \"high\"", () => {
26
+ let checked = 0
27
+ for (const id of HIGH_QUALITY_PROVIDERS) {
28
+ const entry = MODEL_CATALOG[id]
29
+ if (!entry) continue
30
+ checked++
31
+ expect(entry.qualities, `${id}.qualities`).toBeDefined()
32
+ expect(entry.qualities, `${id}.qualities`).toContain("high")
33
+ }
34
+ expect(checked).toBeGreaterThan(0)
35
+ })
36
+
37
+ it("every TWO_K_RESOLUTION_PROVIDERS member in the catalog declares \"2K\"", () => {
38
+ let checked = 0
39
+ for (const id of TWO_K_RESOLUTION_PROVIDERS) {
40
+ const entry = MODEL_CATALOG[id]
41
+ if (!entry) continue
42
+ checked++
43
+ expect(entry.resolutions, `${id}.resolutions`).toContain("2K")
44
+ }
45
+ expect(checked).toBeGreaterThan(0)
46
+ })
47
+
48
+ it("every RESOLUTION_2K_4K_TIERED_PROVIDERS member in the catalog declares \"2K\" and \"4K\"", () => {
49
+ let checked = 0
50
+ for (const id of RESOLUTION_2K_4K_TIERED_PROVIDERS) {
51
+ const entry = MODEL_CATALOG[id]
52
+ if (!entry) continue
53
+ checked++
54
+ expect(entry.resolutions, `${id}.resolutions`).toContain("2K")
55
+ expect(entry.resolutions, `${id}.resolutions`).toContain("4K")
56
+ }
57
+ expect(checked).toBeGreaterThan(0)
58
+ })
59
+
60
+ it("nano-banana-pro declares \"4K\" (its own pricing branch)", () => {
61
+ expect(MODEL_CATALOG["nano-banana-pro"]?.resolutions).toContain("4K")
62
+ })
63
+
64
+ // Flux 2 stores a BARE megapixel count ("1") while the catalog lists "1 MP".
65
+ // normalizeModelInput canonicalizes rather than "corrects" that spelling —
66
+ // if it ever stopped doing so, every Flux 2 node priced from stored data
67
+ // would jump to defaultResolutionFor()'s tier.
68
+ it("Flux 2 bare-MP values canonicalize to the catalog spelling, not a different tier", () => {
69
+ let checked = 0
70
+ for (const id of Object.keys(MODEL_CATALOG)) {
71
+ if (!isFlux2Model(id)) continue
72
+ checked++
73
+ for (const mp of FLUX2_RES_MP) {
74
+ const out = normalizeModelInput(id, { resolution: mp })
75
+ expect(out.resolution, `${id} + "${mp}"`).toBe(`${mp} MP`)
76
+ expect(out.adjustments, `${id} + "${mp}" must not report a correction`).toEqual([])
77
+ }
78
+ }
79
+ expect(checked).toBeGreaterThan(0)
80
+ })
81
+
82
+ // Declared values are always identity under the snap. Cheap, and it fails
83
+ // loudly if someone adds a duplicate/typo'd entry to a lever list.
84
+ it("every declared image lever value normalizes to itself", () => {
85
+ for (const [id, entry] of Object.entries(MODEL_CATALOG)) {
86
+ if (entry.kind !== "image") continue
87
+ for (const a of entry.aspectRatios ?? []) {
88
+ expect(normalizeModelInput(id, { aspectRatio: a }).aspectRatio, `${id} ratio ${a}`).toBe(a)
89
+ }
90
+ for (const r of entry.resolutions ?? []) {
91
+ expect(normalizeModelInput(id, { resolution: r }).resolution, `${id} res ${r}`).toBe(r)
92
+ }
93
+ for (const q of entry.qualities ?? []) {
94
+ expect(normalizeModelInput(id, { quality: q }).quality, `${id} quality ${q}`).toBe(q)
95
+ }
96
+ }
97
+ })
98
+ })
99
+
100
+ /**
101
+ * `IMAGE_ASPECT_RATIO_VALUES` is the ONE ratio vocabulary the three image
102
+ * routes' Zod enums are built from (`/v1/generate-image`, `/v1/image-to-image`,
103
+ * `/v1/edit-image`). Two invariants make that safe, and both are asserted here
104
+ * rather than left to review:
105
+ *
106
+ * 1. It must be a SUPERSET of every ratio any image model declares. A catalog
107
+ * ratio the tuple omits is a live 400 on a value the picker offers — the
108
+ * exact bug that shipped twice (Wan 2.7's `8:1`/`1:8`, then Nano Banana 2
109
+ * Lite's `4:1`/`1:4`). It also makes `applySnappedLevers`' "enum can't carry
110
+ * the snapped value" fallback unreachable: the snap can only ever return a
111
+ * value the caller sent, its canonical spelling, or a catalog-declared
112
+ * option — all three are in the tuple by this test.
113
+ * 2. No duplicates. `z.enum` de-dupes silently, so a copy-paste repeat would
114
+ * never surface at runtime.
115
+ *
116
+ * Widening the tuple is always safe: the per-model gate is the catalog snap,
117
+ * which CORRECTS an unsupported ratio (and discloses it) instead of rejecting.
118
+ */
119
+ describe("IMAGE_ASPECT_RATIO_VALUES covers every catalog-declared image ratio", () => {
120
+ it("is a superset of every `kind: \"image\"` entry's aspectRatios", () => {
121
+ const allowed = new Set<string>(IMAGE_ASPECT_RATIO_VALUES)
122
+ const missing: string[] = []
123
+ let checked = 0
124
+ for (const [id, entry] of Object.entries(MODEL_CATALOG)) {
125
+ if (entry.kind !== "image") continue
126
+ for (const r of entry.aspectRatios ?? []) {
127
+ checked++
128
+ if (!allowed.has(r)) missing.push(`${id} declares "${r}"`)
129
+ }
130
+ }
131
+ expect(missing).toEqual([])
132
+ // Guard the guard: an empty catalog sweep would make this vacuous.
133
+ expect(checked).toBeGreaterThan(100)
134
+ })
135
+
136
+ it("has no duplicate entries (z.enum would swallow them)", () => {
137
+ expect(new Set<string>(IMAGE_ASPECT_RATIO_VALUES).size).toBe(IMAGE_ASPECT_RATIO_VALUES.length)
138
+ })
139
+ })
@@ -59,6 +59,42 @@ describe("normalizeNodeModelParams", () => {
59
59
  expect(MODEL_PARAM_NODE_TYPES.has("image-to-video")).toBe(false)
60
60
  })
61
61
 
62
+ // The write boundary has to cover every image node an agent can author, not
63
+ // just the two that shipped first: `modify-image` carries the same
64
+ // provider/aspectRatio/resolution/quality trio, and `edit-image` carries
65
+ // provider + aspectRatio (its `targetResolution` is an upscale target, a
66
+ // DIFFERENT field the normalizer never reads, so there is no conflation).
67
+ it("covers every image node type whose data carries catalog-governed params", () => {
68
+ expect([...MODEL_PARAM_NODE_TYPES].sort()).toEqual([
69
+ "edit-image",
70
+ "generate-image",
71
+ "image-to-image",
72
+ "modify-image",
73
+ ])
74
+ })
75
+
76
+ it("heals a modify-image node written straight into workflow JSON", () => {
77
+ const { nodes, adjustments } = normalizeNodeModelParams([
78
+ node("m9", "modify-image", { provider: "gpt-image", aspectRatio: "16:9", quality: "basic" }),
79
+ ])
80
+ const d = nodes[0].data as Record<string, unknown>
81
+ expect(d.aspectRatio).not.toBe("16:9")
82
+ expect(d.quality).toBe("medium") // gpt-image declares ["medium", "high"]
83
+ expect(adjustments.map((a) => a.field).sort()).toEqual(["aspectRatio", "quality"])
84
+ expect(adjustments[0].nodeId).toBe("m9")
85
+ })
86
+
87
+ it("heals an edit-image node's ratio without touching its targetResolution", () => {
88
+ const { nodes, adjustments } = normalizeNodeModelParams([
89
+ node("e1", "edit-image", { provider: "recraft-upscale", aspectRatio: "16:9", targetResolution: "4K" }),
90
+ ])
91
+ const d = nodes[0].data as Record<string, unknown>
92
+ // The upscalers declare no aspectRatios at all -> the lever is dropped.
93
+ expect(d.aspectRatio).toBeUndefined()
94
+ expect(d.targetResolution).toBe("4K")
95
+ expect(adjustments.map((a) => a.field)).toEqual(["aspectRatio"])
96
+ })
97
+
62
98
  it("skips multi-provider nodes rather than guessing an intersection", () => {
63
99
  const input = [
64
100
  node("m1", "generate-image", {
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs"
1
2
  import { describe, expect, it } from "vitest"
2
3
  import {
3
4
  ACCESS_LEVELS,
@@ -9,13 +10,14 @@ import {
9
10
  OrgSettingsSchema,
10
11
  PRESET_SETTING_KEYS,
11
12
  PresetSettingsSchema,
13
+ USAGE_GROUP_BYS,
12
14
  WORKSPACE_ROLES,
13
15
  WorkspaceSettingsSchema,
14
16
  } from "../organizations/index.js"
15
17
 
16
18
  describe("organizations wire contract — enums", () => {
17
19
  it("every enum is non-empty and duplicate-free", () => {
18
- for (const list of [ORG_KINDS, ORG_ROLES, WORKSPACE_ROLES, MEMBER_STATUSES, COLLABORATOR_ROLES, ACCESS_LEVELS, ORG_ERROR_CODES]) {
20
+ for (const list of [ORG_KINDS, ORG_ROLES, WORKSPACE_ROLES, MEMBER_STATUSES, COLLABORATOR_ROLES, ACCESS_LEVELS, ORG_ERROR_CODES, USAGE_GROUP_BYS]) {
19
21
  expect(list.length).toBeGreaterThan(0)
20
22
  expect(new Set(list).size).toBe(list.length)
21
23
  }
@@ -24,6 +26,32 @@ describe("organizations wire contract — enums", () => {
24
26
  it("error codes are snake_case identifiers", () => {
25
27
  for (const code of ORG_ERROR_CODES) expect(code).toMatch(/^[a-z][a-z_]*[a-z]$/)
26
28
  })
29
+
30
+ it("USAGE_GROUP_BYS is exactly the report groupings plus none", () => {
31
+ expect([...USAGE_GROUP_BYS]).toEqual(["workspace", "member", "model", "day", "none"])
32
+ })
33
+
34
+ it("ORG_ERROR_CODES carries the P15 CSV write-ahead-audit code", () => {
35
+ expect(ORG_ERROR_CODES).toContain("audit_unavailable")
36
+ })
37
+ })
38
+
39
+ describe("usage wire types carry no cost/economics field (pricing-leak class)", () => {
40
+ it("no UsageReport* / UsageLogEntry field name is cost-shaped", () => {
41
+ const src = readFileSync(new URL("../organizations/views.ts", import.meta.url), "utf8")
42
+ .replace(/\/\*[\s\S]*?\*\//g, "")
43
+ .replace(/\/\/[^\n]*/g, "")
44
+ const start = src.indexOf("export interface UsageReportRow")
45
+ const end = src.indexOf("export interface UsageQuery")
46
+ expect(start).toBeGreaterThan(-1)
47
+ expect(end).toBeGreaterThan(start)
48
+ const usageTypes = src.slice(start, end)
49
+ // "markup" is deliberately NOT in this set: the creator-set app markup is a
50
+ // product term (the `app_markup` variance kind, `appMarkupAbsorbedCredits`),
51
+ // not platform economics — same carve-out as check-pricing-leaks'
52
+ // BARE_MARKUP_IS_PRODUCT_TERM. What must never appear is a $ / rate field.
53
+ expect(usageTypes).not.toMatch(/(cost|usd|margin|price|dollar)/i)
54
+ })
27
55
  })
28
56
 
29
57
  describe("organizations wire contract — settings request schemas", () => {
@@ -6,6 +6,9 @@ import {
6
6
  NEGATIVE_PROMPT_MAX,
7
7
  TTS_TEXT_MAX,
8
8
  SUNO_TITLE_MAX,
9
+ SUNO_HARD_CEILING,
10
+ SUNO_TEXT_MAX,
11
+ SUNO_MODELS,
9
12
  MAX_IMAGE_PROMPT_CHARS_BY_PROVIDER,
10
13
  MAX_VIDEO_PROMPT_CHARS_BY_PROVIDER,
11
14
  MAX_NEGATIVE_PROMPT_CHARS_BY_PROVIDER,
@@ -66,6 +69,10 @@ describe("per-model prompt length limits", () => {
66
69
  it("returns verified higher caps", () => {
67
70
  expect(getMaxVideoPromptChars("seedance-2")).toBe(20000)
68
71
  expect(getMaxVideoPromptChars("gemini-omni-video")).toBe(20000)
72
+ expect(getMaxVideoPromptChars("gemini-omni-flash")).toBe(20000)
73
+ // Wan 3.0 family — 20000 chars, truncated by the provider beyond that.
74
+ expect(getMaxVideoPromptChars("wan-3")).toBe(20000)
75
+ expect(getMaxVideoPromptChars("wan-3-prime")).toBe(20000)
69
76
  expect(getMaxVideoPromptChars("bytedance-pro")).toBe(10000)
70
77
  })
71
78
  it("falls back to VIDEO_PROMPT_MAX for UNVERIFIED + unknown providers", () => {
@@ -119,6 +126,39 @@ describe("per-model prompt length limits", () => {
119
126
  expect(getMaxSunoStyleChars("V5")).toBe(1000)
120
127
  expect(SUNO_TITLE_MAX).toBe(80)
121
128
  })
129
+ it("SUNO_HARD_CEILING is a generous route ceiling that can never reject a valid prompt", () => {
130
+ // The routes accept up to the ceiling and CLAMP to the per-version cap;
131
+ // a ceiling below any real cap would hard-reject before the clamp ran.
132
+ // The list is SUNO_MODELS plus `undefined` (schemas without a `model`
133
+ // field, e.g. replace-section). `V3_5` is NOT a route-accepted model —
134
+ // getMaxSunoPromptChars still names it, but do not add it here.
135
+ const versions = [...SUNO_MODELS, undefined]
136
+ for (const v of versions) {
137
+ for (const custom of [true, false]) {
138
+ expect(getMaxSunoPromptChars(v, custom)).toBeLessThanOrEqual(SUNO_HARD_CEILING)
139
+ }
140
+ expect(getMaxSunoStyleChars(v)).toBeLessThanOrEqual(SUNO_HARD_CEILING)
141
+ }
142
+ expect(SUNO_TEXT_MAX).toBeLessThanOrEqual(SUNO_HARD_CEILING)
143
+ expect(SUNO_HARD_CEILING).toBe(30000)
144
+ })
145
+ it("SUNO_TEXT_MAX pins the max prompt cap across every version × custom-mode combo and is >= every style cap", () => {
146
+ // SUNO_TEXT_MAX is the shared editor-side ceiling (maxLength / warn
147
+ // counter) for prompt-shaped Suno fields. Derive the expected value
148
+ // from the real version list so a future Suno version raising its
149
+ // custom-mode cap above today's 5000 fails this test instead of
150
+ // silently under-capping the editor.
151
+ let maxPromptCap = 0
152
+ for (const v of SUNO_MODELS) {
153
+ for (const custom of [true, false]) {
154
+ maxPromptCap = Math.max(maxPromptCap, getMaxSunoPromptChars(v, custom))
155
+ }
156
+ }
157
+ expect(SUNO_TEXT_MAX).toBe(maxPromptCap)
158
+ for (const v of SUNO_MODELS) {
159
+ expect(SUNO_TEXT_MAX).toBeGreaterThanOrEqual(getMaxSunoStyleChars(v))
160
+ }
161
+ })
122
162
  })
123
163
 
124
164
  describe("drift guards (registry hygiene)", () => {
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ sunoCreditType,
4
+ SUNO_VERSION_PRICED_OPERATIONS,
5
+ SUNO_SELECT_OPERATIONS,
6
+ SUNO_MODELS,
7
+ } from "../index.js"
8
+
9
+ describe("sunoCreditType — the ONE implementation of the Suno route pricing contract", () => {
10
+ it("prices generate/cover/extend by model version", () => {
11
+ expect(sunoCreditType("V5_5", "suno-generate")).toBe("suno-v5_5")
12
+ expect(sunoCreditType("V5", "suno-cover")).toBe("suno-v5")
13
+ expect(sunoCreditType("V5_5", "suno-extend")).toBe("suno-v5_5")
14
+ })
15
+
16
+ it("falls back to the operation key for versions with no dedicated price", () => {
17
+ expect(sunoCreditType("V4", "suno-generate")).toBe("suno-generate")
18
+ expect(sunoCreditType("V4_5ALL", "suno-cover")).toBe("suno-cover")
19
+ expect(sunoCreditType(undefined, "suno-extend")).toBe("suno-extend")
20
+ })
21
+
22
+ // The reason this function is gated rather than a bare version map: these four
23
+ // routes charge a FLAT per-operation key no matter which version the node
24
+ // carries (routes/suno.ts:648, :852, :907, :1016). A quote of "suno-v5_5" for
25
+ // one of them is a key the route never charges.
26
+ it.each([
27
+ "suno-mashup",
28
+ "suno-add-instrumental",
29
+ "suno-add-vocals",
30
+ "suno-upload-extend",
31
+ ])("keeps the flat operation key for %s regardless of version", (operation) => {
32
+ for (const model of SUNO_MODELS) {
33
+ expect(sunoCreditType(model, operation)).toBe(operation)
34
+ }
35
+ expect(sunoCreditType(undefined, operation)).toBe(operation)
36
+ })
37
+
38
+ it("returns an unknown operation unchanged (never invents a key)", () => {
39
+ expect(sunoCreditType("V5", "suno-voice")).toBe("suno-voice")
40
+ expect(sunoCreditType("V5_5", "not-a-suno-op")).toBe("not-a-suno-op")
41
+ })
42
+
43
+ it("exports exactly the three version-priced operations", () => {
44
+ expect([...SUNO_VERSION_PRICED_OPERATIONS]).toEqual([
45
+ "suno-generate",
46
+ "suno-cover",
47
+ "suno-extend",
48
+ ])
49
+ })
50
+
51
+ it("exports exactly the seven select operations", () => {
52
+ expect(SUNO_SELECT_OPERATIONS.length).toBe(7)
53
+ })
54
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { stripDerivedAnalysisFields, videoAnalysisResultSchema } from "../video-analysis.js"
3
+
4
+ /** A real result shape (validated below) so the strip list is tested against
5
+ * the canonical schema, not an invented fixture. */
6
+ const analysis = {
7
+ meta: { durationSec: 12, width: 1920, height: 1080, aspectRatio: "16:9", title: "Clip" },
8
+ slots: [
9
+ { slotId: "hero", label: "Hero", source: "wired-character", role: "person", description: "tan man, mustache, black tee", refImageUrl: "https://r2.example/hero.png" },
10
+ ],
11
+ scenes: [
12
+ { sceneNumber: 1, startSec: 0, endSec: 4, label: "Hook", shotType: "Medium Close-Up", camera: "slow push-in", visual: "{slot:hero} waves", visualResolved: "Hero waves", slotRefs: ["hero"], oversized: false, audio: [] },
13
+ { sceneNumber: 2, startSec: 4, endSec: 12, label: "Turn", shotType: "Medium Close-Up", camera: "static", visual: "an empty street", visualResolved: "an empty street", slotRefs: [], oversized: true, audio: [] },
14
+ ],
15
+ variationFolds: [{ slotId: "hero", variationId: "v2", label: "coat" }],
16
+ warnings: ["auto-cast dropped one duplicate line"],
17
+ }
18
+
19
+ describe("stripDerivedAnalysisFields", () => {
20
+ it("the fixture is a real result (the strip list is tested against the canonical shape)", () => {
21
+ expect(videoAnalysisResultSchema.safeParse(analysis).success).toBe(true)
22
+ })
23
+
24
+ it("drops the derived top-level and per-scene keys and keeps everything else, refImageUrl included", () => {
25
+ const out = stripDerivedAnalysisFields(analysis) as Record<string, unknown>
26
+ expect(out).not.toHaveProperty("warnings")
27
+ expect(out).not.toHaveProperty("variationFolds")
28
+ expect(out.meta).toEqual(analysis.meta)
29
+ expect((out.slots as Array<Record<string, unknown>>)[0].refImageUrl).toBe("https://r2.example/hero.png")
30
+ const scenes = out.scenes as Array<Record<string, unknown>>
31
+ expect(scenes).toHaveLength(2)
32
+ for (const scene of scenes) {
33
+ expect(scene).not.toHaveProperty("visualResolved")
34
+ expect(scene).not.toHaveProperty("slotRefs")
35
+ expect(scene).not.toHaveProperty("oversized")
36
+ expect(scene).toHaveProperty("visual")
37
+ expect(scene).toHaveProperty("audio")
38
+ expect(scene).toHaveProperty("sceneNumber")
39
+ }
40
+ })
41
+
42
+ it("never mutates its input", () => {
43
+ const frozen = JSON.parse(JSON.stringify(analysis))
44
+ stripDerivedAnalysisFields(analysis)
45
+ expect(analysis).toEqual(frozen)
46
+ })
47
+
48
+ it("returns a non-object input untouched and tolerates a non-object scene entry", () => {
49
+ expect(stripDerivedAnalysisFields("nope")).toBe("nope")
50
+ expect(stripDerivedAnalysisFields(null)).toBe(null)
51
+ expect(stripDerivedAnalysisFields([1])).toEqual([1])
52
+ const out = stripDerivedAnalysisFields({ scenes: [1, "x", null] }) as { scenes: unknown[] }
53
+ expect(out.scenes).toEqual([1, "x", null])
54
+ })
55
+
56
+ it("is roughly half the bytes of the full result on a real-shaped scene", () => {
57
+ const full = JSON.stringify(analysis).length
58
+ const stripped = JSON.stringify(stripDerivedAnalysisFields(analysis)).length
59
+ expect(stripped).toBeLessThan(full)
60
+ })
61
+ })