@nodaro/shared 2.19.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.
- package/dist/index.cjs +99 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +854 -557
- package/dist/index.d.ts +854 -557
- package/dist/index.js +92 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/credit-identifiers.test.ts +133 -0
- package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
- package/src/__tests__/normalize-node-params.test.ts +36 -0
- package/src/__tests__/organizations-types.test.ts +29 -1
- package/src/__tests__/prompt-length-limits.test.ts +36 -0
- package/src/__tests__/suno-credit-type.test.ts +54 -0
- package/src/__tests__/video-analysis-brief.test.ts +61 -0
- package/src/credit-identifiers.ts +149 -12
- package/src/index.ts +8 -0
- package/src/model-constants.ts +69 -9
- package/src/normalize-node-params.ts +8 -0
- package/src/organizations/types.ts +12 -0
- package/src/organizations/views.ts +114 -0
- package/src/video-analysis.ts +45 -0
package/package.json
CHANGED
|
@@ -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"
|
|
@@ -870,3 +872,134 @@ describe("gemini-omni-flash", () => {
|
|
|
870
872
|
}
|
|
871
873
|
})
|
|
872
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
|
+
})
|
|
@@ -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,
|
|
@@ -123,6 +126,39 @@ describe("per-model prompt length limits", () => {
|
|
|
123
126
|
expect(getMaxSunoStyleChars("V5")).toBe(1000)
|
|
124
127
|
expect(SUNO_TITLE_MAX).toBe(80)
|
|
125
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
|
+
})
|
|
126
162
|
})
|
|
127
163
|
|
|
128
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
|
+
})
|