@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.2.1",
3
+ "version": "2.5.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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) ---
@@ -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