@nodaro/shared 2.5.0 → 2.8.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 +221 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +198 -20
- package/dist/index.d.ts +198 -20
- package/dist/index.js +212 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/entity-image-handle.test.ts +33 -1
- package/src/__tests__/group-aggregation.test.ts +39 -0
- package/src/__tests__/infer-music-video.test.ts +76 -0
- package/src/__tests__/llm-models.test.ts +137 -10
- package/src/__tests__/seedance-2-5-catalog.test.ts +16 -8
- package/src/entity-image-handle.ts +37 -3
- package/src/group-aggregation.ts +32 -0
- package/src/index.ts +8 -0
- package/src/llm-models.ts +116 -2
- package/src/model-catalog.ts +48 -3
- package/src/model-constants.ts +23 -0
- package/src/node-default-mappings.ts +3 -2
- package/src/reduce-strategy-registry.ts +31 -13
- package/src/video-analysis.ts +70 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, sourceRefKey } from "../entity-image-handle.js"
|
|
2
|
+
import { resolveEffectiveSourceType, ENTITY_IMAGE_HANDLE_TYPES, AGGREGATE_LANE_SOURCE_TYPES, sourceRefKey } from "../entity-image-handle.js"
|
|
3
3
|
|
|
4
4
|
const ENTITY_REF_HANDLE: Record<string, string> = {
|
|
5
5
|
character: "characterRef",
|
|
@@ -62,3 +62,35 @@ describe("sourceRefKey (handle-scoped ref key — prevents node-id collision)",
|
|
|
62
62
|
expect(sourceRefKey("n1", undefined, "character")).toBe("n1")
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
describe("resolveEffectiveSourceType (aggregate lane handle → plain producer of that type)", () => {
|
|
67
|
+
const LANE_TO_PRODUCER: Record<string, string> = {
|
|
68
|
+
"out-image": "upload-image",
|
|
69
|
+
"out-video": "upload-video",
|
|
70
|
+
"out-audio": "upload-audio",
|
|
71
|
+
"out-text": "list",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("remaps every Collect / Group lane to the plain producer of its media type", () => {
|
|
75
|
+
for (const agg of AGGREGATE_LANE_SOURCE_TYPES) {
|
|
76
|
+
for (const [lane, producer] of Object.entries(LANE_TO_PRODUCER)) {
|
|
77
|
+
expect(resolveEffectiveSourceType(agg, lane)).toBe(producer)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("leaves a non-lane / missing handle as the raw aggregate type", () => {
|
|
83
|
+
expect(resolveEffectiveSourceType("collect", "out")).toBe("collect")
|
|
84
|
+
expect(resolveEffectiveSourceType("collect", undefined)).toBe("collect")
|
|
85
|
+
expect(resolveEffectiveSourceType("group", null)).toBe("group")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("does not remap lane-shaped handles on non-aggregate producers", () => {
|
|
89
|
+
expect(resolveEffectiveSourceType("generate-image", "out-image")).toBe("generate-image")
|
|
90
|
+
expect(resolveEffectiveSourceType("list", "out-text")).toBe("list")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("AGGREGATE_LANE_SOURCE_TYPES is exactly group + collect", () => {
|
|
94
|
+
expect([...AGGREGATE_LANE_SOURCE_TYPES].sort()).toEqual(["collect", "group"])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
groupHandleId,
|
|
6
6
|
parseGroupHandle,
|
|
7
7
|
isAggregateableType,
|
|
8
|
+
computeAggregateLanes,
|
|
8
9
|
AGGREGATEABLE_TYPES,
|
|
10
|
+
type AggregationBuckets,
|
|
9
11
|
type Member,
|
|
10
12
|
} from "../group-aggregation.js"
|
|
11
13
|
|
|
@@ -81,3 +83,40 @@ describe("isAggregateableType", () => {
|
|
|
81
83
|
expect(isAggregateableType(undefined)).toBe(false)
|
|
82
84
|
})
|
|
83
85
|
})
|
|
86
|
+
|
|
87
|
+
describe("computeAggregateLanes", () => {
|
|
88
|
+
const EMPTY: AggregationBuckets = { text: [], image: [], video: [], audio: [] }
|
|
89
|
+
|
|
90
|
+
it("returns empty when nothing is wired, bucketed, or referenced", () => {
|
|
91
|
+
expect(computeAggregateLanes("c1", [], EMPTY, [])).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("exposes lanes for wired types even with empty buckets (pre-run)", () => {
|
|
95
|
+
expect(computeAggregateLanes("c1", ["image", "image"], EMPTY, [])).toEqual(["image"])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("exposes lanes for bucket contents (post-run)", () => {
|
|
99
|
+
const buckets: AggregationBuckets = { text: ["t"], image: [], video: [], audio: [] }
|
|
100
|
+
expect(computeAggregateLanes("c1", [], buckets, [])).toEqual(["text"])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("keeps a lane alive when an outgoing edge references it", () => {
|
|
104
|
+
const edges = [{ source: "c1", sourceHandle: "out-video" }]
|
|
105
|
+
expect(computeAggregateLanes("c1", [], EMPTY, edges)).toEqual(["video"])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("ignores outgoing edges of other nodes and non-lane handles", () => {
|
|
109
|
+
const edges = [
|
|
110
|
+
{ source: "other", sourceHandle: "out-video" },
|
|
111
|
+
{ source: "c1", sourceHandle: "out" },
|
|
112
|
+
{ source: "c1", sourceHandle: null },
|
|
113
|
+
]
|
|
114
|
+
expect(computeAggregateLanes("c1", [], EMPTY, edges)).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("unions all three sources and orders by AGGREGATEABLE_TYPES", () => {
|
|
118
|
+
const buckets: AggregationBuckets = { text: [], image: [], video: [], audio: ["a"] }
|
|
119
|
+
const edges = [{ source: "c1", sourceHandle: "out-video" }]
|
|
120
|
+
expect(computeAggregateLanes("c1", ["text"], buckets, edges)).toEqual(["text", "video", "audio"])
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { inferMusicVideo, videoAnalysisResultSchema, VIDEO_ANALYSIS_TIMES_OF_DAY, VIDEO_ANALYSIS_STORY_JUMPS } from "../video-analysis"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The inference decides whether a recast takes the original soundtrack AS-IS
|
|
6
|
+
* (no stem separation). Server (recast route mode derivation) and client
|
|
7
|
+
* (prep pricing + the generate-time mode-mismatch guard) both call THIS
|
|
8
|
+
* function, so its exact boundary behaviour is contract, not detail.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const music = (content: string) => ({ mode: "music" as const, content })
|
|
12
|
+
const scene = (audio: Array<{ mode: string; content: string }>) => ({ audio })
|
|
13
|
+
const vocalScenes = (n: number) => Array.from({ length: n }, () => scene([music("upbeat pop track with sung lyrics")]))
|
|
14
|
+
|
|
15
|
+
describe("inferMusicVideo", () => {
|
|
16
|
+
it("true when ≥80% of ≥4 scenes carry music and vocals are evidenced", () => {
|
|
17
|
+
expect(inferMusicVideo({ scenes: vocalScenes(5) })).toBe(true)
|
|
18
|
+
// 4 of 5 with music (80% exactly), one vocal layer
|
|
19
|
+
expect(inferMusicVideo({ scenes: [...vocalScenes(4), scene([])] })).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("quoted lyric text inside a music layer is vocal evidence on its own", () => {
|
|
23
|
+
const scenes = Array.from({ length: 4 }, () => scene([music('gentle track, "la la la, choosing you tonight"')]))
|
|
24
|
+
expect(inferMusicVideo({ scenes })).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("false below the coverage bar, the scene floor, or with no vocal evidence", () => {
|
|
28
|
+
expect(inferMusicVideo({ scenes: [...vocalScenes(3), scene([]), scene([])] })).toBe(false) // 60%
|
|
29
|
+
expect(inferMusicVideo({ scenes: vocalScenes(3) })).toBe(false) // < 4 scenes
|
|
30
|
+
const instrumentalOnly = Array.from({ length: 5 }, () => scene([music("sweeping orchestral score")]))
|
|
31
|
+
expect(inferMusicVideo({ scenes: instrumentalOnly })).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("negated vocals ('instrumental', 'no vocals') are not evidence; sfx/speech layers never are", () => {
|
|
35
|
+
const negated = Array.from({ length: 5 }, () => scene([music("instrumental version of the song, no vocals")]))
|
|
36
|
+
expect(inferMusicVideo({ scenes: negated })).toBe(false)
|
|
37
|
+
const speechy = Array.from({ length: 5 }, () => scene([{ mode: "speech", content: "she sings later" }, music("soft bed")]))
|
|
38
|
+
expect(inferMusicVideo({ scenes: speechy })).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("bare 'song'/'music' descriptions are NOT vocal evidence (instrumental beds are described as songs)", () => {
|
|
42
|
+
const bare = Array.from({ length: 5 }, () => scene([music("upbeat pop song under the action")]))
|
|
43
|
+
expect(inferMusicVideo({ scenes: bare })).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("throw-proof on malformed or absent input", () => {
|
|
47
|
+
expect(inferMusicVideo(undefined)).toBe(false)
|
|
48
|
+
expect(inferMusicVideo(null)).toBe(false)
|
|
49
|
+
expect(inferMusicVideo({})).toBe(false)
|
|
50
|
+
expect(inferMusicVideo({ scenes: [{}, { audio: [{}] }, {}, {}] } as never)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("chronicle-time fields (2.6.0)", () => {
|
|
55
|
+
const base = {
|
|
56
|
+
startSec: 0, endSec: 2, label: "l", shotType: "Wide", camera: "static",
|
|
57
|
+
visual: "a scene", audio: [], sceneNumber: 1, visualResolved: "a scene", slotRefs: [],
|
|
58
|
+
}
|
|
59
|
+
const result = (sceneOver: Record<string, unknown>) => ({
|
|
60
|
+
meta: { durationSec: 2, width: 10, height: 10, aspectRatio: "1:1" },
|
|
61
|
+
slots: [],
|
|
62
|
+
scenes: [{ ...base, ...sceneOver }],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("are optional (pre-2.6.0 analyses parse unchanged) and enum-validated when present", () => {
|
|
66
|
+
expect(videoAnalysisResultSchema.safeParse(result({})).success).toBe(true)
|
|
67
|
+
expect(videoAnalysisResultSchema.safeParse(result({ timeOfDay: "dusk", storyJump: "years-later" })).success).toBe(true)
|
|
68
|
+
expect(videoAnalysisResultSchema.safeParse(result({ timeOfDay: "noon" })).success).toBe(false)
|
|
69
|
+
expect(videoAnalysisResultSchema.safeParse(result({ storyJump: "later" })).success).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("the enums stay congruence-safe for the window decode grammar (no ints, no maxItems)", () => {
|
|
73
|
+
expect(VIDEO_ANALYSIS_TIMES_OF_DAY).toContain("ambiguous")
|
|
74
|
+
expect(VIDEO_ANALYSIS_STORY_JUMPS).toContain("years-later")
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -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
|
|
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(
|
|
95
|
-
expect(tierCounts.standard).toBe(
|
|
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
|
|
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", () => {
|
|
@@ -382,10 +391,11 @@ describe("LLM_FEATURE_DEFAULTS", () => {
|
|
|
382
391
|
"generate-script",
|
|
383
392
|
"translate",
|
|
384
393
|
"image-critic",
|
|
394
|
+
"pick-best-llm",
|
|
385
395
|
]
|
|
386
396
|
|
|
387
|
-
it("has entries for all
|
|
388
|
-
expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(
|
|
397
|
+
it("has entries for all 16 features", () => {
|
|
398
|
+
expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(16)
|
|
389
399
|
for (const feature of ALL_FEATURES) {
|
|
390
400
|
expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
|
|
391
401
|
}
|
|
@@ -471,6 +481,7 @@ describe("STRUCTURED_VISION_MODELS", () => {
|
|
|
471
481
|
"claude-sonnet-4.6",
|
|
472
482
|
"gemini-3-flash",
|
|
473
483
|
"gemini-3.6-flash",
|
|
484
|
+
"gemini-3.7-flash",
|
|
474
485
|
"gemini-3.1-pro",
|
|
475
486
|
"claude-sonnet-5",
|
|
476
487
|
"claude-opus-4.8",
|
|
@@ -483,15 +494,18 @@ describe("STRUCTURED_VISION_MODELS", () => {
|
|
|
483
494
|
"gpt-5.6-luna",
|
|
484
495
|
"gpt-5.6-terra",
|
|
485
496
|
"gpt-5.6-sol",
|
|
497
|
+
// responses-format Grok — vision + text.format live-verified 2026-08-18.
|
|
498
|
+
"grok-4.6",
|
|
486
499
|
].sort(),
|
|
487
500
|
)
|
|
488
501
|
})
|
|
489
502
|
|
|
490
|
-
it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI (responses text.format) vendors", () => {
|
|
503
|
+
it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI/xAI (responses text.format) vendors", () => {
|
|
491
504
|
const vendors = new Set(STRUCTURED_VISION_MODELS.map((m) => m.vendor))
|
|
492
505
|
expect(vendors).toContain("anthropic")
|
|
493
506
|
expect(vendors).toContain("google")
|
|
494
507
|
expect(vendors).toContain("openai")
|
|
508
|
+
expect(vendors).toContain("xai")
|
|
495
509
|
})
|
|
496
510
|
|
|
497
511
|
it("excludes chat-completions GPT models — no native structured mode there (parse+retry only)", () => {
|
|
@@ -508,9 +522,9 @@ describe("STRUCTURED_VISION_MODELS", () => {
|
|
|
508
522
|
})
|
|
509
523
|
|
|
510
524
|
// ---------------------------------------------------------------------------
|
|
511
|
-
// Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8
|
|
512
|
-
// grok-4.5
|
|
513
|
-
//
|
|
525
|
+
// Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8 /
|
|
526
|
+
// Grok 4.6 — grok-4.5 was deferred on 2026-07-13 because its chat endpoint
|
|
527
|
+
// wasn't live; grok-4.6 is its activation, live-verified 2026-08-18.)
|
|
514
528
|
// ---------------------------------------------------------------------------
|
|
515
529
|
describe("reasoning effort registry", () => {
|
|
516
530
|
it("every reasoningEfforts list is a subset of the superset, in ascending order", () => {
|
|
@@ -525,6 +539,7 @@ describe("reasoning effort registry", () => {
|
|
|
525
539
|
expect(getLlmTier("gpt-5.6-luna")).toBe("economy")
|
|
526
540
|
expect(getLlmTier("gpt-5.6-terra")).toBe("standard")
|
|
527
541
|
expect(getLlmTier("gpt-5.6-sol")).toBe("premium")
|
|
542
|
+
expect(getLlmTier("grok-4.6")).toBe("standard")
|
|
528
543
|
expect(getLlmTier("claude-sonnet-5")).toBe("standard")
|
|
529
544
|
expect(getLlmTier("claude-opus-4.8")).toBe("premium")
|
|
530
545
|
expect(getLlmTier("gpt-5.5")).toBe("premium")
|
|
@@ -556,6 +571,62 @@ describe("reasoning effort registry", () => {
|
|
|
556
571
|
expect(getLlmModel("claude-fable-5")?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"])
|
|
557
572
|
expect(getLlmModel("claude-fable-5")?.directFallbackModel).toBe("claude-fable-5")
|
|
558
573
|
})
|
|
574
|
+
|
|
575
|
+
it("grok-4.6: responses format on the grok family path, KIE's documented effort enum, no sampling params, reasons by default", () => {
|
|
576
|
+
const m = getLlmModel("grok-4.6")
|
|
577
|
+
expect(m?.vendor).toBe("xai")
|
|
578
|
+
expect(m?.kieFormat).toBe("responses")
|
|
579
|
+
expect(m?.kieSlugOrModel).toBe("grok-4-6")
|
|
580
|
+
expect(m?.structuredOutputMode).toBe("responses-json-schema")
|
|
581
|
+
// KIE's enum is low..xhigh with NO `none` — the endpoint reasons
|
|
582
|
+
// unconditionally (thinkingDefaultOn), so `none` would be a lie.
|
|
583
|
+
expect(m?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"])
|
|
584
|
+
// Live-probed 2026-08-18: temperature is silently ignored — never send it.
|
|
585
|
+
expect(m?.supportsTemperature).toBe(false)
|
|
586
|
+
// Load-bearing: consumers floor max_tokens off this flag (a trivial probe
|
|
587
|
+
// spent 169/170 output tokens on reasoning with no reasoning param sent).
|
|
588
|
+
expect(m?.thinkingDefaultOn).toBe(true)
|
|
589
|
+
// KIE-only: no direct lane on either vendor SDK.
|
|
590
|
+
expect(m?.directFallbackModel).toBeUndefined()
|
|
591
|
+
expect(m?.directGeminiModel).toBeUndefined()
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it("grok-4.6 resolves from its dash-form wire slug", () => {
|
|
595
|
+
expect(getLlmModel("grok-4-6")?.id).toBe("grok-4.6")
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// gemini-3.7-flash exposure (both lanes live-verified 2026-08-18: KIE
|
|
601
|
+
// chat-completions with reasoning_effort + enforced response_format, and the
|
|
602
|
+
// direct Google id resolving on generativelanguage)
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
describe("gemini-3.7-flash exposure", () => {
|
|
605
|
+
it("KIE-first chat-completions with a direct lane — 3.6-flash's proven shape", () => {
|
|
606
|
+
const m = getLlmModel("gemini-3.7-flash")
|
|
607
|
+
expect(m?.tier).toBe("economy")
|
|
608
|
+
expect(m?.vendor).toBe("google")
|
|
609
|
+
expect(m?.kieFormat).toBe("chat-completions")
|
|
610
|
+
expect(m?.kieSlugOrModel).toBe("gemini-3-7-flash-openai")
|
|
611
|
+
// KIE-first on purpose (no preferDirect): the cheap lane serves Generate
|
|
612
|
+
// Text; the direct lane is Advanced mode + the reliability fallback.
|
|
613
|
+
expect(m?.preferDirect).toBeUndefined()
|
|
614
|
+
expect(m?.directGeminiModel).toBe("gemini-3.7-flash")
|
|
615
|
+
expect(m?.reasoningEfforts).toEqual(["low", "high"])
|
|
616
|
+
expect(m?.structuredOutputMode).toBe("kie-response-format")
|
|
617
|
+
expect(supportsAdvancedMode("gemini-3.7-flash")).toBe(true)
|
|
618
|
+
expect(availableReasoningEfforts("gemini-3.7-flash", true)).toEqual(["none", "low", "medium", "high"])
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it("stays OUT of video-analysis — image-only modality caps by decision, not omission", () => {
|
|
622
|
+
// Full video+audio caps would auto-enroll it in VIDEO_ANALYSIS_LLM_MODELS
|
|
623
|
+
// and force a VA tier + pricing decision that is deliberately deferred
|
|
624
|
+
// while the smart-family A/B routes this model internally (2026-08-18).
|
|
625
|
+
// If this test goes red, someone flipped the caps — that flip is only
|
|
626
|
+
// valid TOGETHER with the VA-side tier/pricing decision.
|
|
627
|
+
expect(getLlmModalityCaps("gemini-3.7-flash")).toEqual({ image: true, video: false, audio: false })
|
|
628
|
+
expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain("gemini-3.7-flash")
|
|
629
|
+
})
|
|
559
630
|
})
|
|
560
631
|
|
|
561
632
|
describe("effectiveReasoningEffort", () => {
|
|
@@ -583,6 +654,7 @@ describe("buildLlmCreditIdentifier effort bump (xhigh/max only)", () => {
|
|
|
583
654
|
})
|
|
584
655
|
it("standard + xhigh → premium", () => {
|
|
585
656
|
expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-terra", "xhigh")).toBe("llm-chat:premium")
|
|
657
|
+
expect(buildLlmCreditIdentifier("llm-chat", "grok-4.6", "xhigh")).toBe("llm-chat:premium")
|
|
586
658
|
})
|
|
587
659
|
it("premium + max stays premium", () => {
|
|
588
660
|
expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-sol", "max")).toBe("llm-chat:premium")
|
|
@@ -766,3 +838,58 @@ describe("lane-aware reasoning efforts", () => {
|
|
|
766
838
|
expect(bad).toEqual([])
|
|
767
839
|
})
|
|
768
840
|
})
|
|
841
|
+
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
// Vendor grouping — the ONE ordering every LLM model menu renders
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
describe("groupLlmModelsByVendor / orderedLlmModels", () => {
|
|
846
|
+
it("every vendor in the union has an order slot AND a label (totality)", () => {
|
|
847
|
+
// A new vendor added to LlmVendor without a menu decision would silently
|
|
848
|
+
// sort its models to the end of every picker unlabeled — fail here instead.
|
|
849
|
+
const vendorsInUse = new Set(LLM_MODELS.map((m) => m.vendor))
|
|
850
|
+
for (const v of vendorsInUse) {
|
|
851
|
+
expect(LLM_VENDOR_ORDER, `vendor "${v}" missing from LLM_VENDOR_ORDER`).toContain(v)
|
|
852
|
+
expect(LLM_VENDOR_LABELS[v], `vendor "${v}" missing from LLM_VENDOR_LABELS`).toBeTruthy()
|
|
853
|
+
}
|
|
854
|
+
for (const v of LLM_VENDOR_ORDER) {
|
|
855
|
+
expect(LLM_VENDOR_LABELS[v]).toBeTruthy()
|
|
856
|
+
}
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
it("covers every model exactly once, in vendor order", () => {
|
|
860
|
+
const groups = groupLlmModelsByVendor()
|
|
861
|
+
const flattened = groups.flatMap((g) => g.models.map((m) => m.id))
|
|
862
|
+
expect(flattened.sort()).toEqual(LLM_MODELS.map((m) => m.id).sort())
|
|
863
|
+
expect(new Set(flattened).size).toBe(flattened.length)
|
|
864
|
+
const groupVendors = groups.map((g) => g.vendor)
|
|
865
|
+
expect(groupVendors).toEqual(LLM_VENDOR_ORDER.filter((v) => groupVendors.includes(v)))
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it("inside each group models are tier-ordered economy → standard → premium", () => {
|
|
869
|
+
const rank = { economy: 0, standard: 1, premium: 2 } as const
|
|
870
|
+
for (const g of groupLlmModelsByVendor()) {
|
|
871
|
+
const ranks = g.models.map((m) => rank[m.tier])
|
|
872
|
+
expect(ranks, g.vendor).toEqual([...ranks].sort((a, b) => a - b))
|
|
873
|
+
}
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it("groups carry their display label and omit empty groups after a filter", () => {
|
|
877
|
+
const groups = groupLlmModelsByVendor(LLM_MODELS.filter((m) => m.vendor === "xai"))
|
|
878
|
+
expect(groups).toHaveLength(1)
|
|
879
|
+
expect(groups[0].label).toBe("xAI")
|
|
880
|
+
expect(groups[0].models.map((m) => m.id)).toEqual(["grok-4.6"])
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
it("does not mutate the registry (registry order is load-bearing for LLM_MODEL_IDS)", () => {
|
|
884
|
+
const before = LLM_MODELS.map((m) => m.id)
|
|
885
|
+
groupLlmModelsByVendor()
|
|
886
|
+
orderedLlmModels()
|
|
887
|
+
expect(LLM_MODELS.map((m) => m.id)).toEqual(before)
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it("orderedLlmModels is the flattened grouping", () => {
|
|
891
|
+
expect(orderedLlmModels().map((m) => m.id)).toEqual(
|
|
892
|
+
groupLlmModelsByVendor().flatMap((g) => g.models.map((m) => m.id)),
|
|
893
|
+
)
|
|
894
|
+
})
|
|
895
|
+
})
|
|
@@ -23,10 +23,14 @@ const ID = "seedance-2-5"
|
|
|
23
23
|
* api.kie.ai on 2026-08-08, NOT by the published schema — KIE's docs advertise
|
|
24
24
|
* a wider surface than the proxy actually accepts. These tests pin the probed
|
|
25
25
|
* reality so a future "the spec sheet says 4K/180s" edit has to re-probe first.
|
|
26
|
+
*
|
|
27
|
+
* Re-probed 2026-08-17 (KIE "Seedance 2.5 now supports 1080P" release):
|
|
28
|
+
* 1080p passes resolution validation; 1440p/2k/4k are still rejected with
|
|
29
|
+
* "not within the range of allowed options", and duration 31 is still refused.
|
|
26
30
|
*/
|
|
27
|
-
describe("seedance-2-5 catalog (probe-verified 2026-08-08)", () => {
|
|
28
|
-
it("is 480p/720p
|
|
29
|
-
expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p"])
|
|
31
|
+
describe("seedance-2-5 catalog (probe-verified 2026-08-08, re-probed 2026-08-17)", () => {
|
|
32
|
+
it("is 480p/720p/1080p — KIE still rejects 4k/2k/1440p the same way it rejects a nonsense value", () => {
|
|
33
|
+
expect(MODEL_CATALOG[ID].resolutions).toEqual(["480p", "720p", "1080p"])
|
|
30
34
|
})
|
|
31
35
|
|
|
32
36
|
it("runs 4-30s contiguously — 30 is the probed ceiling (31 was rejected)", () => {
|
|
@@ -101,9 +105,13 @@ describe("seedance-2-5 pricing identifiers", () => {
|
|
|
101
105
|
}
|
|
102
106
|
})
|
|
103
107
|
|
|
108
|
+
it("prices 1080p at its own tier — a supported resolution must never clamp away", () => {
|
|
109
|
+
expect(build(8, "1080p")).toBe("seedance-2-5:8s:1080p")
|
|
110
|
+
expect(build(30, "1080p")).toBe("seedance-2-5:30s:1080p")
|
|
111
|
+
})
|
|
112
|
+
|
|
104
113
|
it("clamps an unsupported resolution to the top real tier so the id is always seeded", () => {
|
|
105
|
-
expect(build(8, "
|
|
106
|
-
expect(build(8, "4k")).toBe("seedance-2-5:8s:720p")
|
|
114
|
+
expect(build(8, "4k")).toBe("seedance-2-5:8s:1080p")
|
|
107
115
|
})
|
|
108
116
|
|
|
109
117
|
it("selects the cheaper -ref ladder when a reference video is wired", () => {
|
|
@@ -114,7 +122,7 @@ describe("seedance-2-5 pricing identifiers", () => {
|
|
|
114
122
|
it("resolves every catalog duration x resolution x ref-mode to a distinct tier", () => {
|
|
115
123
|
const seen = new Set<string>()
|
|
116
124
|
for (const d of MODEL_CATALOG[ID].durations!) {
|
|
117
|
-
for (const res of ["480p", "720p"]) {
|
|
125
|
+
for (const res of ["480p", "720p", "1080p"]) {
|
|
118
126
|
for (const ref of [false, true]) {
|
|
119
127
|
const identifier = build(d, res, ref)
|
|
120
128
|
expect(identifier).toBe(`${ID}:${d}s:${res}${ref ? "-ref" : ""}`)
|
|
@@ -122,7 +130,7 @@ describe("seedance-2-5 pricing identifiers", () => {
|
|
|
122
130
|
}
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
|
-
// 27 durations x
|
|
126
|
-
expect(seen.size).toBe(
|
|
133
|
+
// 27 durations x 3 resolutions x 2 ref-modes, none collapsing onto another.
|
|
134
|
+
expect(seen.size).toBe(162)
|
|
127
135
|
})
|
|
128
136
|
})
|
|
@@ -19,11 +19,41 @@ export const ENTITY_IMAGE_HANDLE_TYPES: ReadonlySet<string> = new Set([
|
|
|
19
19
|
"creature",
|
|
20
20
|
])
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Aggregate node types (Group / Collect) whose source handles are typed LANES
|
|
24
|
+
* — `out-text` / `out-image` / `out-video` / `out-audio` — each emitting that
|
|
25
|
+
* media type. Like an entity's `image` handle, a wire leaving a lane is a plain
|
|
26
|
+
* producer of that lane's type, so it must reach the typed inputs of every
|
|
27
|
+
* consumer (Image Collage, Combine Videos, Merge Lists, prompts, …) exactly as
|
|
28
|
+
* an upload node of that type would. Neither node type is in any producer set
|
|
29
|
+
* itself: the NODE emits nothing, its LANES do.
|
|
30
|
+
*/
|
|
31
|
+
export const AGGREGATE_LANE_SOURCE_TYPES: ReadonlySet<string> = new Set(["group", "collect"])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The plain producer each aggregate lane behaves as. `upload-*` are the
|
|
35
|
+
* canonical single-media producers present in every typed accepts set
|
|
36
|
+
* (IMAGE/VIDEO/AUDIO_PRODUCER_TYPES). `out-text` maps to `list`, not
|
|
37
|
+
* `text-prompt`: the lane is a LIST of texts, so it must reach both prompt
|
|
38
|
+
* inputs (TEXT_PRODUCER_TYPES) and the list consumers (Merge Lists / Sort /
|
|
39
|
+
* Dedup / Selector — LIST_PRODUCER_TYPES), exactly like a List text column.
|
|
40
|
+
* (`list` is also a DYNAMIC producer, so the text lane is admitted at media
|
|
41
|
+
* inputs too — the same latitude a List column already has on canvas.)
|
|
42
|
+
*/
|
|
43
|
+
const AGGREGATE_LANE_EFFECTIVE_TYPE: Readonly<Record<string, string>> = {
|
|
44
|
+
"out-image": "upload-image",
|
|
45
|
+
"out-video": "upload-video",
|
|
46
|
+
"out-audio": "upload-audio",
|
|
47
|
+
"out-text": "list",
|
|
48
|
+
}
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* The effective output TYPE a given source handle emits. Returns the raw node
|
|
24
|
-
* type for every `(type, handle)` pair EXCEPT
|
|
25
|
-
*
|
|
26
|
-
*
|
|
52
|
+
* type for every `(type, handle)` pair EXCEPT:
|
|
53
|
+
* - an entity `image` handle → `"upload-image"` (a plain image producer);
|
|
54
|
+
* - an aggregate (group / collect) lane handle → the plain producer of that
|
|
55
|
+
* lane's media type (see AGGREGATE_LANE_EFFECTIVE_TYPE).
|
|
56
|
+
* Pure — safe for both frontend and backend.
|
|
27
57
|
*/
|
|
28
58
|
export function resolveEffectiveSourceType(
|
|
29
59
|
rawSourceType: string | undefined | null,
|
|
@@ -32,6 +62,10 @@ export function resolveEffectiveSourceType(
|
|
|
32
62
|
if (sourceHandleId === "image" && ENTITY_IMAGE_HANDLE_TYPES.has(rawSourceType ?? "")) {
|
|
33
63
|
return "upload-image"
|
|
34
64
|
}
|
|
65
|
+
if (AGGREGATE_LANE_SOURCE_TYPES.has(rawSourceType ?? "")) {
|
|
66
|
+
const effective = AGGREGATE_LANE_EFFECTIVE_TYPE[sourceHandleId ?? ""]
|
|
67
|
+
if (effective) return effective
|
|
68
|
+
}
|
|
35
69
|
return rawSourceType ?? ""
|
|
36
70
|
}
|
|
37
71
|
|
package/src/group-aggregation.ts
CHANGED
|
@@ -73,3 +73,35 @@ export function buildChildrenByParent<N extends { id: string; parentId?: string
|
|
|
73
73
|
export function isCollectInEdge(e: { targetHandle?: string | null }): boolean {
|
|
74
74
|
return (e.targetHandle ?? COLLECT_IN_HANDLE) === COLLECT_IN_HANDLE
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The source-pip lanes an aggregate node (Group / Collect) must EXPOSE.
|
|
79
|
+
*
|
|
80
|
+
* Bucket contents alone are not enough: buckets are computed from upstream
|
|
81
|
+
* RESULTS, so before anything ran the node rendered zero source handles — a
|
|
82
|
+
* pre-authored edge out of the node (MCP-written JSON, import, paste) pointed
|
|
83
|
+
* at a handle that did not exist and React Flow silently hid it ("connected
|
|
84
|
+
* in the panel, invisible on canvas"), and there was no pip to drag outward
|
|
85
|
+
* from. A lane therefore exists when ANY of these hold:
|
|
86
|
+
* (a) a wired member / input of that aggregateable type exists (pre-run),
|
|
87
|
+
* (b) the bucket for that type has values (post-run),
|
|
88
|
+
* (c) an existing outgoing edge references the lane's handle — so an edge
|
|
89
|
+
* can never point at a missing handle, whatever state authored it
|
|
90
|
+
* (also covers results being cleared after wiring downstream).
|
|
91
|
+
* Ordered by AGGREGATEABLE_TYPES so the handle layout is stable.
|
|
92
|
+
*/
|
|
93
|
+
export function computeAggregateLanes(
|
|
94
|
+
nodeId: string,
|
|
95
|
+
wiredTypes: Iterable<AggregateableType>,
|
|
96
|
+
buckets: AggregationBuckets,
|
|
97
|
+
edges: ReadonlyArray<{ source: string; sourceHandle?: string | null }>,
|
|
98
|
+
): AggregateableType[] {
|
|
99
|
+
const present = new Set<AggregateableType>(wiredTypes)
|
|
100
|
+
for (const t of presentTypes(buckets)) present.add(t)
|
|
101
|
+
for (const e of edges) {
|
|
102
|
+
if (e.source !== nodeId) continue
|
|
103
|
+
const lane = parseGroupHandle(e.sourceHandle)
|
|
104
|
+
if (lane) present.add(lane)
|
|
105
|
+
}
|
|
106
|
+
return AGGREGATEABLE_TYPES.filter((t) => present.has(t))
|
|
107
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,7 @@ export {
|
|
|
62
62
|
IMAGE_GEN_PROVIDERS,
|
|
63
63
|
IMAGE_I2I_PROVIDERS,
|
|
64
64
|
IMAGE_EDIT_PROVIDERS,
|
|
65
|
+
TASK_CHAINED_EDIT_PROVIDERS,
|
|
65
66
|
IMAGE_TO_VIDEO_PROVIDERS,
|
|
66
67
|
TEXT_TO_VIDEO_PROVIDERS,
|
|
67
68
|
VIDEO_GEN_PROVIDERS,
|
|
@@ -270,6 +271,7 @@ export {
|
|
|
270
271
|
parseGroupHandle,
|
|
271
272
|
isCollectInEdge,
|
|
272
273
|
buildChildrenByParent,
|
|
274
|
+
computeAggregateLanes,
|
|
273
275
|
type AggregateableType,
|
|
274
276
|
type Member,
|
|
275
277
|
type AggregationBuckets,
|
|
@@ -279,6 +281,12 @@ export {
|
|
|
279
281
|
LLM_MODELS,
|
|
280
282
|
LLM_MODEL_IDS,
|
|
281
283
|
STRUCTURED_VISION_MODELS,
|
|
284
|
+
LLM_VENDOR_ORDER,
|
|
285
|
+
LLM_VENDOR_LABELS,
|
|
286
|
+
groupLlmModelsByVendor,
|
|
287
|
+
orderedLlmModels,
|
|
288
|
+
type LlmVendor,
|
|
289
|
+
type LlmModelGroup,
|
|
282
290
|
VIDEO_ANALYSIS_LLM_MODELS,
|
|
283
291
|
VIDEO_ANALYSIS_TIERS,
|
|
284
292
|
VIDEO_ANALYSIS_LEGACY_MODELS,
|