@nodaro/shared 2.23.0 → 2.26.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 +225 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +80 -1
- package/dist/index.d.ts +80 -1
- package/dist/index.js +223 -58
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +122 -3
- package/src/__tests__/studio-transient.test.ts +205 -0
- package/src/__tests__/video-analysis-catalog-sync.test.ts +1 -1
- package/src/__tests__/video-analysis-pricing.test.ts +6 -6
- package/src/index.ts +7 -0
- package/src/llm-models.ts +138 -4
- package/src/model-catalog.ts +30 -30
- package/src/studio-transient.ts +124 -0
- package/src/video-analysis-pricing.ts +30 -23
package/package.json
CHANGED
|
@@ -35,6 +35,7 @@ const EXPECTED_MODEL_IDS = [
|
|
|
35
35
|
"gemini-3-flash",
|
|
36
36
|
"gemini-3.6-flash",
|
|
37
37
|
"gemini-3.7-flash",
|
|
38
|
+
"gemini-3.8-flash",
|
|
38
39
|
"claude-haiku-4.5",
|
|
39
40
|
"claude-sonnet-4.6",
|
|
40
41
|
"gpt-5.2",
|
|
@@ -45,6 +46,7 @@ const EXPECTED_MODEL_IDS = [
|
|
|
45
46
|
"gpt-5.6-luna",
|
|
46
47
|
"gpt-5.6-terra",
|
|
47
48
|
"gpt-5.6-sol",
|
|
49
|
+
"gpt-6-astra",
|
|
48
50
|
"grok-4.6",
|
|
49
51
|
"claude-sonnet-5",
|
|
50
52
|
"claude-opus-4.8",
|
|
@@ -94,14 +96,14 @@ describe("LLM_MODELS data integrity", () => {
|
|
|
94
96
|
expect(new Set(ids).size).toBe(ids.length)
|
|
95
97
|
})
|
|
96
98
|
|
|
97
|
-
it("has
|
|
99
|
+
it("has 6 economy, 5 standard, 9 premium models", () => {
|
|
98
100
|
const tierCounts: Record<LlmTier, number> = { economy: 0, standard: 0, premium: 0 }
|
|
99
101
|
for (const model of LLM_MODELS) {
|
|
100
102
|
tierCounts[model.tier]++
|
|
101
103
|
}
|
|
102
|
-
expect(tierCounts.economy).toBe(
|
|
104
|
+
expect(tierCounts.economy).toBe(6)
|
|
103
105
|
expect(tierCounts.standard).toBe(5)
|
|
104
|
-
expect(tierCounts.premium).toBe(
|
|
106
|
+
expect(tierCounts.premium).toBe(9)
|
|
105
107
|
})
|
|
106
108
|
|
|
107
109
|
it("all three kieFormats are represented", () => {
|
|
@@ -483,6 +485,7 @@ describe("STRUCTURED_VISION_MODELS", () => {
|
|
|
483
485
|
"gemini-3-flash",
|
|
484
486
|
"gemini-3.6-flash",
|
|
485
487
|
"gemini-3.7-flash",
|
|
488
|
+
"gemini-3.8-flash",
|
|
486
489
|
"gemini-3.1-pro",
|
|
487
490
|
"claude-sonnet-5",
|
|
488
491
|
"claude-opus-4.8",
|
|
@@ -495,6 +498,9 @@ describe("STRUCTURED_VISION_MODELS", () => {
|
|
|
495
498
|
"gpt-5.6-luna",
|
|
496
499
|
"gpt-5.6-terra",
|
|
497
500
|
"gpt-5.6-sol",
|
|
501
|
+
// responses-format GPT-6 — text.format json_schema enforced (server
|
|
502
|
+
// echoed strict:true), live-verified 2026-09-06.
|
|
503
|
+
"gpt-6-astra",
|
|
498
504
|
// responses-format Grok — vision + text.format live-verified 2026-08-18.
|
|
499
505
|
"grok-4.6",
|
|
500
506
|
].sort(),
|
|
@@ -540,6 +546,8 @@ describe("reasoning effort registry", () => {
|
|
|
540
546
|
expect(getLlmTier("gpt-5.6-luna")).toBe("economy")
|
|
541
547
|
expect(getLlmTier("gpt-5.6-terra")).toBe("standard")
|
|
542
548
|
expect(getLlmTier("gpt-5.6-sol")).toBe("premium")
|
|
549
|
+
expect(getLlmTier("gpt-6-astra")).toBe("premium")
|
|
550
|
+
expect(getLlmTier("gemini-3.8-flash")).toBe("economy")
|
|
543
551
|
expect(getLlmTier("grok-4.6")).toBe("standard")
|
|
544
552
|
expect(getLlmTier("claude-sonnet-5")).toBe("standard")
|
|
545
553
|
expect(getLlmTier("claude-opus-4.8")).toBe("premium")
|
|
@@ -630,6 +638,117 @@ describe("gemini-3.7-flash exposure", () => {
|
|
|
630
638
|
})
|
|
631
639
|
})
|
|
632
640
|
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// gemini-3.8-flash exposure (KIE lane live-verified 2026-09-06: enforced
|
|
643
|
+
// response_format json_schema, reasoning_tokens in usage, and a 20k max_tokens
|
|
644
|
+
// request honored for 14,892 completion tokens with finish_reason "stop")
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
describe("gemini-3.8-flash exposure", () => {
|
|
647
|
+
it("KIE-first chat-completions with a direct lane — 3.7-flash's proven shape", () => {
|
|
648
|
+
const m = getLlmModel("gemini-3.8-flash")
|
|
649
|
+
expect(m?.tier).toBe("economy")
|
|
650
|
+
expect(m?.vendor).toBe("google")
|
|
651
|
+
expect(m?.kieFormat).toBe("chat-completions")
|
|
652
|
+
expect(m?.kieSlugOrModel).toBe("gemini-3-8-flash-openai")
|
|
653
|
+
// KIE-first on purpose (no preferDirect): the cheap lane serves the A/B,
|
|
654
|
+
// the direct lane is Advanced mode + the reliability fallback.
|
|
655
|
+
expect(m?.preferDirect).toBeUndefined()
|
|
656
|
+
expect(m?.directGeminiModel).toBe("gemini-3.8-flash")
|
|
657
|
+
expect(m?.reasoningEfforts).toEqual(["low", "high"])
|
|
658
|
+
expect(m?.structuredOutputMode).toBe("kie-response-format")
|
|
659
|
+
expect(supportsAdvancedMode("gemini-3.8-flash")).toBe(true)
|
|
660
|
+
expect(availableReasoningEfforts("gemini-3.8-flash")).toEqual(["low", "high"])
|
|
661
|
+
expect(availableReasoningEfforts("gemini-3.8-flash", true)).toEqual(["none", "low", "medium", "high"])
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it("carries a 16384 output cap — measured, not inherited from its 8192 siblings", () => {
|
|
665
|
+
// 3.6 / 3.7 sit at 8192 as the KIE-safe intersection because nobody
|
|
666
|
+
// measured their endpoints past it. 3.8's WAS measured (2026-09-06:
|
|
667
|
+
// max_tokens 20000 honored, 14,892 completion tokens, finish_reason
|
|
668
|
+
// "stop"), so it is not pinned to its siblings' unmeasured floor.
|
|
669
|
+
expect(getLlmModel("gemini-3.8-flash")?.maxOutputTokens).toBe(16384)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it("resolves by canonical id and by its KIE slug (cost reconciliation reads whatever the wire used)", () => {
|
|
673
|
+
expect(getLlmModel("gemini-3.8-flash")?.id).toBe("gemini-3.8-flash")
|
|
674
|
+
expect(getLlmModel("gemini-3-8-flash-openai")?.id).toBe("gemini-3.8-flash")
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it("stays OUT of video-analysis — image-only modality caps by decision, not omission", () => {
|
|
678
|
+
// Google's own 3.8 lane understands video (agentic video via the
|
|
679
|
+
// Interactions API), so full caps here would auto-enroll it in
|
|
680
|
+
// VIDEO_ANALYSIS_LLM_MODELS and force a VA tier + pricing decision that is
|
|
681
|
+
// deliberately deferred until the 3.8-vs-3.7 analysis A/B concludes.
|
|
682
|
+
// If this goes red, someone flipped the caps — only valid TOGETHER with
|
|
683
|
+
// that VA-side decision.
|
|
684
|
+
expect(getLlmModalityCaps("gemini-3.8-flash")).toEqual({ image: true, video: false, audio: false })
|
|
685
|
+
expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain("gemini-3.8-flash")
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// gpt-6-astra exposure (KIE responses lane live-verified 2026-09-06 on the
|
|
691
|
+
// streaming path: enforced text.format json_schema with strict:true, the
|
|
692
|
+
// documented effort enum, temperature silently ignored, reasoning on by default)
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
describe("gpt-6-astra exposure", () => {
|
|
695
|
+
it("responses-format OpenAI premium — the GPT-5.6 family's proven shape", () => {
|
|
696
|
+
const m = getLlmModel("gpt-6-astra")
|
|
697
|
+
expect(m?.tier).toBe("premium")
|
|
698
|
+
expect(m?.vendor).toBe("openai")
|
|
699
|
+
expect(m?.kieFormat).toBe("responses")
|
|
700
|
+
expect(m?.structuredOutputMode).toBe("responses-json-schema")
|
|
701
|
+
expect(m?.maxOutputTokens).toBe(16384)
|
|
702
|
+
expect(m?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"])
|
|
703
|
+
// Temperature is accepted-and-ignored by this endpoint (echo stayed at 1.0
|
|
704
|
+
// for a sent 0.2), so the registry must say "don't send it".
|
|
705
|
+
expect(m?.supportsTemperature).toBe(false)
|
|
706
|
+
// Reasons with NO reasoning param sent (server echoed effort "medium") —
|
|
707
|
+
// consumers floor max_tokens off this flag or the answer truncates.
|
|
708
|
+
expect(m?.thinkingDefaultOn).toBe(true)
|
|
709
|
+
// No direct lane: OpenAI models route through KIE only, so Advanced mode
|
|
710
|
+
// (a Gemini-lane capability) must stay off.
|
|
711
|
+
expect(supportsAdvancedMode("gpt-6-astra")).toBe(false)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
it("resolves from its own slug — the slug is the body `model`, the path comes from vendor", () => {
|
|
715
|
+
// llm-client derives codex/v1/responses from vendor "openai" (the same
|
|
716
|
+
// derivation that keeps Grok on grok/v1/responses), so kieSlugOrModel is
|
|
717
|
+
// sent as the request body's `model` and doubles as a lookup alias.
|
|
718
|
+
const m = getLlmModel("gpt-6-astra")
|
|
719
|
+
expect(m?.kieSlugOrModel).toBe("gpt-6-astra")
|
|
720
|
+
expect(getLlmModel(m!.kieSlugOrModel)?.id).toBe("gpt-6-astra")
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it("is an accepted structured-vision analyzer", () => {
|
|
724
|
+
expect(STRUCTURED_VISION_MODELS.map((m) => m.id)).toContain("gpt-6-astra")
|
|
725
|
+
expect(STRUCTURED_VISION_MODELS.map((m) => m.id)).toContain("gemini-3.8-flash")
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it("is image-only — KIE's GPT-6 doc lists text + image + file inputs, no video/audio", () => {
|
|
729
|
+
expect(getLlmModalityCaps("gpt-6-astra")).toEqual({ image: true, video: false, audio: false })
|
|
730
|
+
expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain("gpt-6-astra")
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it("bills premium, and xhigh cannot bump past it", () => {
|
|
734
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gpt-6-astra")).toBe("llm-chat:premium")
|
|
735
|
+
expect(buildLlmCreditIdentifier("llm-chat", "gpt-6-astra", "xhigh")).toBe("llm-chat:premium")
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it("is the ONLY model declaring kieCollapseStream — the condition is per model, not per lane", () => {
|
|
739
|
+
// KIE's non-stream responses endpoint 500s ~2 calls in 3 for astra
|
|
740
|
+
// (measured 2026-09-06: 2/6 non-stream vs 5/6 streaming) while serving
|
|
741
|
+
// gpt-5.4/5.5/5.6 fine, so llm-client collapses the streaming wire for
|
|
742
|
+
// THIS model only. If a second model ever legitimately needs it, add it
|
|
743
|
+
// here deliberately — a flag that spreads by copy-paste would quietly move
|
|
744
|
+
// every affected model's provider cost onto the rate-table estimate,
|
|
745
|
+
// because SSE does not reliably carry `credits_consumed`.
|
|
746
|
+
expect(getLlmModel("gpt-6-astra")?.kieCollapseStream).toBe(true)
|
|
747
|
+
const others = LLM_MODELS.filter((m) => m.id !== "gpt-6-astra")
|
|
748
|
+
expect(others.filter((m) => m.kieCollapseStream !== undefined).map((m) => m.id)).toEqual([])
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
633
752
|
describe("effectiveReasoningEffort", () => {
|
|
634
753
|
it("passes through a supported level", () => {
|
|
635
754
|
expect(effectiveReasoningEffort("claude-sonnet-5", "max")).toBe("max")
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
STUDIO_SHOT_TRANSIENT_KEYS,
|
|
5
|
+
STUDIO_TRANSIENT_KEYS,
|
|
6
|
+
stripStudioTransientSettings,
|
|
7
|
+
} from "../studio-transient.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The public projection of `settings.studio` (D12).
|
|
11
|
+
*
|
|
12
|
+
* A shared production is read by anyone holding the link, and the document
|
|
13
|
+
* carries the OWNER's working state beside the film: the recycle bin (every
|
|
14
|
+
* shot, still and clip they deleted, prompts and urls intact), the jobs in
|
|
15
|
+
* flight, and an unsaved editor draft.
|
|
16
|
+
*
|
|
17
|
+
* The fixture below is a real saved document, pinned here byte for byte, and
|
|
18
|
+
* the LEVEL is the whole finding this test exists for: the in-flight markers
|
|
19
|
+
* are written PER SHOT, inside `settings.studio.shots[]`, and a strip that only
|
|
20
|
+
* walks the top level hands a viewer all of them while a fixture shaped to the
|
|
21
|
+
* top level says it does not.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PENDING = {
|
|
25
|
+
jobId: "job-2",
|
|
26
|
+
provider: "seedance-2",
|
|
27
|
+
prompt: "a slow dolly in",
|
|
28
|
+
startedAt: 1_756_000_000_000,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TRASHED = {
|
|
32
|
+
kind: "still",
|
|
33
|
+
id: "trash-1",
|
|
34
|
+
shotId: "s1",
|
|
35
|
+
index: 0,
|
|
36
|
+
deletedAt: "2026-09-01T10:00:00.000Z",
|
|
37
|
+
stillBase: { nodeId: "img-1", provider: "flux-2", prompt: "a lighthouse at dawn" },
|
|
38
|
+
result: { url: "https://cdn/deleted.png" },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A saved document carrying every marker its writer can write. */
|
|
42
|
+
function written(): Record<string, unknown> {
|
|
43
|
+
return {
|
|
44
|
+
studio: {
|
|
45
|
+
version: 3,
|
|
46
|
+
shots: [
|
|
47
|
+
{
|
|
48
|
+
id: "s1",
|
|
49
|
+
imageNodeId: "img-1",
|
|
50
|
+
stillProvider: "flux-2",
|
|
51
|
+
pendingClips: [{ ...PENDING }],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
selectedShotId: "s1",
|
|
55
|
+
shotOrder: ["img-1"],
|
|
56
|
+
shared: true,
|
|
57
|
+
freecutDraftUrl: "https://cdn/draft.json",
|
|
58
|
+
trash: [{ ...TRASHED }],
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `settings.studio` of a stripped document. */
|
|
64
|
+
function studioOf(settings: unknown): Record<string, unknown> {
|
|
65
|
+
return (settings as { studio: Record<string, unknown> }).studio
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("stripStudioTransientSettings", () => {
|
|
69
|
+
it("drops the bin and the draft the writer put at the top level", () => {
|
|
70
|
+
const settings = written()
|
|
71
|
+
// The oracle: the document really does carry these two here.
|
|
72
|
+
expect(studioOf(settings).trash).toHaveLength(1)
|
|
73
|
+
expect(studioOf(settings).freecutDraftUrl).toBe("https://cdn/draft.json")
|
|
74
|
+
|
|
75
|
+
const studio = studioOf(stripStudioTransientSettings(settings))
|
|
76
|
+
expect(studio.trash).toBeUndefined()
|
|
77
|
+
expect(studio.freecutDraftUrl).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("drops the in-flight markers the writer put PER SHOT", () => {
|
|
81
|
+
const settings = written()
|
|
82
|
+
// The oracle again, and the whole point: `pendingClips` is a SHOT's key.
|
|
83
|
+
const stored = (studioOf(settings).shots as Array<Record<string, unknown>>)[0]
|
|
84
|
+
expect(stored.pendingClips).toEqual([PENDING])
|
|
85
|
+
|
|
86
|
+
const shots = studioOf(stripStudioTransientSettings(settings)).shots as Array<
|
|
87
|
+
Record<string, unknown>
|
|
88
|
+
>
|
|
89
|
+
expect(shots[0].pendingClips).toBeUndefined()
|
|
90
|
+
// ...and the shot itself survives, film intact.
|
|
91
|
+
expect(shots[0].id).toBe("s1")
|
|
92
|
+
expect(shots[0].imageNodeId).toBe("img-1")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("leaves the film — the shots, the order, the share flag — untouched", () => {
|
|
96
|
+
const studio = studioOf(stripStudioTransientSettings(written()))
|
|
97
|
+
expect(studio.version).toBe(3)
|
|
98
|
+
expect(studio.shotOrder).toEqual(["img-1"])
|
|
99
|
+
expect(studio.shared).toBe(true)
|
|
100
|
+
expect(studio.selectedShotId).toBe("s1")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("never mutates the caller's document", () => {
|
|
104
|
+
const settings = written()
|
|
105
|
+
const before = JSON.stringify(settings)
|
|
106
|
+
stripStudioTransientSettings(settings)
|
|
107
|
+
expect(JSON.stringify(settings)).toBe(before)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("hands back the very same object when there is nothing to strip", () => {
|
|
111
|
+
// An ordinary share read of an ordinary production allocates nothing.
|
|
112
|
+
const settings: Record<string, unknown> = {
|
|
113
|
+
studio: { version: 3, shots: [{ id: "s1" }], selectedShotId: "s1", shotOrder: [] },
|
|
114
|
+
}
|
|
115
|
+
expect(stripStudioTransientSettings(settings)).toBe(settings)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("drops a shot's pendingStills — the still marker D5 lands there", () => {
|
|
119
|
+
// `pendingStills` is additive: the generation routes write it onto the same
|
|
120
|
+
// shot entry `pendingClips` rides on, so the strip has to know the key
|
|
121
|
+
// before its writer exists — otherwise the first framing batch in flight
|
|
122
|
+
// ships to every share viewer.
|
|
123
|
+
const settings = written()
|
|
124
|
+
const studio = studioOf(settings)
|
|
125
|
+
const shots = (studio.shots as Array<Record<string, unknown>>).map((s) => ({
|
|
126
|
+
...s,
|
|
127
|
+
pendingStills: [{ jobId: "job-1", batchId: "batch-1", count: 4 }],
|
|
128
|
+
}))
|
|
129
|
+
const withStills = { ...settings, studio: { ...studio, shots } }
|
|
130
|
+
|
|
131
|
+
const out = studioOf(stripStudioTransientSettings(withStills))
|
|
132
|
+
expect((out.shots as Array<Record<string, unknown>>)[0].pendingStills).toBeUndefined()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("drops a legacy single `pendingClip` too", () => {
|
|
136
|
+
// Pre-concurrent-markers saves wrote one marker under the singular key; the
|
|
137
|
+
// reader still migrates it, so it is still in-flight state a viewer must
|
|
138
|
+
// not receive.
|
|
139
|
+
const settings = written()
|
|
140
|
+
const studio = studioOf(settings)
|
|
141
|
+
const shots = [{ id: "s2", pendingClip: PENDING }]
|
|
142
|
+
const legacy = { ...settings, studio: { ...studio, shots } }
|
|
143
|
+
|
|
144
|
+
const out = studioOf(stripStudioTransientSettings(legacy))
|
|
145
|
+
expect((out.shots as Array<Record<string, unknown>>)[0]).toEqual({ id: "s2" })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("leaves a workflow that is not a production alone", () => {
|
|
149
|
+
const settings = { presentationSettings: { shareReadOnly: true } }
|
|
150
|
+
expect(stripStudioTransientSettings(settings)).toBe(settings)
|
|
151
|
+
expect(stripStudioTransientSettings(null)).toBeNull()
|
|
152
|
+
expect(stripStudioTransientSettings(undefined)).toBeUndefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("survives a document whose shots are not what the editor writes", () => {
|
|
156
|
+
// The strip runs on whatever is in the column, including a row written by
|
|
157
|
+
// something that is not the studio editor. It must project, never throw.
|
|
158
|
+
const odd = { studio: { version: 3, shots: ["nonsense", null, 7] } }
|
|
159
|
+
expect(() => stripStudioTransientSettings(odd)).not.toThrow()
|
|
160
|
+
expect(studioOf(stripStudioTransientSettings(odd)).shots).toEqual(["nonsense", null, 7])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("drops the DOCUMENT's own pendingMusic and pendingDraft (D5)", () => {
|
|
164
|
+
// The document-level twin of the per-shot pair: a soundtrack render and a
|
|
165
|
+
// Director run in flight. Same rule — they name jobs on the owner's
|
|
166
|
+
// account, and no viewer can read or land one.
|
|
167
|
+
const settings = written()
|
|
168
|
+
const studio = studioOf(settings)
|
|
169
|
+
const inFlight = {
|
|
170
|
+
...settings,
|
|
171
|
+
studio: {
|
|
172
|
+
...studio,
|
|
173
|
+
pendingMusic: { jobId: "job-3", startedAt: 1_756_000_000_000 },
|
|
174
|
+
pendingDraft: { jobId: "job-4", startedAt: 1_756_000_000_000 },
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
// The oracle: the document really does carry both before the public read.
|
|
178
|
+
expect(studioOf(inFlight).pendingMusic).toBeDefined()
|
|
179
|
+
expect(studioOf(inFlight).pendingDraft).toBeDefined()
|
|
180
|
+
|
|
181
|
+
const out = studioOf(stripStudioTransientSettings(inFlight))
|
|
182
|
+
expect(out.pendingMusic).toBeUndefined()
|
|
183
|
+
expect(out.pendingDraft).toBeUndefined()
|
|
184
|
+
// ...and the film is still there.
|
|
185
|
+
expect(out.version).toBe(3)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("pins the two lists — a key added to the type alone strips nothing", () => {
|
|
189
|
+
// The lists are the contract: the codec's own strip re-exports them, so a
|
|
190
|
+
// key that falls off here falls off there too, silently, on both sides.
|
|
191
|
+
expect([...STUDIO_TRANSIENT_KEYS]).toEqual([
|
|
192
|
+
"trash",
|
|
193
|
+
"pendingStills",
|
|
194
|
+
"pendingClips",
|
|
195
|
+
"pendingMusic",
|
|
196
|
+
"pendingDraft",
|
|
197
|
+
"freecutDraftUrl",
|
|
198
|
+
])
|
|
199
|
+
expect([...STUDIO_SHOT_TRANSIENT_KEYS]).toEqual([
|
|
200
|
+
"pendingClips",
|
|
201
|
+
"pendingClip",
|
|
202
|
+
"pendingStills",
|
|
203
|
+
])
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -164,7 +164,7 @@ describe("bare video-analysis node-type credit id", () => {
|
|
|
164
164
|
// that prompt again, moving the pin 8_482 -> 8_706, and 357 wrote 2076:
|
|
165
165
|
// the transition vocabulary v2 doctrine (twelve named values; absent =
|
|
166
166
|
// nothing asserted) grew it once more, moving the pin 8_706 -> 9_082.
|
|
167
|
-
expect(ceiling).toBe(
|
|
167
|
+
expect(ceiling).toBe(2081)
|
|
168
168
|
})
|
|
169
169
|
|
|
170
170
|
it("the bare id still bounds the default tier at the ceiling bucket", () => {
|
|
@@ -180,10 +180,10 @@ describe("video-audit-pricing", () => {
|
|
|
180
180
|
it("videoAuditCreditsForBucket: table lookup by resolved bucket + family, snapping a raw duration onto the ladder", () => {
|
|
181
181
|
expect(videoAuditCreditsForBucket(60, false)).toBe(215)
|
|
182
182
|
expect(videoAuditCreditsForBucket(60, true)).toBe(396)
|
|
183
|
-
expect(videoAuditCreditsForBucket(180, false)).toBe(
|
|
184
|
-
expect(videoAuditCreditsForBucket(360, false)).toBe(
|
|
185
|
-
expect(videoAuditCreditsForBucket(600, false)).toBe(
|
|
186
|
-
expect(videoAuditCreditsForBucket(600, true)).toBe(
|
|
183
|
+
expect(videoAuditCreditsForBucket(180, false)).toBe(291)
|
|
184
|
+
expect(videoAuditCreditsForBucket(360, false)).toBe(664)
|
|
185
|
+
expect(videoAuditCreditsForBucket(600, false)).toBe(1075)
|
|
186
|
+
expect(videoAuditCreditsForBucket(600, true)).toBe(1924)
|
|
187
187
|
// Not just exact ladder values — a raw duration snaps up to its bucket.
|
|
188
188
|
expect(videoAuditCreditsForBucket(70, false)).toBe(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:180s"])
|
|
189
189
|
expect(videoAuditCreditsForBucket(9999, true)).toBe(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"])
|
|
@@ -224,8 +224,8 @@ describe("video-audit-pricing", () => {
|
|
|
224
224
|
// 600s bucket here, same convention as VIDEO_ANALYSIS_BUCKET_CREDITS).
|
|
225
225
|
expect(Object.keys(VIDEO_AUDIT_BUCKET_CREDITS)).toHaveLength(8)
|
|
226
226
|
// Bare-id values quoted in the task/catalog must equal each family's 600s ceiling.
|
|
227
|
-
expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:600s"]).toBe(
|
|
228
|
-
expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"]).toBe(
|
|
227
|
+
expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:600s"]).toBe(1075)
|
|
228
|
+
expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"]).toBe(1924)
|
|
229
229
|
})
|
|
230
230
|
|
|
231
231
|
it("auto family = base family + the gemini-3-flash (legacy fast tier) row at the same bucket, exactly — single-source, never hand-added", () => {
|
package/src/index.ts
CHANGED
|
@@ -1051,3 +1051,10 @@ export {
|
|
|
1051
1051
|
} from "./entity-node-fields.js"
|
|
1052
1052
|
export { ENTITY_NODE_KINDS } from "./entity-node-fields.js"
|
|
1053
1053
|
export type { EntityNodeKind } from "./entity-node-fields.js"
|
|
1054
|
+
|
|
1055
|
+
// --- transient studio keys — the public share read strips them ---
|
|
1056
|
+
export {
|
|
1057
|
+
STUDIO_TRANSIENT_KEYS,
|
|
1058
|
+
STUDIO_SHOT_TRANSIENT_KEYS,
|
|
1059
|
+
stripStudioTransientSettings,
|
|
1060
|
+
} from "./studio-transient.js"
|
package/src/llm-models.ts
CHANGED
|
@@ -94,6 +94,22 @@ export interface LlmModelDef {
|
|
|
94
94
|
supportsTemperature?: false
|
|
95
95
|
/** Claude-only: KIE is the preferred routing, direct Anthropic the fallback. */
|
|
96
96
|
preferKie?: true
|
|
97
|
+
/**
|
|
98
|
+
* KIE's NON-streaming endpoint for this model is unreliable (measured);
|
|
99
|
+
* `llmComplete` serves it by opening the streaming wire and collapsing it to
|
|
100
|
+
* one response. Streaming responses do not reliably carry
|
|
101
|
+
* `credits_consumed`, so provider cost on this path comes from the rate
|
|
102
|
+
* table (`backend/src/lib/pricing/llm-cost.ts`) rather than the real charge.
|
|
103
|
+
*
|
|
104
|
+
* Declared per model — a per-model condition on KIE's side, like the
|
|
105
|
+
* Claude-lane one behind `callKieMessagesCollapsed`. The flag says WHICH
|
|
106
|
+
* model is affected, and `llm-client` reads it instead of matching model
|
|
107
|
+
* names. Today only the `responses` dispatcher in `callKie` honours it (the
|
|
108
|
+
* one format where the condition has been measured); a chat-completions or
|
|
109
|
+
* messages model that needs the same treatment must also teach its `callKie`
|
|
110
|
+
* case to read the flag — declaring it alone changes nothing there.
|
|
111
|
+
*/
|
|
112
|
+
kieCollapseStream?: true
|
|
97
113
|
/**
|
|
98
114
|
* true = the model reasons even when NO thinking parameter is sent, so its
|
|
99
115
|
* reasoning tokens share the `max_tokens` budget on EVERY call — not just
|
|
@@ -133,7 +149,9 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
133
149
|
{
|
|
134
150
|
id: "gemini-3.6-flash",
|
|
135
151
|
displayName: "Gemini 3.6 Flash",
|
|
136
|
-
|
|
152
|
+
// Demoted 2026-09-06 alongside 3.7: superlatives belong to the CURRENT top
|
|
153
|
+
// model of a family only, and 3.8 now holds that slot.
|
|
154
|
+
desc: "Fast Gemini, sharper reasoning",
|
|
137
155
|
tier: "economy",
|
|
138
156
|
kieFormat: "chat-completions",
|
|
139
157
|
// KIE serves Gemini 3.6 Flash on the OpenAI-compatible dialect under this
|
|
@@ -163,7 +181,11 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
163
181
|
{
|
|
164
182
|
id: "gemini-3.7-flash",
|
|
165
183
|
displayName: "Gemini 3.7 Flash",
|
|
166
|
-
|
|
184
|
+
// Demoted 2026-09-06 when 3.8 registered below: superlatives belong to the
|
|
185
|
+
// CURRENT top model of a family only (same rule the Opus entries follow) —
|
|
186
|
+
// `desc` renders in every picker, so leaving "Newest" on the older flash
|
|
187
|
+
// steers the A/B's traffic backwards.
|
|
188
|
+
desc: "Fast Gemini, agentic-tuned",
|
|
167
189
|
tier: "economy",
|
|
168
190
|
kieFormat: "chat-completions",
|
|
169
191
|
// KIE serves it on the OpenAI-compatible dialect under this slug
|
|
@@ -184,6 +206,46 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
184
206
|
directReasoningEfforts: ["none", "low", "medium", "high"],
|
|
185
207
|
directGeminiModel: "gemini-3.7-flash",
|
|
186
208
|
},
|
|
209
|
+
{
|
|
210
|
+
id: "gemini-3.8-flash",
|
|
211
|
+
displayName: "Gemini 3.8 Flash",
|
|
212
|
+
// Inherits "Newest" from 3.7 (demoted above). Deliberately does NOT name
|
|
213
|
+
// agentic video: that capability exists only on the direct Google lane
|
|
214
|
+
// (Interactions API, not wired here) and the modality caps below withhold
|
|
215
|
+
// video — a picker desc must not promise a lever this entry cannot expose.
|
|
216
|
+
desc: "Newest fast Gemini, agentic-tuned",
|
|
217
|
+
tier: "economy",
|
|
218
|
+
kieFormat: "chat-completions",
|
|
219
|
+
// KIE serves it on the OpenAI-compatible dialect under this slug
|
|
220
|
+
// (docs.kie.ai/market/gemini/gemini-3-8-flash-openai.md) — identical
|
|
221
|
+
// chat-completions path shape to gemini-3.7-flash / gemini-3.6-flash,
|
|
222
|
+
// different slug prefix.
|
|
223
|
+
kieSlugOrModel: "gemini-3-8-flash-openai",
|
|
224
|
+
vendor: "google",
|
|
225
|
+
// Live-verified 2026-09-06 on the KIE lane: `response_format: json_schema`
|
|
226
|
+
// is ENFORCED (the reply came back exact-schema valid, credits_consumed
|
|
227
|
+
// present) — not merely accepted-and-ignored.
|
|
228
|
+
structuredOutputMode: "kie-response-format",
|
|
229
|
+
supportsImages: true,
|
|
230
|
+
// 16384 here, NOT the 8192 its 3.6 / 3.7 siblings sit at. Those two stay at
|
|
231
|
+
// the KIE-safe intersection because nobody measured their endpoints past
|
|
232
|
+
// it; 3.8's WAS measured — live-verified 2026-09-06, `max_tokens: 20000`
|
|
233
|
+
// was honored for 14,892 completion tokens with finish_reason "stop" (no
|
|
234
|
+
// truncation), so the KIE lane is not the binding constraint. 16384 keeps
|
|
235
|
+
// it level with the rest of the registry's ceiling rather than at the cap.
|
|
236
|
+
maxOutputTokens: 16384,
|
|
237
|
+
// KIE's 3.8 endpoint enumerates reasoning_effort low | high (its doc enum),
|
|
238
|
+
// and usage carries completion_tokens_details.reasoning_tokens — same
|
|
239
|
+
// KIE-safe intersection as 3.6 / 3.7, because ONE field feeds both lanes
|
|
240
|
+
// and this model is KIE-first.
|
|
241
|
+
reasoningEfforts: ["low", "high"],
|
|
242
|
+
// Google's own API accepts the full minimal→high ladder here (`none` maps
|
|
243
|
+
// to `minimal`), same as 3.7. Advanced mode is what unlocks it.
|
|
244
|
+
directReasoningEfforts: ["none", "low", "medium", "high"],
|
|
245
|
+
// KIE-first (no `preferDirect`) — 3.7's posture exactly: the cheap lane
|
|
246
|
+
// serves the A/B, direct is Advanced mode + the reliability fallback.
|
|
247
|
+
directGeminiModel: "gemini-3.8-flash",
|
|
248
|
+
},
|
|
187
249
|
{
|
|
188
250
|
id: "claude-haiku-4.5",
|
|
189
251
|
displayName: "Claude Haiku 4.5",
|
|
@@ -280,7 +342,9 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
280
342
|
{
|
|
281
343
|
id: "gpt-5.4",
|
|
282
344
|
displayName: "GPT-5.4",
|
|
283
|
-
|
|
345
|
+
// Demoted 2026-09-06: two GPT generations have shipped above it, so
|
|
346
|
+
// "Latest" steered the picker at the oldest premium GPT in the registry.
|
|
347
|
+
desc: "Older GPT, premium quality",
|
|
284
348
|
tier: "premium",
|
|
285
349
|
kieFormat: "responses",
|
|
286
350
|
kieSlugOrModel: "gpt-5-4",
|
|
@@ -335,7 +399,11 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
335
399
|
{
|
|
336
400
|
id: "gpt-5.6-sol",
|
|
337
401
|
displayName: "GPT-5.6 Sol",
|
|
338
|
-
|
|
402
|
+
// Demoted 2026-09-06 when gpt-6-astra registered below — "Flagship /
|
|
403
|
+
// deepest" is the current top GPT's copy only. Worded to stay distinct
|
|
404
|
+
// from gpt-5.5's "Previous flagship GPT, deep reasoning": all three read
|
|
405
|
+
// premium on the invoice, so a stale superlative mis-steers silently.
|
|
406
|
+
desc: "Previous flagship GPT-5.6, deep reasoning",
|
|
339
407
|
tier: "premium",
|
|
340
408
|
kieFormat: "responses",
|
|
341
409
|
kieSlugOrModel: "gpt-5-6-sol",
|
|
@@ -346,6 +414,61 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
|
|
|
346
414
|
reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
|
|
347
415
|
supportsTemperature: false,
|
|
348
416
|
},
|
|
417
|
+
{
|
|
418
|
+
id: "gpt-6-astra",
|
|
419
|
+
displayName: "GPT-6 Astra",
|
|
420
|
+
desc: "Flagship GPT-6, deepest reasoning",
|
|
421
|
+
tier: "premium",
|
|
422
|
+
kieFormat: "responses",
|
|
423
|
+
// KIE serves GPT-6 on the responses dialect under the OpenAI family path —
|
|
424
|
+
// codex/v1/responses, which llm-client DERIVES from `vendor` (the same
|
|
425
|
+
// derivation that keeps Grok on grok/v1/responses). So this field is the
|
|
426
|
+
// body `model` only, not a path segment. Live-verified 2026-09-06 on the
|
|
427
|
+
// streaming path (array `input`, SSE deltas), which is the ONLY path we
|
|
428
|
+
// serve it on — see `kieCollapseStream` below. The non-stream reply does
|
|
429
|
+
// carry `credits_consumed` (and cached_tokens/cache_write_tokens in usage)
|
|
430
|
+
// on the ~1 call in 3 that returns one, but we no longer take that reply,
|
|
431
|
+
// so provider cost here is the rate-table estimate.
|
|
432
|
+
// KIE's codex/v1/responses injects its own Codex-agent `instructions`
|
|
433
|
+
// prompt ABOVE the caller's `developer` message — observed in the
|
|
434
|
+
// 2026-07-14 live probe of that endpoint, and unchanged for GPT-6. Budget
|
|
435
|
+
// for it when a developer message has to dominate.
|
|
436
|
+
kieSlugOrModel: "gpt-6-astra",
|
|
437
|
+
vendor: "openai",
|
|
438
|
+
// Live-verified 2026-09-06: `text.format: json_schema` is passed through
|
|
439
|
+
// and ENFORCED — the server echoed `strict: true` and returned
|
|
440
|
+
// schema-valid JSON.
|
|
441
|
+
structuredOutputMode: "responses-json-schema",
|
|
442
|
+
// text + image + file inputs per KIE's doc. No video/audio — see the
|
|
443
|
+
// image-only LLM_MODALITY_CAPS row below.
|
|
444
|
+
supportsImages: true,
|
|
445
|
+
maxOutputTokens: 16384,
|
|
446
|
+
// KIE's documented enum for this endpoint. No `none` on purpose: the
|
|
447
|
+
// endpoint reasons unconditionally (see thinkingDefaultOn below), so a
|
|
448
|
+
// "none" level would be a lie the wire silently overrides.
|
|
449
|
+
reasoningEfforts: ["low", "medium", "high", "xhigh"],
|
|
450
|
+
// Live-probed 2026-09-06: `temperature: 0.2` was sent and the request echo
|
|
451
|
+
// stayed at 1.0 — silently IGNORED, so never send it. Same treatment as the
|
|
452
|
+
// GPT-5.6 family and grok-4.6.
|
|
453
|
+
supportsTemperature: false,
|
|
454
|
+
// With NO reasoning param sent, the server echoed effort "medium" — it
|
|
455
|
+
// reasons by default (2026-09-06), so reasoning tokens share `max_tokens`
|
|
456
|
+
// on EVERY call, not just effort-bearing ones. Consumers must floor output
|
|
457
|
+
// headroom off this flag or a premium answer truncates into a paid-for
|
|
458
|
+
// empty reply.
|
|
459
|
+
thinkingDefaultOn: true,
|
|
460
|
+
// Measured 2026-09-06, 12 identical requests (developer + user message,
|
|
461
|
+
// reasoning.effort low, text.format json_schema): `stream: false`
|
|
462
|
+
// succeeded 2/6 in 4–5 s and 500'd 4/6 with
|
|
463
|
+
// {"error":{"type":"server_error"}} after 34, 34, 35 and 64 s, while the
|
|
464
|
+
// same body with `stream: true` succeeded 5/6 in the same 4–5 s. An
|
|
465
|
+
// earlier non-stream probe timed out at 90 s with 0 bytes, and a
|
|
466
|
+
// non-stream call WITHOUT a schema 500'd after 65 s — the schema is not
|
|
467
|
+
// the trigger, the non-stream lane is. The very same endpoint serves
|
|
468
|
+
// gpt-5.4/5.5/5.6 non-stream reliably (live-verified 2026-07-14), which is
|
|
469
|
+
// why this is per-model and not a lane-wide flag.
|
|
470
|
+
kieCollapseStream: true,
|
|
471
|
+
},
|
|
349
472
|
{
|
|
350
473
|
id: "grok-4.6",
|
|
351
474
|
displayName: "Grok 4.6",
|
|
@@ -577,6 +700,15 @@ export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean;
|
|
|
577
700
|
// deferred while the smart-family A/B routes this model internally (#747).
|
|
578
701
|
// Flip these two flags ONLY together with that VA-side decision.
|
|
579
702
|
"gemini-3.7-flash": { image: true, video: false, audio: false },
|
|
703
|
+
// gemini-3.8-flash is IMAGE-ONLY by DECISION, not omission — 3.7's rationale
|
|
704
|
+
// exactly: full video+audio caps would auto-enroll it in
|
|
705
|
+
// VIDEO_ANALYSIS_LLM_MODELS (derived below) and force a video-analysis tier +
|
|
706
|
+
// pricing decision that stays deferred until the 3.8-vs-3.7 analysis A/B
|
|
707
|
+
// concludes. Google's own 3.8 lane DOES understand video (agentic video, via
|
|
708
|
+
// the Interactions API we don't wire) — which is precisely why withholding it
|
|
709
|
+
// has to be a decision rather than a gap. Flip these two flags ONLY together
|
|
710
|
+
// with that VA-side decision.
|
|
711
|
+
"gemini-3.8-flash": { image: true, video: false, audio: false },
|
|
580
712
|
"gemini-3.1-pro": { image: true, video: true, audio: true },
|
|
581
713
|
"claude-haiku-4.5": { image: true, video: false, audio: false },
|
|
582
714
|
"claude-sonnet-4.6": { image: true, video: false, audio: false },
|
|
@@ -587,6 +719,8 @@ export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean;
|
|
|
587
719
|
"gpt-5.6-luna": { image: true, video: false, audio: false },
|
|
588
720
|
"gpt-5.6-terra": { image: true, video: false, audio: false },
|
|
589
721
|
"gpt-5.6-sol": { image: true, video: false, audio: false },
|
|
722
|
+
// KIE's GPT-6 doc lists text + image + file inputs only — no video, no audio.
|
|
723
|
+
"gpt-6-astra": { image: true, video: false, audio: false },
|
|
590
724
|
"grok-4.6": { image: true, video: false, audio: false },
|
|
591
725
|
"claude-sonnet-5": { image: true, video: false, audio: false },
|
|
592
726
|
"claude-opus-4.8": { image: true, video: false, audio: false },
|