@nodaro/shared 2.7.0 → 2.10.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.7.0",
3
+ "version": "2.10.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",
@@ -6,12 +6,18 @@ import {
6
6
  LLM_REASONING_EFFORTS,
7
7
  getLlmModel,
8
8
  getLlmTier,
9
+ getLlmModalityCaps,
10
+ VIDEO_ANALYSIS_LLM_MODELS,
9
11
  buildLlmCreditIdentifier,
10
12
  resolveLlmCreditId,
11
13
  motionGraphicsFeature,
12
14
  effectiveReasoningEffort,
13
15
  supportsAdvancedMode,
14
16
  availableReasoningEfforts,
17
+ LLM_VENDOR_ORDER,
18
+ LLM_VENDOR_LABELS,
19
+ groupLlmModelsByVendor,
20
+ orderedLlmModels,
15
21
  } from "../llm-models.js"
16
22
  import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
17
23
  import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
@@ -28,6 +34,7 @@ import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
28
34
  const EXPECTED_MODEL_IDS = [
29
35
  "gemini-3-flash",
30
36
  "gemini-3.6-flash",
37
+ "gemini-3.7-flash",
31
38
  "claude-haiku-4.5",
32
39
  "claude-sonnet-4.6",
33
40
  "gpt-5.2",
@@ -38,6 +45,7 @@ const EXPECTED_MODEL_IDS = [
38
45
  "gpt-5.6-luna",
39
46
  "gpt-5.6-terra",
40
47
  "gpt-5.6-sol",
48
+ "grok-4.6",
41
49
  "claude-sonnet-5",
42
50
  "claude-opus-4.8",
43
51
  "claude-opus-5",
@@ -86,13 +94,13 @@ describe("LLM_MODELS data integrity", () => {
86
94
  expect(new Set(ids).size).toBe(ids.length)
87
95
  })
88
96
 
89
- it("has 4 economy, 4 standard, 8 premium models", () => {
97
+ it("has 5 economy, 5 standard, 8 premium models", () => {
90
98
  const tierCounts: Record<LlmTier, number> = { economy: 0, standard: 0, premium: 0 }
91
99
  for (const model of LLM_MODELS) {
92
100
  tierCounts[model.tier]++
93
101
  }
94
- expect(tierCounts.economy).toBe(4)
95
- expect(tierCounts.standard).toBe(4)
102
+ expect(tierCounts.economy).toBe(5)
103
+ expect(tierCounts.standard).toBe(5)
96
104
  expect(tierCounts.premium).toBe(8)
97
105
  })
98
106
 
@@ -103,11 +111,12 @@ describe("LLM_MODELS data integrity", () => {
103
111
  expect(formats).toContain("responses")
104
112
  })
105
113
 
106
- it("all three vendors are represented", () => {
114
+ it("all four vendors are represented", () => {
107
115
  const vendors = new Set(LLM_MODELS.map((m) => m.vendor))
108
116
  expect(vendors).toContain("anthropic")
109
117
  expect(vendors).toContain("google")
110
118
  expect(vendors).toContain("openai")
119
+ expect(vendors).toContain("xai")
111
120
  })
112
121
 
113
122
  it("all models support images", () => {
@@ -383,10 +392,11 @@ describe("LLM_FEATURE_DEFAULTS", () => {
383
392
  "translate",
384
393
  "image-critic",
385
394
  "pick-best-llm",
395
+ "workflow-copilot",
386
396
  ]
387
397
 
388
- it("has entries for all 16 features", () => {
389
- expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(16)
398
+ it("has entries for all 17 features", () => {
399
+ expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(17)
390
400
  for (const feature of ALL_FEATURES) {
391
401
  expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
392
402
  }
@@ -472,6 +482,7 @@ describe("STRUCTURED_VISION_MODELS", () => {
472
482
  "claude-sonnet-4.6",
473
483
  "gemini-3-flash",
474
484
  "gemini-3.6-flash",
485
+ "gemini-3.7-flash",
475
486
  "gemini-3.1-pro",
476
487
  "claude-sonnet-5",
477
488
  "claude-opus-4.8",
@@ -484,15 +495,18 @@ describe("STRUCTURED_VISION_MODELS", () => {
484
495
  "gpt-5.6-luna",
485
496
  "gpt-5.6-terra",
486
497
  "gpt-5.6-sol",
498
+ // responses-format Grok — vision + text.format live-verified 2026-08-18.
499
+ "grok-4.6",
487
500
  ].sort(),
488
501
  )
489
502
  })
490
503
 
491
- it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI (responses text.format) vendors", () => {
504
+ it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI/xAI (responses text.format) vendors", () => {
492
505
  const vendors = new Set(STRUCTURED_VISION_MODELS.map((m) => m.vendor))
493
506
  expect(vendors).toContain("anthropic")
494
507
  expect(vendors).toContain("google")
495
508
  expect(vendors).toContain("openai")
509
+ expect(vendors).toContain("xai")
496
510
  })
497
511
 
498
512
  it("excludes chat-completions GPT models — no native structured mode there (parse+retry only)", () => {
@@ -509,9 +523,9 @@ describe("STRUCTURED_VISION_MODELS", () => {
509
523
  })
510
524
 
511
525
  // ---------------------------------------------------------------------------
512
- // Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8)
513
- // grok-4.5 is DEFERRED its chat endpoint is not live on the provider yet
514
- // (2026-07-13); its tests are intentionally omitted here.
526
+ // Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8 /
527
+ // Grok 4.6 — grok-4.5 was deferred on 2026-07-13 because its chat endpoint
528
+ // wasn't live; grok-4.6 is its activation, live-verified 2026-08-18.)
515
529
  // ---------------------------------------------------------------------------
516
530
  describe("reasoning effort registry", () => {
517
531
  it("every reasoningEfforts list is a subset of the superset, in ascending order", () => {
@@ -526,6 +540,7 @@ describe("reasoning effort registry", () => {
526
540
  expect(getLlmTier("gpt-5.6-luna")).toBe("economy")
527
541
  expect(getLlmTier("gpt-5.6-terra")).toBe("standard")
528
542
  expect(getLlmTier("gpt-5.6-sol")).toBe("premium")
543
+ expect(getLlmTier("grok-4.6")).toBe("standard")
529
544
  expect(getLlmTier("claude-sonnet-5")).toBe("standard")
530
545
  expect(getLlmTier("claude-opus-4.8")).toBe("premium")
531
546
  expect(getLlmTier("gpt-5.5")).toBe("premium")
@@ -557,6 +572,62 @@ describe("reasoning effort registry", () => {
557
572
  expect(getLlmModel("claude-fable-5")?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"])
558
573
  expect(getLlmModel("claude-fable-5")?.directFallbackModel).toBe("claude-fable-5")
559
574
  })
575
+
576
+ it("grok-4.6: responses format on the grok family path, KIE's documented effort enum, no sampling params, reasons by default", () => {
577
+ const m = getLlmModel("grok-4.6")
578
+ expect(m?.vendor).toBe("xai")
579
+ expect(m?.kieFormat).toBe("responses")
580
+ expect(m?.kieSlugOrModel).toBe("grok-4-6")
581
+ expect(m?.structuredOutputMode).toBe("responses-json-schema")
582
+ // KIE's enum is low..xhigh with NO `none` — the endpoint reasons
583
+ // unconditionally (thinkingDefaultOn), so `none` would be a lie.
584
+ expect(m?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"])
585
+ // Live-probed 2026-08-18: temperature is silently ignored — never send it.
586
+ expect(m?.supportsTemperature).toBe(false)
587
+ // Load-bearing: consumers floor max_tokens off this flag (a trivial probe
588
+ // spent 169/170 output tokens on reasoning with no reasoning param sent).
589
+ expect(m?.thinkingDefaultOn).toBe(true)
590
+ // KIE-only: no direct lane on either vendor SDK.
591
+ expect(m?.directFallbackModel).toBeUndefined()
592
+ expect(m?.directGeminiModel).toBeUndefined()
593
+ })
594
+
595
+ it("grok-4.6 resolves from its dash-form wire slug", () => {
596
+ expect(getLlmModel("grok-4-6")?.id).toBe("grok-4.6")
597
+ })
598
+ })
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // gemini-3.7-flash exposure (both lanes live-verified 2026-08-18: KIE
602
+ // chat-completions with reasoning_effort + enforced response_format, and the
603
+ // direct Google id resolving on generativelanguage)
604
+ // ---------------------------------------------------------------------------
605
+ describe("gemini-3.7-flash exposure", () => {
606
+ it("KIE-first chat-completions with a direct lane — 3.6-flash's proven shape", () => {
607
+ const m = getLlmModel("gemini-3.7-flash")
608
+ expect(m?.tier).toBe("economy")
609
+ expect(m?.vendor).toBe("google")
610
+ expect(m?.kieFormat).toBe("chat-completions")
611
+ expect(m?.kieSlugOrModel).toBe("gemini-3-7-flash-openai")
612
+ // KIE-first on purpose (no preferDirect): the cheap lane serves Generate
613
+ // Text; the direct lane is Advanced mode + the reliability fallback.
614
+ expect(m?.preferDirect).toBeUndefined()
615
+ expect(m?.directGeminiModel).toBe("gemini-3.7-flash")
616
+ expect(m?.reasoningEfforts).toEqual(["low", "high"])
617
+ expect(m?.structuredOutputMode).toBe("kie-response-format")
618
+ expect(supportsAdvancedMode("gemini-3.7-flash")).toBe(true)
619
+ expect(availableReasoningEfforts("gemini-3.7-flash", true)).toEqual(["none", "low", "medium", "high"])
620
+ })
621
+
622
+ it("stays OUT of video-analysis — image-only modality caps by decision, not omission", () => {
623
+ // Full video+audio caps would auto-enroll it in VIDEO_ANALYSIS_LLM_MODELS
624
+ // and force a VA tier + pricing decision that is deliberately deferred
625
+ // while the smart-family A/B routes this model internally (2026-08-18).
626
+ // If this test goes red, someone flipped the caps — that flip is only
627
+ // valid TOGETHER with the VA-side tier/pricing decision.
628
+ expect(getLlmModalityCaps("gemini-3.7-flash")).toEqual({ image: true, video: false, audio: false })
629
+ expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain("gemini-3.7-flash")
630
+ })
560
631
  })
561
632
 
562
633
  describe("effectiveReasoningEffort", () => {
@@ -584,6 +655,7 @@ describe("buildLlmCreditIdentifier effort bump (xhigh/max only)", () => {
584
655
  })
585
656
  it("standard + xhigh → premium", () => {
586
657
  expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-terra", "xhigh")).toBe("llm-chat:premium")
658
+ expect(buildLlmCreditIdentifier("llm-chat", "grok-4.6", "xhigh")).toBe("llm-chat:premium")
587
659
  })
588
660
  it("premium + max stays premium", () => {
589
661
  expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-sol", "max")).toBe("llm-chat:premium")
@@ -767,3 +839,58 @@ describe("lane-aware reasoning efforts", () => {
767
839
  expect(bad).toEqual([])
768
840
  })
769
841
  })
842
+
843
+ // ---------------------------------------------------------------------------
844
+ // Vendor grouping — the ONE ordering every LLM model menu renders
845
+ // ---------------------------------------------------------------------------
846
+ describe("groupLlmModelsByVendor / orderedLlmModels", () => {
847
+ it("every vendor in the union has an order slot AND a label (totality)", () => {
848
+ // A new vendor added to LlmVendor without a menu decision would silently
849
+ // sort its models to the end of every picker unlabeled — fail here instead.
850
+ const vendorsInUse = new Set(LLM_MODELS.map((m) => m.vendor))
851
+ for (const v of vendorsInUse) {
852
+ expect(LLM_VENDOR_ORDER, `vendor "${v}" missing from LLM_VENDOR_ORDER`).toContain(v)
853
+ expect(LLM_VENDOR_LABELS[v], `vendor "${v}" missing from LLM_VENDOR_LABELS`).toBeTruthy()
854
+ }
855
+ for (const v of LLM_VENDOR_ORDER) {
856
+ expect(LLM_VENDOR_LABELS[v]).toBeTruthy()
857
+ }
858
+ })
859
+
860
+ it("covers every model exactly once, in vendor order", () => {
861
+ const groups = groupLlmModelsByVendor()
862
+ const flattened = groups.flatMap((g) => g.models.map((m) => m.id))
863
+ expect(flattened.sort()).toEqual(LLM_MODELS.map((m) => m.id).sort())
864
+ expect(new Set(flattened).size).toBe(flattened.length)
865
+ const groupVendors = groups.map((g) => g.vendor)
866
+ expect(groupVendors).toEqual(LLM_VENDOR_ORDER.filter((v) => groupVendors.includes(v)))
867
+ })
868
+
869
+ it("inside each group models are tier-ordered economy → standard → premium", () => {
870
+ const rank = { economy: 0, standard: 1, premium: 2 } as const
871
+ for (const g of groupLlmModelsByVendor()) {
872
+ const ranks = g.models.map((m) => rank[m.tier])
873
+ expect(ranks, g.vendor).toEqual([...ranks].sort((a, b) => a - b))
874
+ }
875
+ })
876
+
877
+ it("groups carry their display label and omit empty groups after a filter", () => {
878
+ const groups = groupLlmModelsByVendor(LLM_MODELS.filter((m) => m.vendor === "xai"))
879
+ expect(groups).toHaveLength(1)
880
+ expect(groups[0].label).toBe("xAI")
881
+ expect(groups[0].models.map((m) => m.id)).toEqual(["grok-4.6"])
882
+ })
883
+
884
+ it("does not mutate the registry (registry order is load-bearing for LLM_MODEL_IDS)", () => {
885
+ const before = LLM_MODELS.map((m) => m.id)
886
+ groupLlmModelsByVendor()
887
+ orderedLlmModels()
888
+ expect(LLM_MODELS.map((m) => m.id)).toEqual(before)
889
+ })
890
+
891
+ it("orderedLlmModels is the flattened grouping", () => {
892
+ expect(orderedLlmModels().map((m) => m.id)).toEqual(
893
+ groupLlmModelsByVendor().flatMap((g) => g.models.map((m) => m.id)),
894
+ )
895
+ })
896
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ ACCESS_LEVELS,
4
+ COLLABORATOR_ROLES,
5
+ MEMBER_STATUSES,
6
+ ORG_ERROR_CODES,
7
+ ORG_KINDS,
8
+ ORG_ROLES,
9
+ OrgSettingsSchema,
10
+ PRESET_SETTING_KEYS,
11
+ PresetSettingsSchema,
12
+ WORKSPACE_ROLES,
13
+ WorkspaceSettingsSchema,
14
+ } from "../organizations/index.js"
15
+
16
+ describe("organizations wire contract — enums", () => {
17
+ 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]) {
19
+ expect(list.length).toBeGreaterThan(0)
20
+ expect(new Set(list).size).toBe(list.length)
21
+ }
22
+ })
23
+
24
+ it("error codes are snake_case identifiers", () => {
25
+ for (const code of ORG_ERROR_CODES) expect(code).toMatch(/^[a-z][a-z_]*[a-z]$/)
26
+ })
27
+ })
28
+
29
+ describe("organizations wire contract — settings request schemas", () => {
30
+ it("the preset shape has exactly the nine resolvable keys", () => {
31
+ expect([...PRESET_SETTING_KEYS].sort()).toEqual(
32
+ [
33
+ "admin_access",
34
+ "collaborators_can_invite",
35
+ "default_workflow_visibility",
36
+ "member_access_to_shared",
37
+ "member_caps_enabled",
38
+ "members_can_create_projects",
39
+ "personal_space_enabled",
40
+ "policy_survives_suspension",
41
+ "workspace_admins_can_invite",
42
+ ].sort(),
43
+ )
44
+ expect(PresetSettingsSchema.safeParse({}).success).toBe(false)
45
+ })
46
+
47
+ it("workspace settings are a partial of the preset; unknown keys are stripped; bad values rejected", () => {
48
+ expect(WorkspaceSettingsSchema.parse({ admin_access: "edit", rogue: 1 })).toEqual({ admin_access: "edit" })
49
+ expect(WorkspaceSettingsSchema.safeParse({}).success).toBe(true)
50
+ expect(WorkspaceSettingsSchema.safeParse({ admin_access: "owner" }).success).toBe(false)
51
+ expect(WorkspaceSettingsSchema.safeParse({ member_caps_enabled: "yes" }).success).toBe(false)
52
+ })
53
+
54
+ it("org settings accept lower-case domains only and string vocabulary overrides", () => {
55
+ expect(OrgSettingsSchema.safeParse({ allowed_email_domains: ["school.edu", "sub.school.ac.il"] }).success).toBe(true)
56
+ expect(OrgSettingsSchema.safeParse({ allowed_email_domains: ["School.edu"] }).success).toBe(false)
57
+ expect(OrgSettingsSchema.safeParse({ allowed_email_domains: ["not a domain"] }).success).toBe(false)
58
+ expect(OrgSettingsSchema.safeParse({ vocabulary_overrides: { workspace: "Cohort" } }).success).toBe(true)
59
+ expect(OrgSettingsSchema.safeParse({ vocabulary_overrides: { workspace: 3 } }).success).toBe(false)
60
+ })
61
+ })
@@ -23,6 +23,17 @@ describe("producer-types", () => {
23
23
  DYNAMIC_PRODUCER_TYPES,
24
24
  } as const
25
25
 
26
+ // still-to-video (still image + audio → MP4 via local FFmpeg) has a video
27
+ // OUTPUT handle — absent from this set, every downstream video input would
28
+ // hard-reject its edges (the "cannot connect the outputs" bug class).
29
+ it("registers still-to-video as a video producer", () => {
30
+ expect(VIDEO_PRODUCER_TYPES.has("still-to-video")).toBe(true)
31
+ })
32
+
33
+ it("registers slideshow as a video producer (still-to-video's N-image companion)", () => {
34
+ expect(VIDEO_PRODUCER_TYPES.has("slideshow")).toBe(true)
35
+ })
36
+
26
37
  it("registers voice-changer-pro as an audio producer (its default output)", () => {
27
38
  expect(AUDIO_PRODUCER_TYPES.has("voice-changer-pro")).toBe(true)
28
39
  })
@@ -102,9 +102,13 @@ describe("per-model prompt length limits", () => {
102
102
  })
103
103
 
104
104
  describe("Suno per-version caps", () => {
105
- it("prompt: 500 non-custom; 3000 V4 custom; 5000 V4.5+/V5 custom", () => {
106
- expect(getMaxSunoPromptChars("V4", false)).toBe(500)
107
- expect(getMaxSunoPromptChars("V5", false)).toBe(500)
105
+ // Non-custom was 500 until 2026-08-19 six times under the provider's
106
+ // documented 3000, and the route truncates rather than rejects, so a
107
+ // 950-char recast score brief reached Suno cut to exactly 500 mid-word.
108
+ it("prompt: 3000 non-custom (all models); 3000 V4 custom; 5000 V4.5+/V5 custom", () => {
109
+ expect(getMaxSunoPromptChars("V4", false)).toBe(3000)
110
+ expect(getMaxSunoPromptChars("V5", false)).toBe(3000)
111
+ expect(getMaxSunoPromptChars("V5_5", false)).toBe(3000)
108
112
  expect(getMaxSunoPromptChars("V4", true)).toBe(3000)
109
113
  expect(getMaxSunoPromptChars("V4_5", true)).toBe(5000)
110
114
  expect(getMaxSunoPromptChars("V5", true)).toBe(5000)
@@ -11,9 +11,8 @@ import {
11
11
  * job dbf95612 — the original 1.0s tails made every generate-video-pro
12
12
  * continuation segment fail deterministically). Everything this platform
13
13
  * sends as a Seedance-2 video reference cuts SEEDANCE_2_CONTINUATION_REF_SEC
14
- * seconds; do NOT "optimize" it back below the floor. The private-plugin
15
- * twin constants (nodaro-cloud-plugins chain.ts TAIL_SEC / bridge-math.ts
16
- * MIN_REF) are guarded by that repo's r2v-ref-floor.test.ts — keep in sync. */
14
+ * seconds; do NOT "optimize" it back below the floor. The plugin repo
15
+ * carries twin constants guarded by its own tests — keep the two in sync. */
17
16
  describe("seedance-2 continuation-reference floor (do not regress)", () => {
18
17
  it("the platform's continuation-ref length clears KIE's r2v minimum", () => {
19
18
  expect(SEEDANCE_2_CONTINUATION_REF_SEC).toBeGreaterThanOrEqual(SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC)
@@ -157,7 +157,7 @@ describe("bare video-analysis node-type credit id", () => {
157
157
  // 24 and the schedule regenerated ~47% lower at the ceiling bucket, and 300
158
158
  // wrote 2064: the V1 hybrid-smart reprice (task A3) moved `smart` to a
159
159
  // multi-roll plan (native skeleton + donor rolls, always refined) and
160
- // trued up every tier's judge/refine terms from staging measurement).
160
+ // trued up every tier's judge/refine terms).
161
161
  expect(ceiling).toBe(2064)
162
162
  })
163
163
 
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  // The measured-rate constants and the $-derived `videoAnalysisBucketCredits`
17
17
  // formula are PRIVATE, in @nodaroai/cloud-plugins
18
- // (src/plugins/video-analysis/cost.ts) — its tests (the worked-example
18
+ // (in the plugin repo) — its tests (the worked-example
19
19
  // bucket-credit values and the cross-check against VIDEO_ANALYSIS_BUCKET_CREDITS
20
20
  // below) live in that private package's __tests__/cost.test.ts. This file
21
21
  // covers only the NON-monetary duration-bucketing, window-batching, and
@@ -121,7 +121,7 @@ describe("video-analysis-pricing", () => {
121
121
  })
122
122
 
123
123
  // Full drift-detection against the live $-formula lives in the PRIVATE
124
- // plugin repo (src/plugins/video-analysis/__tests__/cost.test.ts) — the
124
+ // plugin repo — the
125
125
  // formula moved there in 2026-07 and the app-side test was deleted with it,
126
126
  // so nothing in THIS repo can recompute these numbers. This is a lightweight
127
127
  // shape check that the precomputed table covers every legal id.
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ VIDEO_MODE_ALIASES,
4
+ VIDEO_REF_LIMITS_BY_PROVIDER,
5
+ resolveVideoModeForInputs,
6
+ resolveVideoProviderForMode,
7
+ } from "../model-constants.js"
8
+
9
+ /**
10
+ * #861 — reference images wired to Grok Imagine 1 without a start frame were
11
+ * silently dropped: the run resolved to text-to-video, the t2v twin (`grok`)
12
+ * has no image parameter, and nothing said so. The mode must follow what the
13
+ * model can CARRY, derived from the catalog's reference caps — not a name.
14
+ */
15
+ const I2V = "image-to-video"
16
+ const T2V = "text-to-video"
17
+
18
+ describe("resolveVideoModeForInputs", () => {
19
+ it("a start frame is image-to-video for every provider", () => {
20
+ for (const id of ["grok-i2v", "grok", "seedance-2", "kling", "veo3", "wan-i2v", undefined]) {
21
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: true, hasImageRefs: false })).toBe(I2V)
22
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: true, hasImageRefs: true })).toBe(I2V)
23
+ }
24
+ })
25
+
26
+ it("nothing wired is text-to-video", () => {
27
+ expect(resolveVideoModeForInputs("grok-i2v", { hasStartFrame: false, hasImageRefs: false })).toBe(T2V)
28
+ expect(resolveVideoModeForInputs(undefined, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
29
+ })
30
+
31
+ it("refs alone route Grok Imagine 1 to its i2v twin — the only one that can carry them (#861)", () => {
32
+ // Whichever member id the node stored (base/i2v/t2v all appear in old workflows).
33
+ for (const id of ["grok-i2v", "grok"]) {
34
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(I2V)
35
+ }
36
+ // The premise, pinned: i2v carries refs, t2v does not.
37
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["grok-i2v"]?.images).toBeGreaterThan(0)
38
+ expect(VIDEO_REF_LIMITS_BY_PROVIDER["grok"]).toBeUndefined()
39
+ })
40
+
41
+ it("single-id models are untouched: the same id serves both modes, so there is nothing to re-route", () => {
42
+ // seedance-2 / gemini-omni-video / veo forward refs on their t2v path
43
+ // (generate-video.md mode table). kling-3-omni is i2v-only and keeps its
44
+ // documented orchestrator behavior (image_required on the t2v path) —
45
+ // whether refs alone should satisfy it is a separate question from #861.
46
+ for (const id of ["seedance-2", "gemini-omni-video", "veo3.1", "kling-3-omni"]) {
47
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
48
+ }
49
+ })
50
+
51
+ it("a split-id model with no ref support on either twin is unaffected (wan, happyhorse)", () => {
52
+ for (const id of ["wan-i2v", "wan-2.7-i2v", "happyhorse-i2v"]) {
53
+ expect(resolveVideoModeForInputs(id, { hasStartFrame: false, hasImageRefs: true })).toBe(T2V)
54
+ }
55
+ })
56
+
57
+ it("every alias group is classified — a new split-id model lands in one of the two pinned outcomes", () => {
58
+ // Explicit per-group expectations (not a re-derivation of the formula):
59
+ // grok is the one group whose twins differ on ref support today.
60
+ const expectedByBase: Record<string, "image-to-video" | "text-to-video"> = {
61
+ "grok-i2v": I2V,
62
+ "wan-i2v": T2V,
63
+ "wan-2.7-i2v": T2V,
64
+ "happyhorse-i2v": T2V,
65
+ }
66
+ expect(VIDEO_MODE_ALIASES.map((g) => g.base).sort()).toEqual(Object.keys(expectedByBase).sort())
67
+ for (const g of VIDEO_MODE_ALIASES) {
68
+ const expected = expectedByBase[g.base]!
69
+ expect(resolveVideoModeForInputs(g.base, { hasStartFrame: false, hasImageRefs: true }), g.base).toBe(expected)
70
+ // The mode it picks resolves to the twin that can actually run it.
71
+ if (expected === I2V) expect(resolveVideoProviderForMode(g.base, expected)).toBe(g.i2v)
72
+ }
73
+ })
74
+ })
@@ -480,3 +480,41 @@ export function resolveXfadeName(id: string): string | null {
480
480
  }
481
481
  return entry.xfade
482
482
  }
483
+
484
+ /**
485
+ * Map a `transition` PARAMETER-NODE pick (the @nodaro/prompts creative
486
+ * catalog — prose-hint transitions for AI video prompts) onto the ffmpeg
487
+ * xfade vocabulary above. The two vocabularies are deliberately different
488
+ * (the picker describes shots, this table describes blends); only the
489
+ * visually-faithful subset maps — everything else falls back to `cut`.
490
+ *
491
+ * Consumed by the slideshow node (its transition TYPE comes from a wired
492
+ * transition parameter node; unwired defaults to cut). Kept here — not in
493
+ * @nodaro/prompts — because the dependency points prompts → shared, and the
494
+ * ids are plain strings on both sides.
495
+ */
496
+ export const PICKER_TO_COMBINE_TRANSITION: Readonly<Record<string, string>> = {
497
+ "none": "cut",
498
+ "auto": "dissolve", // an explicitly wired "auto" wants A blend — the classic slideshow default
499
+ "cross-dissolve": "dissolve",
500
+ "fade-to-black": "dip-to-black",
501
+ "fade-to-white": "dip-to-white",
502
+ "iris": "circle-open",
503
+ "wipe": "wipe-left",
504
+ "pixelate-reform": "pixelize",
505
+ "dissolve-to-mist": "hblur",
506
+ "match-cut": "cut",
507
+ "smash-cut": "cut",
508
+ "freeze-frame-jump": "cut",
509
+ }
510
+
511
+ /**
512
+ * Resolve ANY transition string a slideshow may receive — a combine id
513
+ * (already valid), a picker id (mapped), or anything else (→ cut).
514
+ */
515
+ export function resolveSlideshowTransition(value: string | undefined | null): string {
516
+ if (!value) return "cut"
517
+ if (COMBINE_TRANSITION_IDS.includes(value)) return value
518
+ return PICKER_TO_COMBINE_TRANSITION[value] ?? "cut"
519
+ }
520
+
@@ -155,7 +155,7 @@ export function estimateLoopTrimAddonCredits(
155
155
  Math.ceil(frames / VIDEO_UTIL_PRICING.FRAMES_PER_CREDIT)
156
156
  }
157
157
 
158
- /** BASE credits (at-cost) for assemble-narrated-video: 3 flat + 1 per 6
158
+ /** BASE credits for assemble-narrated-video: 3 flat + 1 per 6
159
159
  * blocks. 6→4, 24→7, 60→13. Single source of truth shared by the backend
160
160
  * route/creditGuard (`backend/src/providers/video/narrated-block-fit.ts`
161
161
  * re-exports this) and the frontend pre-run estimate
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Featured entity starter catalog (north-star §6 ③ — the "Featured" tab of each
2
+ * Featured entity starter catalog (the "Featured" tab of each
3
3
  * entity Library).
4
4
  *
5
5
  * These are app-provided starter presets: a curated visual description per
package/src/i18n/types.ts CHANGED
@@ -52,8 +52,6 @@ export interface LanguageDefinition {
52
52
  readonly shortCode: string
53
53
  /** Reading direction. */
54
54
  readonly dir: LocaleDirection
55
- /** Optional flag emoji for visual recognition. */
56
- readonly flag: string
57
55
  }
58
56
 
59
57
  /**
@@ -62,18 +60,18 @@ export interface LanguageDefinition {
62
60
  * languages, then RTL languages last.
63
61
  */
64
62
  export const LANGUAGES: ReadonlyArray<LanguageDefinition> = [
65
- { id: "en", englishName: "English", nativeName: "English", shortCode: "EN", dir: "ltr", flag: "🇬🇧" },
66
- { id: "es", englishName: "Spanish", nativeName: "Español", shortCode: "ES", dir: "ltr", flag: "🇪🇸" },
67
- { id: "fr", englishName: "French", nativeName: "Français", shortCode: "FR", dir: "ltr", flag: "🇫🇷" },
68
- { id: "de", englishName: "German", nativeName: "Deutsch", shortCode: "DE", dir: "ltr", flag: "🇩🇪" },
69
- { id: "pt-BR", englishName: "Portuguese (Brazil)", nativeName: "Português", shortCode: "PT", dir: "ltr", flag: "🇧🇷" },
70
- { id: "ru", englishName: "Russian", nativeName: "Русский", shortCode: "RU", dir: "ltr", flag: "🇷🇺" },
71
- { id: "hi", englishName: "Hindi", nativeName: "हिन्दी", shortCode: "HI", dir: "ltr", flag: "🇮🇳" },
72
- { id: "ja", englishName: "Japanese", nativeName: "日本語", shortCode: "JA", dir: "ltr", flag: "🇯🇵" },
73
- { id: "ko", englishName: "Korean", nativeName: "한국어", shortCode: "KO", dir: "ltr", flag: "🇰🇷" },
74
- { id: "zh-CN", englishName: "Chinese (Simplified)", nativeName: "简体中文", shortCode: "ZH", dir: "ltr", flag: "🇨🇳" },
75
- { id: "he", englishName: "Hebrew", nativeName: "עברית", shortCode: "HE", dir: "rtl", flag: "🇮🇱" },
76
- { id: "ar", englishName: "Arabic", nativeName: "العربية", shortCode: "AR", dir: "rtl", flag: "🇸🇦" },
63
+ { id: "en", englishName: "English", nativeName: "English", shortCode: "EN", dir: "ltr" },
64
+ { id: "es", englishName: "Spanish", nativeName: "Español", shortCode: "ES", dir: "ltr" },
65
+ { id: "fr", englishName: "French", nativeName: "Français", shortCode: "FR", dir: "ltr" },
66
+ { id: "de", englishName: "German", nativeName: "Deutsch", shortCode: "DE", dir: "ltr" },
67
+ { id: "pt-BR", englishName: "Portuguese (Brazil)", nativeName: "Português", shortCode: "PT", dir: "ltr" },
68
+ { id: "ru", englishName: "Russian", nativeName: "Русский", shortCode: "RU", dir: "ltr" },
69
+ { id: "hi", englishName: "Hindi", nativeName: "हिन्दी", shortCode: "HI", dir: "ltr" },
70
+ { id: "ja", englishName: "Japanese", nativeName: "日本語", shortCode: "JA", dir: "ltr" },
71
+ { id: "ko", englishName: "Korean", nativeName: "한국어", shortCode: "KO", dir: "ltr" },
72
+ { id: "zh-CN", englishName: "Chinese (Simplified)", nativeName: "简体中文", shortCode: "ZH", dir: "ltr" },
73
+ { id: "he", englishName: "Hebrew", nativeName: "עברית", shortCode: "HE", dir: "rtl" },
74
+ { id: "ar", englishName: "Arabic", nativeName: "العربية", shortCode: "AR", dir: "rtl" },
77
75
  ] as const
78
76
 
79
77
  export const LOCALE_IDS: ReadonlyArray<LocaleId> = LANGUAGES.map((l) => l.id)
package/src/index.ts CHANGED
@@ -110,6 +110,7 @@ export {
110
110
  VIDEO_MODE_ALIASES,
111
111
  VIDEO_GEN_COLLAPSED_T2V_IDS,
112
112
  resolveVideoProviderForMode,
113
+ resolveVideoModeForInputs,
113
114
  isSeedance2Provider,
114
115
  MINIMAX_H3_PROVIDERS,
115
116
  isMinimaxH3Provider,
@@ -281,6 +282,12 @@ export {
281
282
  LLM_MODELS,
282
283
  LLM_MODEL_IDS,
283
284
  STRUCTURED_VISION_MODELS,
285
+ LLM_VENDOR_ORDER,
286
+ LLM_VENDOR_LABELS,
287
+ groupLlmModelsByVendor,
288
+ orderedLlmModels,
289
+ type LlmVendor,
290
+ type LlmModelGroup,
284
291
  VIDEO_ANALYSIS_LLM_MODELS,
285
292
  VIDEO_ANALYSIS_TIERS,
286
293
  VIDEO_ANALYSIS_LEGACY_MODELS,
@@ -390,7 +397,6 @@ export {
390
397
  TILT_CARRIED_FRACTION,
391
398
  isTiltDirection,
392
399
  defaultCarriedFraction,
393
- buildSurroundFillPrompt,
394
400
  type SurroundDirection,
395
401
  } from "./surround.js"
396
402
 
@@ -756,6 +762,9 @@ export type {
756
762
  WorkflowExportObject,
757
763
  WorkflowExportCreature,
758
764
  WorkflowExportLocation,
765
+ WorkflowMediaRef,
766
+ WorkflowPortability,
767
+ WorkflowImportReport,
759
768
  } from "./workflow-export.js"
760
769
  export { stripExportContent } from "./workflow-export.js"
761
770
 
@@ -854,6 +863,8 @@ export * from "./reduce-strategy-registry.js"
854
863
  export {
855
864
  COMBINE_TRANSITIONS,
856
865
  COMBINE_TRANSITION_IDS,
866
+ PICKER_TO_COMBINE_TRANSITION,
867
+ resolveSlideshowTransition,
857
868
  COMBINE_TRANSITION_GROUP_ORDER,
858
869
  COMBINE_TRANSITION_GROUP_LABELS,
859
870
  getCombineTransition,
@@ -880,6 +891,8 @@ export {
880
891
  FAN_OUT_EACH_TYPES,
881
892
  } from "./producer-types.js"
882
893
 
894
+ export { SUNO_TRACK_SOURCE_TYPES } from "./suno-track-sources.js"
895
+
883
896
  export {
884
897
  VOICE_CHANGER_MODELS,
885
898
  VOICE_CHANGER_MODEL_IDS,
@@ -947,3 +960,6 @@ export * from "./smart-cut-windows.js"
947
960
 
948
961
  export * from "./entity-asset-types.js"
949
962
  export * from "./hint-graph-types.js"
963
+
964
+ // --- Organizations (second tenancy axis): wire contract only — enums, settings request schemas, error codes ---
965
+ export * from "./organizations/index.js"