@nodaro/shared 2.19.0 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +548 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1043 -386
- package/dist/index.d.ts +1043 -386
- package/dist/index.js +529 -32
- 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__/safety-retry-policy.test.ts +47 -0
- package/src/__tests__/suno-credit-type.test.ts +54 -0
- package/src/__tests__/topaz-upscale.test.ts +132 -0
- package/src/__tests__/unresolved-ref-tokens.test.ts +66 -0
- package/src/__tests__/video-analysis-brief.test.ts +61 -0
- package/src/__tests__/video-analysis.test.ts +83 -0
- package/src/__tests__/video-audio-capability.test.ts +38 -4
- package/src/__tests__/video-catalog-totality.test.ts +85 -0
- package/src/__tests__/video-collapse-parity.test.ts +76 -0
- package/src/__tests__/video-ref-video-duration-limits.test.ts +69 -0
- package/src/__tests__/video-request-normalize.test.ts +239 -0
- package/src/credit-identifiers.ts +264 -20
- package/src/index.ts +30 -2
- package/src/model-catalog.ts +287 -6
- package/src/model-constants.ts +185 -13
- package/src/node-refs.ts +82 -0
- package/src/node-runtime-keys.ts +5 -0
- package/src/normalize-node-params.ts +8 -0
- package/src/organizations/types.ts +12 -0
- package/src/organizations/views.ts +114 -0
- package/src/safety-retry-policy.ts +37 -0
- package/src/topaz-upscale.ts +163 -0
- package/src/video-analysis.ts +115 -5
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,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { safetyRetryPolicy } from "../safety-retry-policy.js"
|
|
3
|
+
import { MODEL_CATALOG, getModel } from "../model-catalog.js"
|
|
4
|
+
|
|
5
|
+
describe("safetyRetryPolicy", () => {
|
|
6
|
+
it("gives gpt-image-2 a second attempt and names its fallback", () => {
|
|
7
|
+
expect(safetyRetryPolicy("gpt-image-2")).toEqual({
|
|
8
|
+
maxAttempts: 2,
|
|
9
|
+
fallback: "nano-banana-pro",
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("gives a model without the flag a single attempt and no fallback", () => {
|
|
14
|
+
// nano-banana-pro does not declare `safetyFilter` itself.
|
|
15
|
+
expect(safetyRetryPolicy("nano-banana-pro")).toEqual({ maxAttempts: 1 })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("gives an unknown model id a single attempt and no fallback", () => {
|
|
19
|
+
expect(safetyRetryPolicy("totally-not-a-real-model-id")).toEqual({ maxAttempts: 1 })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("every declared fallback resolves to a catalog entry that can actually cover the flagged model", () => {
|
|
23
|
+
const flagged = Object.values(MODEL_CATALOG).filter((m) => m.safetyFilter?.fallback)
|
|
24
|
+
// Sanity: this guard is only meaningful if at least one entry exercises it.
|
|
25
|
+
expect(flagged.length).toBeGreaterThan(0)
|
|
26
|
+
|
|
27
|
+
for (const entry of flagged) {
|
|
28
|
+
const fallbackId = entry.safetyFilter!.fallback!
|
|
29
|
+
const fallbackEntry = getModel(fallbackId)
|
|
30
|
+
|
|
31
|
+
expect(fallbackEntry, `${entry.id} declares fallback "${fallbackId}" which is not a catalog entry`).toBeDefined()
|
|
32
|
+
expect(fallbackEntry!.kind, `${entry.id}'s fallback "${fallbackId}" does not produce an image`).toBe("image")
|
|
33
|
+
|
|
34
|
+
for (const mode of entry.modes) {
|
|
35
|
+
expect(
|
|
36
|
+
fallbackEntry!.modes,
|
|
37
|
+
`${entry.id}'s fallback "${fallbackId}" is missing mode "${mode}" that ${entry.id} supports`,
|
|
38
|
+
).toContain(mode)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
fallbackEntry!.features ?? [],
|
|
43
|
+
`${entry.id}'s fallback "${fallbackId}" does not accept a reference image`,
|
|
44
|
+
).toContain("reference-image")
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -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,132 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { resolveTopazUpscale, buildCreditModelIdentifier } from "../index.js"
|
|
3
|
+
|
|
4
|
+
describe("resolveTopazUpscale", () => {
|
|
5
|
+
it("defaults to the provider's own default factor when nothing is set", () => {
|
|
6
|
+
const r = resolveTopazUpscale({})
|
|
7
|
+
expect(r.upscaleFactor).toBe("2")
|
|
8
|
+
expect(r.creditTier).toBeUndefined()
|
|
9
|
+
expect(r.adjustments).toEqual([])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("takes an explicit factor as the lever and prices it", () => {
|
|
13
|
+
expect(resolveTopazUpscale({ upscaleFactor: "1" })).toMatchObject({ upscaleFactor: "1", creditTier: undefined })
|
|
14
|
+
expect(resolveTopazUpscale({ upscaleFactor: "2" })).toMatchObject({ upscaleFactor: "2", creditTier: undefined })
|
|
15
|
+
expect(resolveTopazUpscale({ upscaleFactor: "4" })).toMatchObject({ upscaleFactor: "4", creditTier: "4K" })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("an explicit factor wins over a legacy targetResolution (the factor is what we send)", () => {
|
|
19
|
+
const r = resolveTopazUpscale({ upscaleFactor: "2", targetResolution: "4K" })
|
|
20
|
+
expect(r.upscaleFactor).toBe("2")
|
|
21
|
+
expect(r.creditTier).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("maps a legacy targetResolution forward when no factor is set", () => {
|
|
25
|
+
expect(resolveTopazUpscale({ targetResolution: "2K" })).toMatchObject({ upscaleFactor: "2", creditTier: undefined })
|
|
26
|
+
expect(resolveTopazUpscale({ targetResolution: "4K" })).toMatchObject({ upscaleFactor: "4", creditTier: "4K" })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("caps a legacy 8K request at the 4x the provider actually offers, and says so", () => {
|
|
30
|
+
const r = resolveTopazUpscale({ targetResolution: "8K" })
|
|
31
|
+
expect(r.upscaleFactor).toBe("4")
|
|
32
|
+
expect(r.creditTier).toBe("4K")
|
|
33
|
+
expect(r.adjustments).toHaveLength(1)
|
|
34
|
+
expect(r.adjustments[0]).toMatchObject({ field: "targetResolution", from: "8K", to: "4" })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("coerces an out-of-enum factor and records the adjustment", () => {
|
|
38
|
+
const r = resolveTopazUpscale({ upscaleFactor: "8" })
|
|
39
|
+
expect(r.upscaleFactor).toBe("2")
|
|
40
|
+
expect(r.adjustments[0]).toMatchObject({ field: "upscaleFactor", from: "8", to: "2" })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("every resolution it can return prices through the existing composites", () => {
|
|
44
|
+
for (const input of [
|
|
45
|
+
{}, { upscaleFactor: "1" }, { upscaleFactor: "2" }, { upscaleFactor: "4" },
|
|
46
|
+
{ targetResolution: "2K" }, { targetResolution: "4K" }, { targetResolution: "8K" },
|
|
47
|
+
]) {
|
|
48
|
+
const { creditTier } = resolveTopazUpscale(input)
|
|
49
|
+
const id = buildCreditModelIdentifier("topaz-image-upscale", undefined, undefined, undefined, creditTier)
|
|
50
|
+
expect(["topaz-image-upscale", "topaz-image-upscale:4K"]).toContain(id)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// --- Fix round 1 (2026-09-02 review): factor resolved before adjustments
|
|
55
|
+
// are built, `to` always pinned to the resolved factor, `from` always the
|
|
56
|
+
// raw untransformed input, and a valid factor's override of a stored
|
|
57
|
+
// targetResolution is disclosed rather than silently dropped. ---
|
|
58
|
+
|
|
59
|
+
it("an invalid factor still lets a legacy tier win, and reports the REAL resolved factor", () => {
|
|
60
|
+
const r = resolveTopazUpscale({ upscaleFactor: "8", targetResolution: "4K" })
|
|
61
|
+
expect(r.upscaleFactor).toBe("4")
|
|
62
|
+
expect(r.creditTier).toBe("4K")
|
|
63
|
+
const factorAdj = r.adjustments.find((a) => a.field === "upscaleFactor")
|
|
64
|
+
expect(factorAdj).toMatchObject({ field: "upscaleFactor", from: "8", to: "4" })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("a valid factor overriding a disagreeing legacy tier discloses the override", () => {
|
|
68
|
+
const r = resolveTopazUpscale({ upscaleFactor: "2", targetResolution: "8K" })
|
|
69
|
+
expect(r.upscaleFactor).toBe("2")
|
|
70
|
+
const tierAdj = r.adjustments.find((a) => a.field === "targetResolution")
|
|
71
|
+
expect(tierAdj).toBeDefined()
|
|
72
|
+
expect(tierAdj).toMatchObject({ field: "targetResolution", from: "8K", to: undefined })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("a valid factor that agrees with the legacy tier's factor discloses nothing", () => {
|
|
76
|
+
const r = resolveTopazUpscale({ upscaleFactor: "4", targetResolution: "4K" })
|
|
77
|
+
expect(r.upscaleFactor).toBe("4")
|
|
78
|
+
expect(r.adjustments).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("an unknown legacy tier alone falls back to the default and reports the raw token", () => {
|
|
82
|
+
const r = resolveTopazUpscale({ targetResolution: "1080p" })
|
|
83
|
+
expect(r.upscaleFactor).toBe("2")
|
|
84
|
+
expect(r.creditTier).toBeUndefined()
|
|
85
|
+
expect(r.adjustments).toHaveLength(1)
|
|
86
|
+
expect(r.adjustments[0]).toMatchObject({ field: "targetResolution", from: "1080p", to: "2" })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const FACTOR_INPUTS: Array<string | undefined> = [undefined, "2", "4", "8", "foo", " 4 ", "4k"]
|
|
90
|
+
const TIER_INPUTS: Array<string | null | undefined> = [undefined, "2K", "4K", "8K", "4k", "1080p", null]
|
|
91
|
+
|
|
92
|
+
describe("factor x targetResolution table", () => {
|
|
93
|
+
for (const upscaleFactor of FACTOR_INPUTS) {
|
|
94
|
+
for (const targetResolution of TIER_INPUTS) {
|
|
95
|
+
const label = `upscaleFactor=${JSON.stringify(upscaleFactor)} targetResolution=${JSON.stringify(targetResolution)}`
|
|
96
|
+
it(`${label} — adjustments never disclose a factor other than the resolved one`, () => {
|
|
97
|
+
const result = resolveTopazUpscale({ upscaleFactor, targetResolution })
|
|
98
|
+
|
|
99
|
+
// Every field:"upscaleFactor" adjustment must announce the factor
|
|
100
|
+
// that was ACTUALLY resolved — never an intermediate guess.
|
|
101
|
+
expect(
|
|
102
|
+
result.adjustments.every((a) => a.field !== "upscaleFactor" || a.to === result.upscaleFactor),
|
|
103
|
+
).toBe(true)
|
|
104
|
+
|
|
105
|
+
// `from` is always the raw, untransformed input — never trimmed or
|
|
106
|
+
// case-normalized — for both adjustment kinds.
|
|
107
|
+
for (const adj of result.adjustments) {
|
|
108
|
+
if (adj.field === "upscaleFactor") {
|
|
109
|
+
expect(adj.from).toBe(upscaleFactor)
|
|
110
|
+
} else {
|
|
111
|
+
expect(adj.from).toBe(targetResolution)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The resolved factor and credit tier are always internally consistent.
|
|
116
|
+
expect(result.creditTier).toBe(result.upscaleFactor === "4" ? "4K" : undefined)
|
|
117
|
+
|
|
118
|
+
// Every resolution this function can produce must still price
|
|
119
|
+
// through an existing composite identifier.
|
|
120
|
+
const id = buildCreditModelIdentifier(
|
|
121
|
+
"topaz-image-upscale",
|
|
122
|
+
undefined,
|
|
123
|
+
undefined,
|
|
124
|
+
undefined,
|
|
125
|
+
result.creditTier,
|
|
126
|
+
)
|
|
127
|
+
expect(["topaz-image-upscale", "topaz-image-upscale:4K"]).toContain(id)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|