@nodaro/shared 3.10.0 → 3.12.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.
Files changed (72) hide show
  1. package/dist/index.cjs +2242 -86
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1779 -32
  4. package/dist/index.d.ts +1779 -32
  5. package/dist/index.js +2041 -87
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/parameter-node-value.test.ts +13 -1
  17. package/src/__tests__/presentation-utils.test.ts +67 -0
  18. package/src/__tests__/producer-types.test.ts +19 -0
  19. package/src/__tests__/schedule-rules.test.ts +265 -0
  20. package/src/__tests__/speaker-layouts.test.ts +203 -0
  21. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  22. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  23. package/src/__tests__/trigger-feeds.test.ts +39 -0
  24. package/src/__tests__/video-analysis.test.ts +15 -0
  25. package/src/__tests__/video-duration-auto.test.ts +65 -0
  26. package/src/__tests__/video-duration.test.ts +56 -0
  27. package/src/__tests__/video-frame-fit.test.ts +189 -0
  28. package/src/__tests__/video-link.test.ts +137 -0
  29. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  30. package/src/caption-styles.ts +240 -0
  31. package/src/catalog-projection.ts +3 -0
  32. package/src/character-motion-metadata.ts +19 -0
  33. package/src/credit-identifiers.ts +31 -0
  34. package/src/edit-plan-contract.ts +96 -0
  35. package/src/edl-multicam.ts +185 -0
  36. package/src/edl.ts +747 -0
  37. package/src/entity-image-handle.ts +24 -1
  38. package/src/fan-out-rows.ts +213 -0
  39. package/src/i18n/character-motion.ar.ts +126 -75
  40. package/src/i18n/character-motion.de.ts +126 -75
  41. package/src/i18n/character-motion.es.ts +126 -75
  42. package/src/i18n/character-motion.fr.ts +126 -75
  43. package/src/i18n/character-motion.he.ts +126 -75
  44. package/src/i18n/character-motion.hi.ts +126 -75
  45. package/src/i18n/character-motion.ja.ts +126 -75
  46. package/src/i18n/character-motion.ko.ts +126 -75
  47. package/src/i18n/character-motion.pt-BR.ts +126 -75
  48. package/src/i18n/character-motion.ru.ts +126 -75
  49. package/src/i18n/character-motion.zh-CN.ts +126 -75
  50. package/src/index.ts +211 -3
  51. package/src/instagram-scrape.ts +204 -0
  52. package/src/llm-models.ts +80 -3
  53. package/src/meta-ads-scrape.ts +463 -0
  54. package/src/model-catalog.ts +48 -5
  55. package/src/model-constants.ts +148 -5
  56. package/src/node-mappable-fields.ts +2 -0
  57. package/src/node-runtime-keys.ts +28 -0
  58. package/src/parameter-node-value.ts +31 -5
  59. package/src/presentation-utils.ts +49 -0
  60. package/src/producer-types.ts +20 -0
  61. package/src/schedule-rules.ts +484 -0
  62. package/src/speaker-layouts.ts +220 -0
  63. package/src/transcribe-preflight.ts +101 -0
  64. package/src/trigger-feeds.ts +59 -0
  65. package/src/trigger-node-types.ts +20 -0
  66. package/src/video-analysis.ts +15 -0
  67. package/src/video-duration-auto.ts +18 -0
  68. package/src/video-duration.ts +32 -0
  69. package/src/video-frame-fit.ts +228 -0
  70. package/src/video-link.ts +167 -0
  71. package/src/video-output-canvas.ts +119 -0
  72. package/src/workflow-export.ts +37 -1
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ NON_PROMPT_TEXT_LANES,
4
+ fanOutTextFeedsPrompt,
5
+ compactWithRows,
6
+ resolveListFanOut,
7
+ planFanOut,
8
+ alignedFieldList,
9
+ liveRowColumn,
10
+ type FanOutCandidate,
11
+ } from "../fan-out-rows.js"
12
+ import { REPEAT_PLACEHOLDER, encodeProviderItem } from "../repeat-types.js"
13
+
14
+ describe("fanOutTextFeedsPrompt", () => {
15
+ it("a list wired to the prompt (or to a node's single generic input) feeds the prompt", () => {
16
+ expect(fanOutTextFeedsPrompt("generate-image", "prompt")).toBe(true)
17
+ expect(fanOutTextFeedsPrompt("text-to-speech", "in")).toBe(true)
18
+ expect(fanOutTextFeedsPrompt("generate-image", undefined)).toBe(true)
19
+ expect(fanOutTextFeedsPrompt(undefined, null)).toBe(true)
20
+ })
21
+
22
+ it("a list wired to a lane the routers divert does NOT feed the prompt", () => {
23
+ expect(fanOutTextFeedsPrompt("generate-image", "negative")).toBe(false)
24
+ expect(fanOutTextFeedsPrompt("llm-chat", "system-prompt")).toBe(false)
25
+ expect(fanOutTextFeedsPrompt("ai-avatar", "script")).toBe(false)
26
+ })
27
+
28
+ it("is scoped by node type: the same handle can be the prompt elsewhere", () => {
29
+ expect(fanOutTextFeedsPrompt("add-captions", "transcript")).toBe(false)
30
+ expect(fanOutTextFeedsPrompt("forced-alignment", "transcript")).toBe(true)
31
+ })
32
+
33
+ it("a '*' lane covers every node type, including ones that do not exist yet", () => {
34
+ expect(NON_PROMPT_TEXT_LANES.negative).toBe("*")
35
+ expect(fanOutTextFeedsPrompt("some-future-node", "negative")).toBe(false)
36
+ })
37
+
38
+ it("a handle named like an Object.prototype member is just an unlisted handle", () => {
39
+ expect(fanOutTextFeedsPrompt("generate-image", "constructor")).toBe(true)
40
+ expect(fanOutTextFeedsPrompt("generate-image", "toString")).toBe(true)
41
+ })
42
+ })
43
+
44
+ describe("compactWithRows", () => {
45
+ it("drops empty cells but remembers the row each kept value came from", () => {
46
+ expect(compactWithRows(["a", "", " ", "d"])).toEqual({ items: ["a", "d"], rowIndices: [0, 3] })
47
+ })
48
+
49
+ it("is the identity (with 0..n-1 rows) for a list with no holes", () => {
50
+ expect(compactWithRows(["a", "b"])).toEqual({ items: ["a", "b"], rowIndices: [0, 1] })
51
+ })
52
+
53
+ it("keeps the ORIGINAL text of a kept value (no trimming of content)", () => {
54
+ expect(compactWithRows([" a ", "b\nc"]).items).toEqual([" a ", "b\nc"])
55
+ })
56
+ })
57
+
58
+ describe("liveRowColumn", () => {
59
+ it("drops a row only when EVERY cell of it is blank, and keeps an empty cell of a live row in place", () => {
60
+ const rows = [["a", "x"], ["b", ""], ["", ""], ["", "z"]]
61
+ expect(liveRowColumn(rows, 0)).toEqual(["a", "b", ""])
62
+ expect(liveRowColumn(rows, 1)).toEqual(["x", "", "z"])
63
+ })
64
+
65
+ it("a single-column list with a trailing blank row does not grow", () => {
66
+ expect(liveRowColumn([["a"], ["b"], [""]], 0)).toEqual(["a", "b"])
67
+ })
68
+
69
+ it("trims cells and tolerates ragged rows", () => {
70
+ expect(liveRowColumn([[" a "], ["b", "y"]], 1)).toEqual(["", "y"])
71
+ })
72
+ })
73
+
74
+ describe("resolveListFanOut", () => {
75
+ const cand = (targetHandle: string, aligned: string[]): FanOutCandidate => ({ targetHandle, aligned })
76
+
77
+ it("a single list: its values drive, on the rows they sit in", () => {
78
+ const only = cand("prompt", ["p1", "", "p3"])
79
+ expect(resolveListFanOut([only], "generate-image")).toEqual({ items: ["p1", "p3"], rowIndices: [0, 2], targetHandle: "prompt" })
80
+ })
81
+
82
+ it("the list that feeds the prompt drives — whichever wire the engine met first", () => {
83
+ const negative = cand("negative", ["n1", "n2", "n3"])
84
+ const prompt = cand("prompt", ["p1", "p2", "p3"])
85
+ const expected = { items: ["p1", "p2", "p3"], rowIndices: [0, 1, 2], targetHandle: "prompt" }
86
+ expect(resolveListFanOut([negative, prompt], "generate-image")).toEqual(expected)
87
+ expect(resolveListFanOut([prompt, negative], "generate-image")).toEqual(expected)
88
+ })
89
+
90
+ it("a list wired to negative ALONE drives through negative (so nothing writes it into the prompt)", () => {
91
+ const negative = cand("negative", ["n1", "n2"])
92
+ expect(resolveListFanOut([negative], "generate-image")?.targetHandle).toBe("negative")
93
+ })
94
+
95
+ it("a row runs when ANY column has a value in it; an empty driver cell overrides nothing", () => {
96
+ const negative = cand("negative", ["n1", "n2", "n3"])
97
+ const prompt = cand("prompt", ["p1", "", "p3"])
98
+ const expected = { items: ["p1", "", "p3"], rowIndices: [0, 1, 2], targetHandle: "prompt" }
99
+ expect(resolveListFanOut([negative, prompt], "generate-image")).toEqual(expected)
100
+ expect(resolveListFanOut([prompt, negative], "generate-image")).toEqual(expected)
101
+ })
102
+
103
+ it("a row that is empty in EVERY column does not run", () => {
104
+ const negative = cand("negative", ["n1", "", "n3"])
105
+ const prompt = cand("prompt", ["p1", "", "p3"])
106
+ expect(resolveListFanOut([prompt, negative], "generate-image")?.rowIndices).toEqual([0, 2])
107
+ })
108
+
109
+ it("the list holding the MOST values sets the rows — in either wire order — and one of another length stays out of it", () => {
110
+ const negative = cand("negative", ["n1", "n2", "n3"])
111
+ const prompt = cand("prompt", ["p1", "p2"])
112
+ const expected = { items: ["n1", "n2", "n3"], rowIndices: [0, 1, 2], targetHandle: "negative" }
113
+ expect(resolveListFanOut([negative, prompt], "generate-image")).toEqual(expected)
114
+ expect(resolveListFanOut([prompt, negative], "generate-image")).toEqual(expected)
115
+ })
116
+
117
+ it("values held decide the primary, not the row count (a long column that is mostly empty does not win)", () => {
118
+ const sparse = cand("negative", ["n1", "", "", "", "n5"])
119
+ const prompts = cand("prompt", ["p1", "p2", "p3"])
120
+ expect(resolveListFanOut([sparse, prompts], "generate-image")).toEqual({
121
+ items: ["p1", "p2", "p3"],
122
+ rowIndices: [0, 1, 2],
123
+ targetHandle: "prompt",
124
+ })
125
+ })
126
+
127
+ it("no candidates, no fan-out", () => {
128
+ expect(resolveListFanOut([], "generate-image")).toBeUndefined()
129
+ })
130
+
131
+ it("a media list never supplies the prompt: beside images, the prompts drive and every image row still runs", () => {
132
+ const images = cand("image", ["https://cdn/a.png", "https://cdn/b.png", "https://cdn/c.png"])
133
+ const prompts = cand("prompt", ["p1", "", "p3"])
134
+ const expected = { items: ["p1", "", "p3"], rowIndices: [0, 1, 2], targetHandle: "prompt" }
135
+ expect(resolveListFanOut([images, prompts], "image-to-video")).toEqual(expected)
136
+ expect(resolveListFanOut([prompts, images], "image-to-video")).toEqual(expected)
137
+ })
138
+ })
139
+
140
+ describe("planFanOut", () => {
141
+ const fanOut = { items: ["a", "c"], rowIndices: [0, 2], targetHandle: "prompt" }
142
+
143
+ it("maps every iteration to the row its item came from", () => {
144
+ expect(planFanOut(fanOut, "generate-image", {})).toEqual({
145
+ items: ["a", "c"],
146
+ rows: [0, 2],
147
+ targetHandle: "prompt",
148
+ })
149
+ })
150
+
151
+ it("repeat xN keeps each repeated iteration on ITS OWN row (the sibling columns stay paired)", () => {
152
+ expect(planFanOut(fanOut, "generate-image", { repeatCount: 2 })).toEqual({
153
+ items: ["a", "a", "c", "c"],
154
+ rows: [0, 0, 2, 2],
155
+ targetHandle: "prompt",
156
+ })
157
+ })
158
+
159
+ it("repeat-only and provider-only runs have no rows (nothing is list-driven)", () => {
160
+ expect(planFanOut(undefined, "generate-image", { repeatCount: 3 })).toEqual({
161
+ items: [REPEAT_PLACEHOLDER, REPEAT_PLACEHOLDER, REPEAT_PLACEHOLDER],
162
+ rows: [undefined, undefined, undefined],
163
+ targetHandle: undefined,
164
+ })
165
+ expect(planFanOut(undefined, "generate-image", { providers: ["a", "b"] })?.items).toEqual([
166
+ encodeProviderItem("a"),
167
+ encodeProviderItem("b"),
168
+ ])
169
+ })
170
+
171
+ it("returns null when there is nothing to fan out (single run)", () => {
172
+ expect(planFanOut(undefined, "generate-image", {})).toBeNull()
173
+ expect(planFanOut({ items: ["only"], rowIndices: [0], targetHandle: "prompt" }, "generate-image", {})).toBeNull()
174
+ })
175
+ })
176
+
177
+ describe("alignedFieldList", () => {
178
+ const concepts = [
179
+ { prompt: "p1", negative: "n1" },
180
+ { prompt: "p2", negative: "" },
181
+ { prompt: "p3", negative: "n3" },
182
+ { prompt: "p4" },
183
+ { prompt: "p5", negative: null },
184
+ ]
185
+
186
+ it("emits one entry per element of a root array, empty where the element has no value", () => {
187
+ expect(alignedFieldList(concepts, "negative")).toEqual(["n1", "", "n3", "", ""])
188
+ expect(alignedFieldList(concepts, "prompt")).toEqual(["p1", "p2", "p3", "p4", "p5"])
189
+ })
190
+
191
+ it("stringifies like Extract Field does (numbers, booleans, objects)", () => {
192
+ expect(alignedFieldList([{ v: 1 }, { v: false }, { v: { a: 1 } }], "v")).toEqual(["1", "false", '{"a":1}'])
193
+ })
194
+
195
+ it("supports nested dot paths and whole-item mode", () => {
196
+ expect(alignedFieldList([{ a: { b: "x" } }, { a: {} }], "a.b")).toEqual(["x", ""])
197
+ expect(alignedFieldList(["a", "b"], "")).toEqual(["a", "b"])
198
+ })
199
+
200
+ it("has no answer when rows cannot be defined — not a root array, or an element fans into several values", () => {
201
+ expect(alignedFieldList({ prompt: "p" }, "prompt")).toBeUndefined()
202
+ expect(alignedFieldList([{ pages: [{ url: "u1" }, { url: "u2" }] }], "pages.url")).toBeUndefined()
203
+ })
204
+
205
+ it("has no answer when NO element carries the field (an all-blank list is not a list)", () => {
206
+ expect(alignedFieldList([{ a: 1 }, { b: 2 }], "missing")).toBeUndefined()
207
+ })
208
+ })
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ INSTAGRAM_SCRAPE_CREDIT_COSTS,
4
+ INSTAGRAM_SCRAPE_FALLBACK_CREDIT_ID,
5
+ INSTAGRAM_SCRAPE_MAX_SOURCES,
6
+ INSTAGRAM_SCRAPE_TIERS,
7
+ buildInstagramScrapeCreditId,
8
+ clampInstagramFeaturedIndex,
9
+ featuredInstagramOutputs,
10
+ instagramScrapeCreditIdFromNode,
11
+ instagramScrapeSources,
12
+ resolveInstagramScrapeCreditId,
13
+ splitInstagramTargets,
14
+ } from "../instagram-scrape.js"
15
+ import { resolveEffectiveSourceType } from "../entity-image-handle.js"
16
+
17
+ describe("instagram-scrape vocabulary", () => {
18
+ it("splitInstagramTargets: one per line / comma, strips @ and #, dedupes, caps at MAX_SOURCES", () => {
19
+ expect(splitInstagramTargets("@nike\n#running, adidas")).toEqual(["nike", "running", "adidas"])
20
+ expect(splitInstagramTargets("nike\nNIKE")).toEqual(["nike"])
21
+ expect(splitInstagramTargets([" puma ", "", "@vans"])).toEqual(["puma", "vans"])
22
+ expect(splitInstagramTargets(Array.from({ length: 9 }, (_, i) => `b${i}`))).toHaveLength(INSTAGRAM_SCRAPE_MAX_SOURCES)
23
+ expect(splitInstagramTargets(undefined)).toEqual([])
24
+ })
25
+
26
+ it("credit ids: 1 credit per requested post, tiered on count × sources, analysis folds in", () => {
27
+ expect(buildInstagramScrapeCreditId({ count: 20, sources: 1 })).toBe("instagram-scrape:20")
28
+ expect(buildInstagramScrapeCreditId({ count: 30, sources: 2 })).toBe("instagram-scrape:100")
29
+ expect(buildInstagramScrapeCreditId({ count: 20, sources: 1, analysis: "economy" })).toBe("instagram-scrape:20:analysis:economy")
30
+ expect(buildInstagramScrapeCreditId({ count: 50, sources: 1, analysis: "standard" })).toBe("instagram-scrape:50:analysis")
31
+ for (const tier of INSTAGRAM_SCRAPE_TIERS) {
32
+ expect(INSTAGRAM_SCRAPE_CREDIT_COSTS[`instagram-scrape:${tier}`]).toBe(tier)
33
+ expect(INSTAGRAM_SCRAPE_CREDIT_COSTS[`instagram-scrape:${tier}:analysis:economy`]).toBe(tier * 2)
34
+ }
35
+ expect(INSTAGRAM_SCRAPE_CREDIT_COSTS[INSTAGRAM_SCRAPE_FALLBACK_CREDIT_ID]).toBeDefined()
36
+ })
37
+
38
+ it("the node quote and the pre-Zod guard land on the SAME identifier", () => {
39
+ const data = { mode: "profile", targets: "nike\nadidas", count: 30, analyze: true }
40
+ expect(instagramScrapeSources(data)).toBe(2)
41
+ expect(instagramScrapeCreditIdFromNode(data)).toBe("instagram-scrape:100:analysis:economy")
42
+ // The wire body the node sends (targets already split to an array).
43
+ expect(resolveInstagramScrapeCreditId({ targets: ["nike", "adidas"], count: 30, analyze: true })).toBe("instagram-scrape:100:analysis:economy")
44
+ expect(resolveInstagramScrapeCreditId({})).toBe("instagram-scrape:20") // omitted count = default, 1 source
45
+ })
46
+
47
+ it("featured outputs: caption / first image / first video, clamped", () => {
48
+ const posts = [
49
+ { caption: "hi", images: ["https://i/1.jpg"], videos: [], videoPreviews: [] },
50
+ { caption: "vid", images: [], videos: ["https://v/2.mp4"], videoPreviews: ["https://v/2.jpg"] },
51
+ ]
52
+ expect(featuredInstagramOutputs(posts, 0)).toEqual({ text: "hi", imageUrl: "https://i/1.jpg" })
53
+ expect(featuredInstagramOutputs(posts, 1)).toEqual({ text: "vid", imageUrl: "https://v/2.jpg", videoUrl: "https://v/2.mp4" })
54
+ expect(featuredInstagramOutputs(posts, 9)).toEqual(featuredInstagramOutputs(posts, 1))
55
+ expect(featuredInstagramOutputs([], 0)).toEqual({})
56
+ expect(clampInstagramFeaturedIndex(-3, 2)).toBe(0)
57
+ })
58
+
59
+ it("typed handles resolve as the canonical single-media producers on canvas; json keeps the raw type", () => {
60
+ expect(resolveEffectiveSourceType("instagram-scrape", "text")).toBe("combine-text")
61
+ expect(resolveEffectiveSourceType("instagram-scrape", "image")).toBe("upload-image")
62
+ expect(resolveEffectiveSourceType("instagram-scrape", "video")).toBe("upload-video")
63
+ expect(resolveEffectiveSourceType("instagram-scrape", "json")).toBe("instagram-scrape")
64
+ expect(resolveEffectiveSourceType("instagram-scrape", "audio")).toBe("instagram-scrape")
65
+ })
66
+ })
@@ -18,6 +18,8 @@ import {
18
18
  LLM_VENDOR_LABELS,
19
19
  groupLlmModelsByVendor,
20
20
  orderedLlmModels,
21
+ REASONING_OUTPUT_FLOOR,
22
+ reasoningOutputFloor,
21
23
  } from "../llm-models.js"
22
24
  import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
23
25
  import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
@@ -388,6 +390,7 @@ describe("LLM_FEATURE_DEFAULTS", () => {
388
390
  "lottie-overlay",
389
391
  "3d-title",
390
392
  "image-to-text",
393
+ "meta-ads-analysis",
391
394
  "describe-to-picker",
392
395
  "qa-check",
393
396
  "generate-script",
@@ -399,8 +402,8 @@ describe("LLM_FEATURE_DEFAULTS", () => {
399
402
  "3d-scene",
400
403
  ]
401
404
 
402
- it("has entries for all 18 features", () => {
403
- expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(18)
405
+ it("has entries for all 19 features", () => {
406
+ expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(19)
404
407
  for (const feature of ALL_FEATURES) {
405
408
  expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
406
409
  }
@@ -577,6 +580,38 @@ describe("reasoning effort registry", () => {
577
580
  }
578
581
  })
579
582
 
583
+ it("every Gemini 3 model reasons by default and is floored within what its lanes take (#1588)", () => {
584
+ // Measured on 3.6 (#1588): with no thinking param sent, the direct lane
585
+ // spent ~1,060 of a node's 1,100-token cap reasoning and returned 120
586
+ // characters. Unflagged, the node's small cap reaches the wire untouched.
587
+ // The floor is the model's own, never the 32768 default: it rides the KIE
588
+ // endpoint too, and the flash ones are only known to take 8192.
589
+ const expected: Record<string, number> = {
590
+ "gemini-3-flash": 8192,
591
+ "gemini-3.6-flash": 8192,
592
+ "gemini-3.7-flash": 8192,
593
+ "gemini-3.8-flash": 16384,
594
+ "gemini-3.1-pro": 16384,
595
+ }
596
+ for (const [id, floor] of Object.entries(expected)) {
597
+ const m = getLlmModel(id)!
598
+ expect(m.thinkingDefaultOn, id).toBe(true)
599
+ expect(reasoningOutputFloor(m), id).toBe(floor)
600
+ expect(reasoningOutputFloor(m), `${id} floor above its KIE-safe cap`).toBeLessThanOrEqual(m.maxOutputTokens)
601
+ }
602
+ })
603
+
604
+ it("a reasoning floor never sits below the model's default cap, and is declared only below the default", () => {
605
+ for (const m of LLM_MODELS) {
606
+ // A floor under maxOutputTokens would LOWER a call's cap — not a floor.
607
+ expect(reasoningOutputFloor(m), m.id).toBeGreaterThanOrEqual(m.maxOutputTokens)
608
+ // Declaring the default is noise that hides which models are lane-limited.
609
+ if (m.reasoningOutputFloor !== undefined) {
610
+ expect(m.reasoningOutputFloor, m.id).toBeLessThan(REASONING_OUTPUT_FLOOR)
611
+ }
612
+ }
613
+ })
614
+
580
615
  it("gemini-3.6-flash exposes the KIE low/high thinking levels; claude-fable-5 the full Claude ladder", () => {
581
616
  expect(getLlmModel("gemini-3.6-flash")?.reasoningEfforts).toEqual(["low", "high"])
582
617
  expect(getLlmModel("claude-fable-5")?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"])
@@ -737,17 +772,19 @@ describe("gpt-6-astra exposure", () => {
737
772
  expect(buildLlmCreditIdentifier("llm-chat", "gpt-6-astra", "xhigh")).toBe("llm-chat:premium")
738
773
  })
739
774
 
740
- it("is the ONLY model declaring kieCollapseStream — the condition is per model, not per lane", () => {
775
+ it("names EXACTLY the models KIE's non-stream responses lane fails for — the condition is per model, not per lane", () => {
741
776
  // KIE's non-stream responses endpoint 500s ~2 calls in 3 for astra
742
- // (measured 2026-09-06: 2/6 non-stream vs 5/6 streaming) while serving
743
- // gpt-5.4/5.5/5.6 fine, so llm-client collapses the streaming wire for
744
- // THIS model only. If a second model ever legitimately needs it, add it
745
- // here deliberately — a flag that spreads by copy-paste would quietly move
746
- // every affected model's provider cost onto the rate-table estimate,
747
- // because SSE does not reliably carry `credits_consumed`.
777
+ // (measured 2026-09-06: 2/6 non-stream vs 5/6 streaming), and on
778
+ // 2026-09-17 a live gpt-5.6-sol call returned the same `server_error`,
779
+ // retiring the 2026-07-14 "the GPT-5.6 family is fine non-stream" reading.
780
+ // So llm-client collapses the streaming wire for THESE models only. Adding
781
+ // a third is a deliberate act, never copy-paste: the flag moves a model's
782
+ // provider cost onto the rate-table estimate, because SSE does not reliably
783
+ // carry `credits_consumed`.
784
+ const collapsed = LLM_MODELS.filter((m) => m.kieCollapseStream !== undefined).map((m) => m.id)
785
+ expect(collapsed.sort()).toEqual(["gpt-5.6-sol", "gpt-6-astra"])
748
786
  expect(getLlmModel("gpt-6-astra")?.kieCollapseStream).toBe(true)
749
- const others = LLM_MODELS.filter((m) => m.id !== "gpt-6-astra")
750
- expect(others.filter((m) => m.kieCollapseStream !== undefined).map((m) => m.id)).toEqual([])
787
+ expect(getLlmModel("gpt-5.6-sol")?.kieCollapseStream).toBe(true)
751
788
  })
752
789
  })
753
790