@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.23.0",
3
+ "version": "2.26.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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 5 economy, 5 standard, 8 premium models", () => {
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(5)
104
+ expect(tierCounts.economy).toBe(6)
103
105
  expect(tierCounts.standard).toBe(5)
104
- expect(tierCounts.premium).toBe(8)
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(2076)
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(290)
184
- expect(videoAuditCreditsForBucket(360, false)).toBe(663)
185
- expect(videoAuditCreditsForBucket(600, false)).toBe(1073)
186
- expect(videoAuditCreditsForBucket(600, true)).toBe(1921)
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(1073)
228
- expect(VIDEO_AUDIT_BUCKET_CREDITS["video-audit:auto:600s"]).toBe(1921)
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
- desc: "Latest fast Gemini, sharper reasoning",
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
- desc: "Newest fast Gemini, agentic-tuned",
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
- desc: "Latest GPT, premium quality",
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
- desc: "Flagship GPT-5.6, deepest reasoning",
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 },