@nodaro/shared 2.2.1 → 2.3.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.3.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,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
+ })
package/src/index.ts CHANGED
@@ -693,6 +693,8 @@ export {
693
693
  creditRangesAll,
694
694
  modelIdsByKindMode,
695
695
  buildModelMenu,
696
+ normalizeModelInput,
697
+ defaultResolutionFor,
696
698
  } from "./model-catalog.js"
697
699
  export type {
698
700
  ModelCatalogEntry,
@@ -704,6 +706,8 @@ export type {
704
706
  ValidationField,
705
707
  LabeledOption,
706
708
  ModelMenuOption,
709
+ ModelInputAdjustment,
710
+ NormalizedModelInput,
707
711
  } from "./model-catalog.js"
708
712
 
709
713
  export {
@@ -876,6 +880,13 @@ export type { VoiceChangerModel } from "./voice-changer-models.js"
876
880
 
877
881
  // --- Node presets ---
878
882
  export { EXECUTION_DATA_KEYS, TRANSIENT_RUNTIME_KEYS, stripTransientRuntimeData } from "./node-runtime-keys.js"
883
+
884
+ export {
885
+ MODEL_PARAM_NODE_TYPES,
886
+ normalizeNodeModelParams,
887
+ describeNodeAdjustments,
888
+ } from "./normalize-node-params.js"
889
+ export type { NodeParamAdjustment, NormalizedNodes } from "./normalize-node-params.js"
879
890
  export { extractPresetData, PRESET_EXCLUDED_KEYS, PRESET_APPLY_CLEAR_KEYS, presetDataMatches } from "./node-preset-extract.js"
880
891
 
881
892
  // --- 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
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Workflow-node parameter normalization.
3
+ *
4
+ * The config panels already prevent an impossible provider/parameter pair from
5
+ * being *selected*: their dropdowns are provider-aware and a fail-safe effect
6
+ * snaps stale values when the provider changes. But both of those are React
7
+ * effects — they only run when that specific node's panel or hover strip is
8
+ * mounted. A node written straight into workflow JSON by a non-UI author (an
9
+ * agent, an import, a template) is never seen by either, so its invalid pair
10
+ * survives all the way to the provider call.
11
+ *
12
+ * That is the 2026-08-09 incident: one `generate-image` node carrying
13
+ * `gpt-image` + `16:9` (GPT Image 1.5 renders 1:1 / 3:2 / 2:3 only) reached KIE,
14
+ * got rejected, and aborted a run whose five sibling nodes had already produced
15
+ * — and billed for — their images.
16
+ *
17
+ * This module is the write-boundary guard: it heals the node instead of
18
+ * rejecting it, so the user sees a valid value on the canvas rather than a
19
+ * failed run. It reads exclusively from `MODEL_CATALOG`, so a model added later
20
+ * is covered without touching this file.
21
+ */
22
+
23
+ import { normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
24
+
25
+ /**
26
+ * Node types whose `data` carries catalog-governed model parameters under the
27
+ * shape this module understands (`provider` + aspectRatio/resolution/quality).
28
+ *
29
+ * IMAGE ONLY, deliberately. The video nodes route several of these fields
30
+ * through mode-dependent defaults (`"adaptive"` for Seedance/Hailuo, duration
31
+ * composites tied to pricing) that this flat normalizer would flatten wrongly;
32
+ * they get their own pass once those defaults are catalog-derived too.
33
+ */
34
+ export const MODEL_PARAM_NODE_TYPES: ReadonlySet<string> = new Set([
35
+ "generate-image",
36
+ "image-to-image",
37
+ ])
38
+
39
+ export interface NodeParamAdjustment extends ModelInputAdjustment {
40
+ nodeId: string
41
+ provider: string
42
+ }
43
+
44
+ export interface NormalizedNodes<T> {
45
+ nodes: T[]
46
+ /** Empty when nothing needed correcting. */
47
+ adjustments: NodeParamAdjustment[]
48
+ }
49
+
50
+ interface NodeLike {
51
+ id?: unknown
52
+ type?: unknown
53
+ data?: unknown
54
+ }
55
+
56
+ /**
57
+ * Return `nodes` with every catalog-governed image parameter coerced into a
58
+ * combination its provider actually accepts, plus the list of what changed.
59
+ *
60
+ * Immutable: nodes that need no correction are returned by reference, and a
61
+ * corrected node is a fresh object (never a mutation of the caller's input).
62
+ *
63
+ * Multi-provider nodes (`data.providers` holding 2+ entries) are SKIPPED: the
64
+ * valid set there is the intersection across every selected provider, and when
65
+ * a stored value falls outside it there is no single defensible replacement —
66
+ * picking one provider's default would silently misconfigure the others. Those
67
+ * nodes are still guarded interactively by the panel's intersection dropdown.
68
+ */
69
+ export function normalizeNodeModelParams<T extends NodeLike>(
70
+ nodes: readonly T[],
71
+ ): NormalizedNodes<T> {
72
+ const adjustments: NodeParamAdjustment[] = []
73
+ const out = nodes.map((node) => {
74
+ const type = typeof node.type === "string" ? node.type : ""
75
+ if (!MODEL_PARAM_NODE_TYPES.has(type)) return node
76
+
77
+ const data = node.data
78
+ if (!data || typeof data !== "object" || Array.isArray(data)) return node
79
+ const d = data as Record<string, unknown>
80
+
81
+ const multi = Array.isArray(d.providers) ? (d.providers as unknown[]) : []
82
+ if (multi.length > 1) return node
83
+
84
+ const provider =
85
+ typeof d.provider === "string"
86
+ ? d.provider
87
+ : typeof multi[0] === "string"
88
+ ? (multi[0] as string)
89
+ : undefined
90
+ if (!provider) return node
91
+
92
+ const normalized = normalizeModelInput(provider, {
93
+ aspectRatio: typeof d.aspectRatio === "string" ? d.aspectRatio : undefined,
94
+ resolution: typeof d.resolution === "string" ? d.resolution : undefined,
95
+ quality: typeof d.quality === "string" ? d.quality : undefined,
96
+ })
97
+ if (normalized.adjustments.length === 0) return node
98
+
99
+ const nodeId = typeof node.id === "string" ? node.id : "(unknown node)"
100
+ for (const adj of normalized.adjustments) {
101
+ adjustments.push({ ...adj, nodeId, provider })
102
+ }
103
+
104
+ // Only the three governed keys are rewritten; everything else on the node
105
+ // is passed through untouched. A dropped lever is written as `undefined`
106
+ // rather than deleted so the shape stays stable for downstream readers.
107
+ return {
108
+ ...node,
109
+ data: {
110
+ ...d,
111
+ aspectRatio: normalized.aspectRatio,
112
+ resolution: normalized.resolution,
113
+ quality: normalized.quality,
114
+ },
115
+ }
116
+ })
117
+
118
+ return { nodes: out, adjustments }
119
+ }
120
+
121
+ /** One-line-per-change summary, for surfacing back to an agent or a log. */
122
+ export function describeNodeAdjustments(adjustments: readonly NodeParamAdjustment[]): string[] {
123
+ return adjustments.map(
124
+ (a) => `${a.nodeId} (${a.provider}): ${a.field} "${a.from}" → ${a.to === undefined ? "removed" : `"${a.to}"`} — ${a.reason}`,
125
+ )
126
+ }