@nodaro/shared 2.2.1 → 2.5.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 +166 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +171 -4
- package/dist/index.d.ts +171 -4
- package/dist/index.js +158 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/effective-tier.test.ts +116 -0
- package/src/__tests__/normalize-model-input.test.ts +153 -0
- package/src/__tests__/normalize-node-params.test.ts +95 -0
- package/src/effective-tier.ts +72 -0
- package/src/index.ts +18 -0
- package/src/model-catalog.ts +161 -0
- package/src/model-constants.ts +6 -1
- package/src/normalize-node-params.ts +126 -0
- package/src/pipeline-defaults.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
resolveStoredTier,
|
|
4
|
+
resolveEffectiveTier,
|
|
5
|
+
isPaygRetentionActive,
|
|
6
|
+
PAYG_RETENTION_DAYS,
|
|
7
|
+
} from "../effective-tier.js"
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
const NOW = new Date("2026-08-12T12:00:00Z")
|
|
11
|
+
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY_MS)
|
|
12
|
+
|
|
13
|
+
describe("resolveStoredTier", () => {
|
|
14
|
+
it("prefers tier, falls back to subscription_tier, then free", () => {
|
|
15
|
+
expect(resolveStoredTier({ tier: "pro", subscription_tier: "basic" })).toBe("pro")
|
|
16
|
+
expect(resolveStoredTier({ tier: null, subscription_tier: "basic" })).toBe("basic")
|
|
17
|
+
expect(resolveStoredTier({ tier: null, subscription_tier: null })).toBe("free")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("resolveEffectiveTier — the payg derivation matrix (design §9)", () => {
|
|
22
|
+
it("never-paid free user stays free", () => {
|
|
23
|
+
expect(
|
|
24
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
25
|
+
).toBe("free")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("free user with NET lifetime > 0 derives payg — even at zero current balance", () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 3300 })
|
|
31
|
+
).toBe("payg")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("refunded-to-zero user drops back to free (NET lifetime)", () => {
|
|
35
|
+
expect(
|
|
36
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
37
|
+
).toBe("free")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("#489 pre-subscribe carryover fixture: topup balance without purchase stays free", () => {
|
|
41
|
+
// Cancel-path carryover moves a pre-subscribe balance into topup_credits
|
|
42
|
+
// with NO purchase — lifetime stays 0, so the user must NOT derive payg.
|
|
43
|
+
expect(
|
|
44
|
+
resolveEffectiveTier({ tier: "free", subscription_tier: null, lifetime_topup_credits: 0 })
|
|
45
|
+
).toBe("free")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("every stored paid tier passes through untouched, regardless of lifetime", () => {
|
|
49
|
+
for (const t of ["basic", "standard", "pro", "business"]) {
|
|
50
|
+
expect(
|
|
51
|
+
resolveEffectiveTier({ tier: t, subscription_tier: null, lifetime_topup_credits: 9999 })
|
|
52
|
+
).toBe(t)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("subscription_tier-only legacy rows resolve through the stored fallback", () => {
|
|
57
|
+
expect(
|
|
58
|
+
resolveEffectiveTier({ tier: null, subscription_tier: "standard", lifetime_topup_credits: 500 })
|
|
59
|
+
).toBe("standard")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("null-tier never-paid rows resolve free; with lifetime they derive payg", () => {
|
|
63
|
+
expect(
|
|
64
|
+
resolveEffectiveTier({ tier: null, subscription_tier: null, lifetime_topup_credits: 0 })
|
|
65
|
+
).toBe("free")
|
|
66
|
+
expect(
|
|
67
|
+
resolveEffectiveTier({ tier: null, subscription_tier: null, lifetime_topup_credits: 100 })
|
|
68
|
+
).toBe("payg")
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("isPaygRetentionActive — 90-day activity window boundaries", () => {
|
|
73
|
+
it("exports the 90-day constant", () => {
|
|
74
|
+
expect(PAYG_RETENTION_DAYS).toBe(90)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("purchase activity: 89d active, 90d active (inclusive), 91d inactive", () => {
|
|
78
|
+
const base = { lifetimeTopupCredits: 100, lastSpendAt: null }
|
|
79
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(89) }, NOW)).toBe(true)
|
|
80
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(90) }, NOW)).toBe(true)
|
|
81
|
+
expect(isPaygRetentionActive({ ...base, lastTopupAt: daysAgo(91) }, NOW)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("spend activity counts on its own (usage_logs MAX, not balance polls)", () => {
|
|
85
|
+
const base = { lifetimeTopupCredits: 100, lastTopupAt: daysAgo(200) }
|
|
86
|
+
expect(isPaygRetentionActive({ ...base, lastSpendAt: daysAgo(10) }, NOW)).toBe(true)
|
|
87
|
+
expect(isPaygRetentionActive({ ...base, lastSpendAt: daysAgo(91) }, NOW)).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("either source alone is sufficient; the most recent wins", () => {
|
|
91
|
+
expect(
|
|
92
|
+
isPaygRetentionActive(
|
|
93
|
+
{ lifetimeTopupCredits: 100, lastTopupAt: daysAgo(120), lastSpendAt: daysAgo(5) },
|
|
94
|
+
NOW
|
|
95
|
+
)
|
|
96
|
+
).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("string timestamps (supabase rows) are accepted", () => {
|
|
100
|
+
expect(
|
|
101
|
+
isPaygRetentionActive(
|
|
102
|
+
{ lifetimeTopupCredits: 100, lastTopupAt: daysAgo(5).toISOString(), lastSpendAt: null },
|
|
103
|
+
NOW
|
|
104
|
+
)
|
|
105
|
+
).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("all-null activity is inactive; never-paid users are never retention-active", () => {
|
|
109
|
+
expect(
|
|
110
|
+
isPaygRetentionActive({ lifetimeTopupCredits: 100, lastTopupAt: null, lastSpendAt: null }, NOW)
|
|
111
|
+
).toBe(false)
|
|
112
|
+
expect(
|
|
113
|
+
isPaygRetentionActive({ lifetimeTopupCredits: 0, lastTopupAt: daysAgo(1), lastSpendAt: null }, NOW)
|
|
114
|
+
).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
MODEL_CATALOG,
|
|
4
|
+
normalizeModelInput,
|
|
5
|
+
validateModelInput,
|
|
6
|
+
defaultResolutionFor,
|
|
7
|
+
} from "../model-catalog.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `normalizeModelInput` is the correcting twin of `validateModelInput`, used at
|
|
11
|
+
* persistence and execution boundaries where rejecting a fixable value would
|
|
12
|
+
* abort a run (and take every already-billed sibling node with it).
|
|
13
|
+
*
|
|
14
|
+
* The load-bearing invariant is the round-trip: whatever the normalizer emits
|
|
15
|
+
* MUST satisfy the validator. If those two ever disagree, a "normalized" node
|
|
16
|
+
* still 400s upstream and the whole exercise is theatre.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
describe("normalizeModelInput", () => {
|
|
20
|
+
it("leaves an already-valid combination untouched", () => {
|
|
21
|
+
const out = normalizeModelInput("gpt-image-2", { aspectRatio: "16:9", resolution: "2K" })
|
|
22
|
+
expect(out.aspectRatio).toBe("16:9")
|
|
23
|
+
expect(out.resolution).toBe("2K")
|
|
24
|
+
expect(out.adjustments).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("snaps the aspect ratio that aborted the 2026-08-09 run", () => {
|
|
28
|
+
// gpt-image (GPT Image 1.5) accepts 1:1 / 3:2 / 2:3 only — KIE rejects 16:9.
|
|
29
|
+
const out = normalizeModelInput("gpt-image", { aspectRatio: "16:9" })
|
|
30
|
+
expect(out.aspectRatio).not.toBe("16:9")
|
|
31
|
+
expect(MODEL_CATALOG["gpt-image"].aspectRatios).toContain(out.aspectRatio!)
|
|
32
|
+
expect(out.adjustments).toHaveLength(1)
|
|
33
|
+
expect(out.adjustments[0].field).toBe("aspectRatio")
|
|
34
|
+
expect(out.adjustments[0].from).toBe("16:9")
|
|
35
|
+
// The reason is user-facing — it must name the alternatives.
|
|
36
|
+
expect(out.adjustments[0].reason).toContain("3:2")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("drops a lever the model does not have at all", () => {
|
|
40
|
+
// Same node also carried resolution "2K"; GPT Image 1.5 has no resolution.
|
|
41
|
+
const out = normalizeModelInput("gpt-image", { resolution: "2K" })
|
|
42
|
+
expect(out.resolution).toBeUndefined()
|
|
43
|
+
expect(out.adjustments[0]).toMatchObject({ field: "resolution", from: "2K", to: undefined })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("canonicalizes an equivalent spelling instead of re-pricing the node", () => {
|
|
47
|
+
// Flux 2 resolution reaches the payload builder as a bare megapixel count
|
|
48
|
+
// ("1"); the catalog lists the display form ("1 MP"). Treating that as
|
|
49
|
+
// invalid would snap it to the 2 MP default — a silent price increase on a
|
|
50
|
+
// node the user configured correctly.
|
|
51
|
+
const out = normalizeModelInput("flux-2-pro", { resolution: "1" })
|
|
52
|
+
expect(out.resolution).toBe("1 MP")
|
|
53
|
+
expect(out.adjustments).toEqual([])
|
|
54
|
+
// Case drift is the same class of non-change.
|
|
55
|
+
expect(normalizeModelInput("nano-banana-pro", { resolution: "4k" }).resolution).toBe("4K")
|
|
56
|
+
expect(normalizeModelInput("nano-banana-pro", { resolution: "4k" }).adjustments).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("does NOT treat a different unit as equivalent", () => {
|
|
60
|
+
// "1" must not quietly satisfy a 1K/2K/4K model — those are different scales.
|
|
61
|
+
const out = normalizeModelInput("nano-banana-pro", { resolution: "1" })
|
|
62
|
+
expect(out.resolution).not.toBe("1")
|
|
63
|
+
expect(MODEL_CATALOG["nano-banana-pro"].resolutions).toContain(out.resolution!)
|
|
64
|
+
expect(out.adjustments).toHaveLength(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("prefers the Flux 2 default over the cheapest option when snapping", () => {
|
|
68
|
+
// options[0] is "0.5 MP" — snapping there would silently downgrade quality.
|
|
69
|
+
const out = normalizeModelInput("flux-2-pro", { resolution: "2K" })
|
|
70
|
+
expect(out.resolution).toBe(defaultResolutionFor("flux-2-pro"))
|
|
71
|
+
expect(out.resolution).toBe("2 MP")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("applies the gpt-image-2 cross-field rules after snapping", () => {
|
|
75
|
+
expect(normalizeModelInput("gpt-image-2", { aspectRatio: "auto", resolution: "4K" }).resolution).toBe("1K")
|
|
76
|
+
expect(normalizeModelInput("gpt-image-2", { aspectRatio: "1:1", resolution: "4K" }).resolution).toBe("2K")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("passes unknown model ids through untouched (the Zod enum owns those)", () => {
|
|
80
|
+
const out = normalizeModelInput("totally-fake-model", { aspectRatio: "21:9" })
|
|
81
|
+
expect(out.aspectRatio).toBe("21:9")
|
|
82
|
+
expect(out.adjustments).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("is idempotent — normalizing twice changes nothing the second time", () => {
|
|
86
|
+
const once = normalizeModelInput("gpt-image", { aspectRatio: "16:9", resolution: "2K" })
|
|
87
|
+
const twice = normalizeModelInput("gpt-image", {
|
|
88
|
+
aspectRatio: once.aspectRatio,
|
|
89
|
+
resolution: once.resolution,
|
|
90
|
+
})
|
|
91
|
+
expect(twice.adjustments).toEqual([])
|
|
92
|
+
expect(twice.aspectRatio).toBe(once.aspectRatio)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
// The invariant. Runs over the WHOLE catalog so a model added later is
|
|
97
|
+
// covered by default rather than by anyone remembering to extend a list.
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
it("INVARIANT: normalized output always satisfies validateModelInput", () => {
|
|
100
|
+
// Deliberately hostile inputs — a value from some OTHER model's allow-list
|
|
101
|
+
// is exactly what a provider switch or a non-UI author leaves behind.
|
|
102
|
+
const hostile = [
|
|
103
|
+
{ aspectRatio: "16:9" },
|
|
104
|
+
{ aspectRatio: "auto" },
|
|
105
|
+
{ aspectRatio: "1:1", resolution: "4K" },
|
|
106
|
+
{ aspectRatio: "21:9", resolution: "2K", quality: "high" },
|
|
107
|
+
{ resolution: "0.5 MP" },
|
|
108
|
+
{ quality: "basic" },
|
|
109
|
+
{ duration: 7 },
|
|
110
|
+
{ aspectRatio: "9:21", resolution: "8K", quality: "TURBO", duration: 999 },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
const failures: string[] = []
|
|
114
|
+
for (const modelId of Object.keys(MODEL_CATALOG)) {
|
|
115
|
+
for (const input of hostile) {
|
|
116
|
+
const out = normalizeModelInput(modelId, input)
|
|
117
|
+
const issue = validateModelInput(modelId, {
|
|
118
|
+
aspectRatio: out.aspectRatio,
|
|
119
|
+
resolution: out.resolution,
|
|
120
|
+
quality: out.quality,
|
|
121
|
+
duration: out.duration,
|
|
122
|
+
})
|
|
123
|
+
if (issue) {
|
|
124
|
+
failures.push(
|
|
125
|
+
`${modelId} ← ${JSON.stringify(input)} → ${JSON.stringify({
|
|
126
|
+
aspectRatio: out.aspectRatio,
|
|
127
|
+
resolution: out.resolution,
|
|
128
|
+
quality: out.quality,
|
|
129
|
+
duration: out.duration,
|
|
130
|
+
})}: ${issue.message}`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
expect(failures, `normalizeModelInput emitted values validateModelInput rejects:\n${failures.join("\n")}`).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("INVARIANT: every adjustment names a real change", () => {
|
|
140
|
+
for (const modelId of Object.keys(MODEL_CATALOG)) {
|
|
141
|
+
const out = normalizeModelInput(modelId, {
|
|
142
|
+
aspectRatio: "21:9",
|
|
143
|
+
resolution: "8K",
|
|
144
|
+
quality: "high",
|
|
145
|
+
duration: 999,
|
|
146
|
+
})
|
|
147
|
+
for (const adj of out.adjustments) {
|
|
148
|
+
expect(adj.from, `${modelId}/${adj.field} reported a no-op adjustment`).not.toEqual(adj.to)
|
|
149
|
+
expect(adj.reason.length).toBeGreaterThan(0)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { normalizeNodeModelParams, MODEL_PARAM_NODE_TYPES } from "../normalize-node-params.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Write-boundary guard for agent/import-authored graphs. The config panel's
|
|
6
|
+
* provider-aware dropdown and its stale-value snap are React effects — they
|
|
7
|
+
* only run for a node whose panel or hover strip is mounted, so a node written
|
|
8
|
+
* straight into workflow JSON never meets either.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const node = (id: string, type: string, data: Record<string, unknown>) => ({
|
|
12
|
+
id,
|
|
13
|
+
type,
|
|
14
|
+
position: { x: 0, y: 0 },
|
|
15
|
+
data,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("normalizeNodeModelParams", () => {
|
|
19
|
+
it("heals the exact node that aborted the 2026-08-09 run", () => {
|
|
20
|
+
const { nodes, adjustments } = normalizeNodeModelParams([
|
|
21
|
+
node("node_8", "generate-image", { provider: "gpt-image", aspectRatio: "16:9", resolution: "2K" }),
|
|
22
|
+
])
|
|
23
|
+
const d = nodes[0].data as Record<string, unknown>
|
|
24
|
+
expect(d.aspectRatio).not.toBe("16:9")
|
|
25
|
+
expect(d.resolution).toBeUndefined() // GPT Image 1.5 has no resolution lever
|
|
26
|
+
expect(adjustments.map((a) => a.field).sort()).toEqual(["aspectRatio", "resolution"])
|
|
27
|
+
expect(adjustments[0].nodeId).toBe("node_8")
|
|
28
|
+
expect(adjustments[0].provider).toBe("gpt-image")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("leaves the sibling nodes that were already valid completely alone", () => {
|
|
32
|
+
// Same workflow, the five nodes that generated fine — must be untouched,
|
|
33
|
+
// and returned BY REFERENCE so a delta/CAS save sees no spurious change.
|
|
34
|
+
const input = [
|
|
35
|
+
node("a", "generate-image", { provider: "gpt-image-2", aspectRatio: "16:9", resolution: "2K" }),
|
|
36
|
+
node("b", "generate-image", { provider: "grok", aspectRatio: "16:9" }),
|
|
37
|
+
]
|
|
38
|
+
const { nodes, adjustments } = normalizeNodeModelParams(input)
|
|
39
|
+
expect(adjustments).toEqual([])
|
|
40
|
+
expect(nodes[0]).toBe(input[0])
|
|
41
|
+
expect(nodes[1]).toBe(input[1])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("never mutates the caller's node objects", () => {
|
|
45
|
+
const input = [node("n1", "generate-image", { provider: "gpt-image", aspectRatio: "16:9" })]
|
|
46
|
+
const before = JSON.parse(JSON.stringify(input))
|
|
47
|
+
normalizeNodeModelParams(input)
|
|
48
|
+
expect(input).toEqual(before)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("ignores node types that carry no catalog-governed params", () => {
|
|
52
|
+
const input = [
|
|
53
|
+
node("t1", "text-prompt", { provider: "gpt-image", aspectRatio: "16:9" }),
|
|
54
|
+
node("v1", "image-to-video", { provider: "veo3", aspectRatio: "21:9" }),
|
|
55
|
+
]
|
|
56
|
+
const { nodes, adjustments } = normalizeNodeModelParams(input)
|
|
57
|
+
expect(adjustments).toEqual([])
|
|
58
|
+
expect(nodes).toEqual(input)
|
|
59
|
+
expect(MODEL_PARAM_NODE_TYPES.has("image-to-video")).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("skips multi-provider nodes rather than guessing an intersection", () => {
|
|
63
|
+
const input = [
|
|
64
|
+
node("m1", "generate-image", {
|
|
65
|
+
providers: ["gpt-image", "gpt-image-2"],
|
|
66
|
+
provider: "gpt-image",
|
|
67
|
+
aspectRatio: "16:9",
|
|
68
|
+
}),
|
|
69
|
+
]
|
|
70
|
+
const { nodes, adjustments } = normalizeNodeModelParams(input)
|
|
71
|
+
expect(adjustments).toEqual([])
|
|
72
|
+
expect(nodes[0]).toBe(input[0])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("survives malformed nodes without throwing", () => {
|
|
76
|
+
const input = [
|
|
77
|
+
{ id: "x", type: "generate-image" },
|
|
78
|
+
{ id: "y", type: "generate-image", data: null },
|
|
79
|
+
{ id: "z", type: "generate-image", data: { provider: 42 } },
|
|
80
|
+
{ type: "generate-image", data: { provider: "gpt-image", aspectRatio: "16:9" } },
|
|
81
|
+
] as Array<{ id?: unknown; type?: unknown; data?: unknown }>
|
|
82
|
+
expect(() => normalizeNodeModelParams(input)).not.toThrow()
|
|
83
|
+
const { adjustments } = normalizeNodeModelParams(input)
|
|
84
|
+
// The last entry has no id but IS healable — it reports under a placeholder.
|
|
85
|
+
expect(adjustments.every((a) => typeof a.nodeId === "string")).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("is idempotent — a second pass reports nothing", () => {
|
|
89
|
+
const first = normalizeNodeModelParams([
|
|
90
|
+
node("n1", "generate-image", { provider: "gpt-image", aspectRatio: "16:9", resolution: "2K" }),
|
|
91
|
+
])
|
|
92
|
+
const second = normalizeNodeModelParams(first.nodes)
|
|
93
|
+
expect(second.adjustments).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective-tier resolution — the payg derivation.
|
|
3
|
+
*
|
|
4
|
+
* "payg" is a DERIVED tier, never stored: a user whose stored tier resolves
|
|
5
|
+
* to "free" but who has NET lifetime top-up credits (granted − refunded,
|
|
6
|
+
* clamped ≥ 0 by every SQL writer) is treated as "payg" by entitlement
|
|
7
|
+
* checks. Stored tier stays untouched, so subscription webhooks never learn
|
|
8
|
+
* about payg and a cancel→free transition re-derives it automatically.
|
|
9
|
+
*
|
|
10
|
+
* Lives in @nodaro/shared because the pipeline tier maps already do, and
|
|
11
|
+
* both backend (credit gates, workers) and read surfaces need one source.
|
|
12
|
+
*
|
|
13
|
+
* The profile fields are REQUIRED (non-optional) on purpose: a call site
|
|
14
|
+
* whose SELECT forgot to fetch `lifetime_topup_credits` becomes a compile
|
|
15
|
+
* error instead of a silent `?? 0` that quietly deactivates payg — that is
|
|
16
|
+
* the failure mode this shape exists to prevent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Stored-tier resolution — mirrors SQL COALESCE(tier, subscription_tier, 'free'). */
|
|
20
|
+
export function resolveStoredTier(p: {
|
|
21
|
+
tier: string | null
|
|
22
|
+
subscription_tier: string | null
|
|
23
|
+
}): string {
|
|
24
|
+
return p.tier ?? p.subscription_tier ?? "free"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Effective tier: stored "free" with NET lifetime top-ups > 0 derives "payg".
|
|
29
|
+
* Every other stored tier passes through untouched.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveEffectiveTier(p: {
|
|
32
|
+
tier: string | null
|
|
33
|
+
subscription_tier: string | null
|
|
34
|
+
lifetime_topup_credits: number
|
|
35
|
+
}): string {
|
|
36
|
+
const stored = resolveStoredTier(p)
|
|
37
|
+
if (stored === "free" && p.lifetime_topup_credits > 0) return "payg"
|
|
38
|
+
return stored
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Media-retention activity window for payg users (design 2026-07-05 §4.6). */
|
|
42
|
+
export const PAYG_RETENTION_DAYS = 90
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is this payg user inside their retention-activity window?
|
|
46
|
+
*
|
|
47
|
+
* Activity = credit SPEND (callers supply MAX(usage_logs.created_at) — never
|
|
48
|
+
* `last_daily_reset`, which read-only balance polls bump) OR a top-up
|
|
49
|
+
* PURCHASE (`last_topup_at`). Within PAYG_RETENTION_DAYS of either → the
|
|
50
|
+
* nightly reaper leaves their media alone; past it, the standard free-tier
|
|
51
|
+
* reaper rules apply. A user with no lifetime purchases is never
|
|
52
|
+
* retention-active (they are not payg).
|
|
53
|
+
*/
|
|
54
|
+
export function isPaygRetentionActive(
|
|
55
|
+
p: {
|
|
56
|
+
lifetimeTopupCredits: number
|
|
57
|
+
lastTopupAt: string | Date | null
|
|
58
|
+
lastSpendAt: string | Date | null
|
|
59
|
+
},
|
|
60
|
+
now: Date
|
|
61
|
+
): boolean {
|
|
62
|
+
if (p.lifetimeTopupCredits <= 0) return false
|
|
63
|
+
const cutoff = now.getTime() - PAYG_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
|
64
|
+
const at = (v: string | Date | null): number | null => {
|
|
65
|
+
if (v === null) return null
|
|
66
|
+
const t = v instanceof Date ? v.getTime() : new Date(v).getTime()
|
|
67
|
+
return Number.isFinite(t) ? t : null
|
|
68
|
+
}
|
|
69
|
+
const topup = at(p.lastTopupAt)
|
|
70
|
+
const spend = at(p.lastSpendAt)
|
|
71
|
+
return (topup !== null && topup >= cutoff) || (spend !== null && spend >= cutoff)
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -540,6 +540,13 @@ export type {
|
|
|
540
540
|
SharedListing,
|
|
541
541
|
} from "./community.js"
|
|
542
542
|
|
|
543
|
+
export {
|
|
544
|
+
resolveStoredTier,
|
|
545
|
+
resolveEffectiveTier,
|
|
546
|
+
isPaygRetentionActive,
|
|
547
|
+
PAYG_RETENTION_DAYS,
|
|
548
|
+
} from "./effective-tier.js"
|
|
549
|
+
|
|
543
550
|
export {
|
|
544
551
|
NODE_DEFAULT_TYPES,
|
|
545
552
|
validateProviderForNodeType,
|
|
@@ -693,6 +700,8 @@ export {
|
|
|
693
700
|
creditRangesAll,
|
|
694
701
|
modelIdsByKindMode,
|
|
695
702
|
buildModelMenu,
|
|
703
|
+
normalizeModelInput,
|
|
704
|
+
defaultResolutionFor,
|
|
696
705
|
} from "./model-catalog.js"
|
|
697
706
|
export type {
|
|
698
707
|
ModelCatalogEntry,
|
|
@@ -704,6 +713,8 @@ export type {
|
|
|
704
713
|
ValidationField,
|
|
705
714
|
LabeledOption,
|
|
706
715
|
ModelMenuOption,
|
|
716
|
+
ModelInputAdjustment,
|
|
717
|
+
NormalizedModelInput,
|
|
707
718
|
} from "./model-catalog.js"
|
|
708
719
|
|
|
709
720
|
export {
|
|
@@ -876,6 +887,13 @@ export type { VoiceChangerModel } from "./voice-changer-models.js"
|
|
|
876
887
|
|
|
877
888
|
// --- Node presets ---
|
|
878
889
|
export { EXECUTION_DATA_KEYS, TRANSIENT_RUNTIME_KEYS, stripTransientRuntimeData } from "./node-runtime-keys.js"
|
|
890
|
+
|
|
891
|
+
export {
|
|
892
|
+
MODEL_PARAM_NODE_TYPES,
|
|
893
|
+
normalizeNodeModelParams,
|
|
894
|
+
describeNodeAdjustments,
|
|
895
|
+
} from "./normalize-node-params.js"
|
|
896
|
+
export type { NodeParamAdjustment, NormalizedNodes } from "./normalize-node-params.js"
|
|
879
897
|
export { extractPresetData, PRESET_EXCLUDED_KEYS, PRESET_APPLY_CLEAR_KEYS, presetDataMatches } from "./node-preset-extract.js"
|
|
880
898
|
|
|
881
899
|
// --- Factory prompt-snippets (reusable inline prompt fragments) ---
|
package/src/model-catalog.ts
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* `docs/choosing-models.md` guide. CI (`gen:skills:check`) fails on drift.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { isFlux2Model } from "./flux2-pricing.js"
|
|
32
|
+
|
|
31
33
|
export type ModelKind = "image" | "video" | "audio"
|
|
32
34
|
|
|
33
35
|
export type ModelMode =
|
|
@@ -2385,6 +2387,165 @@ export function validateModelInput(
|
|
|
2385
2387
|
return null
|
|
2386
2388
|
}
|
|
2387
2389
|
|
|
2390
|
+
/**
|
|
2391
|
+
* The resolution a Flux 2 model should land on when its stored value is absent
|
|
2392
|
+
* or invalid. Flux 2 exposes ascending megapixel options ("0.5 MP"…"4 MP"), so
|
|
2393
|
+
* snapping to `options[0]` would silently downgrade every node to the cheapest
|
|
2394
|
+
* tier; each variant has a sensible mid default instead. Returns undefined for
|
|
2395
|
+
* every non-Flux-2 model (they snap to `options[0]` normally).
|
|
2396
|
+
*/
|
|
2397
|
+
export function defaultResolutionFor(modelId: string): string | undefined {
|
|
2398
|
+
if (!isFlux2Model(modelId)) return undefined
|
|
2399
|
+
return modelId === "flux-2-klein" ? "1 MP" : "2 MP"
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* True when two option values denote the same setting written differently.
|
|
2404
|
+
* Handles case/whitespace ("4k" vs "4K") and the megapixel form Flux 2 stores
|
|
2405
|
+
* as a bare count ("1") against the catalog's display form ("1 MP").
|
|
2406
|
+
*/
|
|
2407
|
+
function sameOptionValue(a: string | number, b: string | number): boolean {
|
|
2408
|
+
if (a === b) return true
|
|
2409
|
+
const norm = (v: string | number) =>
|
|
2410
|
+
String(v).trim().toLowerCase().replace(/\s*mp$/, "").replace(/\s+/g, "")
|
|
2411
|
+
const na = norm(a)
|
|
2412
|
+
const nb = norm(b)
|
|
2413
|
+
if (na === nb) return true
|
|
2414
|
+
// Numeric equivalence so "1" matches "1.0" and " 1 MP".
|
|
2415
|
+
const fa = Number(na)
|
|
2416
|
+
const fb = Number(nb)
|
|
2417
|
+
return Number.isFinite(fa) && Number.isFinite(fb) && fa === fb
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/** One correction `normalizeModelInput` made, for disclosure to the user. */
|
|
2421
|
+
export interface ModelInputAdjustment {
|
|
2422
|
+
field: "aspectRatio" | "resolution" | "quality" | "duration"
|
|
2423
|
+
/** The value that was asked for. */
|
|
2424
|
+
from: string | number
|
|
2425
|
+
/** What it became — `undefined` means the lever was dropped entirely. */
|
|
2426
|
+
to: string | number | undefined
|
|
2427
|
+
reason: string
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
export interface NormalizedModelInput {
|
|
2431
|
+
aspectRatio?: string
|
|
2432
|
+
resolution?: string
|
|
2433
|
+
quality?: string
|
|
2434
|
+
duration?: number
|
|
2435
|
+
/** Empty when the input was already valid. */
|
|
2436
|
+
adjustments: ModelInputAdjustment[]
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Coerce a model's parameters into a combination the model actually accepts.
|
|
2441
|
+
*
|
|
2442
|
+
* The correcting twin of `validateModelInput`. Validation is right when a
|
|
2443
|
+
* human/agent is composing a single call and can retry (MCP verbs do this).
|
|
2444
|
+
* It is the WRONG answer at a persistence or execution boundary: rejecting
|
|
2445
|
+
* there turns a fixable typo into a failed run — and a failed run takes every
|
|
2446
|
+
* already-generated, already-billed sibling node down with it. Incident
|
|
2447
|
+
* 2026-08-09: one node carrying `gpt-image` + `16:9` (a pair the config panel
|
|
2448
|
+
* cannot produce, written straight into workflow JSON by a non-UI author)
|
|
2449
|
+
* aborted a run whose five other nodes had already produced images.
|
|
2450
|
+
*
|
|
2451
|
+
* Rules, mirroring the config panels' provider-change snap:
|
|
2452
|
+
* - Model has no such lever → drop the value (sending it 400s upstream).
|
|
2453
|
+
* - Value outside the model's allow-list → snap to the model's default
|
|
2454
|
+
* (`defaultResolutionFor`) or the first valid option.
|
|
2455
|
+
* - Then apply cross-field constraints that only hold for certain models.
|
|
2456
|
+
*
|
|
2457
|
+
* Unknown model ids pass through untouched — the route's Zod model enum is the
|
|
2458
|
+
* right gate for those, exactly as in `validateModelInput`.
|
|
2459
|
+
*
|
|
2460
|
+
* Every correction is reported in `adjustments` so callers can disclose what
|
|
2461
|
+
* changed rather than silently handing back something else.
|
|
2462
|
+
*/
|
|
2463
|
+
export function normalizeModelInput(
|
|
2464
|
+
modelId: string,
|
|
2465
|
+
input: {
|
|
2466
|
+
aspectRatio?: string
|
|
2467
|
+
resolution?: string
|
|
2468
|
+
quality?: string
|
|
2469
|
+
duration?: number
|
|
2470
|
+
},
|
|
2471
|
+
): NormalizedModelInput {
|
|
2472
|
+
const m = MODEL_CATALOG[modelId]
|
|
2473
|
+
const adjustments: ModelInputAdjustment[] = []
|
|
2474
|
+
if (!m) return { ...input, adjustments }
|
|
2475
|
+
|
|
2476
|
+
const out: NormalizedModelInput = { ...input, adjustments }
|
|
2477
|
+
|
|
2478
|
+
const snap = <T extends string | number>(
|
|
2479
|
+
field: ModelInputAdjustment["field"],
|
|
2480
|
+
value: T | undefined,
|
|
2481
|
+
allowed: readonly T[] | undefined,
|
|
2482
|
+
preferred?: T,
|
|
2483
|
+
): T | undefined => {
|
|
2484
|
+
if (value === undefined) return undefined
|
|
2485
|
+
if (!allowed || allowed.length === 0) {
|
|
2486
|
+
adjustments.push({
|
|
2487
|
+
field,
|
|
2488
|
+
from: value,
|
|
2489
|
+
to: undefined,
|
|
2490
|
+
reason: `${m.label} has no ${field} setting — the value was dropped.`,
|
|
2491
|
+
})
|
|
2492
|
+
return undefined
|
|
2493
|
+
}
|
|
2494
|
+
if (allowed.includes(value)) return value
|
|
2495
|
+
// Same value, different spelling — canonicalize instead of "correcting".
|
|
2496
|
+
// Stored data is not uniform with the catalog's display form: Flux 2 bills
|
|
2497
|
+
// off a bare megapixel count ("1") while the catalog lists "1 MP", and "4k"
|
|
2498
|
+
// appears alongside "4K". Treating those as invalid would snap a perfectly
|
|
2499
|
+
// good value to a DIFFERENT tier — i.e. silently re-price the node — which
|
|
2500
|
+
// is worse than the bug this function exists to fix.
|
|
2501
|
+
const canonical = allowed.find((a) => sameOptionValue(a, value))
|
|
2502
|
+
if (canonical !== undefined) return canonical
|
|
2503
|
+
const next = preferred !== undefined && allowed.includes(preferred) ? preferred : allowed[0]
|
|
2504
|
+
adjustments.push({
|
|
2505
|
+
field,
|
|
2506
|
+
from: value,
|
|
2507
|
+
to: next,
|
|
2508
|
+
reason: `${m.label} does not support ${field} "${value}" — using "${next}" instead. Supported: ${allowed.join(", ")}.`,
|
|
2509
|
+
})
|
|
2510
|
+
return next
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
out.aspectRatio = snap("aspectRatio", input.aspectRatio, m.aspectRatios)
|
|
2514
|
+
out.resolution = snap(
|
|
2515
|
+
"resolution",
|
|
2516
|
+
input.resolution,
|
|
2517
|
+
m.resolutions,
|
|
2518
|
+
defaultResolutionFor(modelId),
|
|
2519
|
+
)
|
|
2520
|
+
out.quality = snap("quality", input.quality, m.qualities)
|
|
2521
|
+
out.duration = snap("duration", input.duration, m.durations)
|
|
2522
|
+
|
|
2523
|
+
// Cross-field constraints — a pair that is individually valid but jointly
|
|
2524
|
+
// rejected upstream. GPT Image 2 (per docs.kie.ai): `auto` requires 1K, and
|
|
2525
|
+
// 1:1 cannot go to 4K. Applied last so it sees the already-snapped values.
|
|
2526
|
+
if (modelId === "gpt-image-2" || modelId === "gpt-image-2-i2i") {
|
|
2527
|
+
if (out.aspectRatio === "auto" && out.resolution !== undefined && out.resolution !== "1K") {
|
|
2528
|
+
adjustments.push({
|
|
2529
|
+
field: "resolution",
|
|
2530
|
+
from: out.resolution,
|
|
2531
|
+
to: "1K",
|
|
2532
|
+
reason: `${m.label} only renders 1K at the "auto" aspect ratio.`,
|
|
2533
|
+
})
|
|
2534
|
+
out.resolution = "1K"
|
|
2535
|
+
} else if (out.aspectRatio === "1:1" && out.resolution === "4K") {
|
|
2536
|
+
adjustments.push({
|
|
2537
|
+
field: "resolution",
|
|
2538
|
+
from: "4K",
|
|
2539
|
+
to: "2K",
|
|
2540
|
+
reason: `${m.label} cannot render 4K at a 1:1 aspect ratio.`,
|
|
2541
|
+
})
|
|
2542
|
+
out.resolution = "2K"
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
return out
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2388
2549
|
// =============================================================================
|
|
2389
2550
|
// Frontend picker helpers — return `{value, label}[]` shapes that the
|
|
2390
2551
|
// existing config-panel components expect, derived from the catalog so we
|