@nodaro/shared 1.21.0 → 1.22.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": "1.21.0",
3
+ "version": "1.22.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",
@@ -10,6 +10,8 @@ import {
10
10
  resolveLlmCreditId,
11
11
  motionGraphicsFeature,
12
12
  effectiveReasoningEffort,
13
+ supportsAdvancedMode,
14
+ availableReasoningEfforts,
13
15
  } from "../llm-models.js"
14
16
  import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
15
17
  import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
@@ -609,3 +611,139 @@ describe("buildLlmCreditIdentifier effort bump (xhigh/max only)", () => {
609
611
  expect(resolveLlmCreditId("llm-chat", { llmModel: "gpt-5.6-terra", reasoningEffort: "max" })).toBe("llm-chat:premium")
610
612
  })
611
613
  })
614
+
615
+ describe("direct-vendor lane declarations", () => {
616
+ it("no model declares BOTH preferKie and preferDirect", () => {
617
+ // They are the two halves of one idea (which lane goes first). Declaring
618
+ // both is meaningless and would make routing depend on branch order in
619
+ // llm-client rather than on the registry.
620
+ const conflicted = LLM_MODELS.filter((m) => m.preferKie && m.preferDirect).map((m) => m.id)
621
+ expect(conflicted).toEqual([])
622
+ })
623
+
624
+ it("preferDirect is only meaningful alongside a directGeminiModel", () => {
625
+ const orphaned = LLM_MODELS.filter((m) => m.preferDirect && !m.directGeminiModel).map((m) => m.id)
626
+ expect(orphaned).toEqual([])
627
+ })
628
+
629
+ it("every direct-lane model id is a non-empty, non-placeholder string", () => {
630
+ for (const m of LLM_MODELS.filter((x) => x.directGeminiModel)) {
631
+ expect(m.directGeminiModel!.length, m.id).toBeGreaterThan(0)
632
+ expect(m.directGeminiModel, m.id).not.toContain(" ")
633
+ }
634
+ })
635
+
636
+ it("only google-vendor models carry a Gemini direct lane", () => {
637
+ const misvendored = LLM_MODELS.filter((m) => m.directGeminiModel && m.vendor !== "google").map((m) => m.id)
638
+ expect(misvendored).toEqual([])
639
+ })
640
+
641
+ it("getLlmModel resolves a model by its direct Google id", () => {
642
+ // The `-preview`-suffixed Google ids differ from our canonical ids, and
643
+ // cost/usage reconciliation looks models up by whatever id the wire used.
644
+ expect(getLlmModel("gemini-3.1-pro-preview")?.id).toBe("gemini-3.1-pro")
645
+ expect(getLlmModel("gemini-3-flash-preview")?.id).toBe("gemini-3-flash")
646
+ })
647
+ })
648
+
649
+ describe("advanced mode", () => {
650
+ it("is available exactly on models with a direct Gemini lane", () => {
651
+ for (const m of LLM_MODELS) {
652
+ expect(supportsAdvancedMode(m.id), m.id).toBe(Boolean(m.directGeminiModel))
653
+ }
654
+ })
655
+
656
+ it("is unavailable for an unknown or missing model id", () => {
657
+ expect(supportsAdvancedMode(undefined)).toBe(false)
658
+ expect(supportsAdvancedMode("not-a-real-model")).toBe(false)
659
+ })
660
+
661
+ it("bumps the credit tier one step", () => {
662
+ // gemini-3-flash is economy → standard (standard renders as the bare feature)
663
+ expect(buildLlmCreditIdentifier("llm-chat", "gemini-3-flash")).toBe("llm-chat:economy")
664
+ expect(buildLlmCreditIdentifier("llm-chat", "gemini-3-flash", undefined, true)).toBe("llm-chat")
665
+ })
666
+
667
+ it("is independent of the effort bump — advanced adds exactly one step", () => {
668
+ // No Gemini model declares xhigh/max today (their KIE-safe ceiling is
669
+ // `high`, which never bumps), so the two bumps cannot currently stack in
670
+ // practice — `max` clamps to `high` first. What must hold regardless is
671
+ // that advanced adds exactly one step on top of whatever the
672
+ // effort-clamped tier already is.
673
+ for (const effort of [undefined, "low", "high", "max"]) {
674
+ const plain = buildLlmCreditIdentifier("llm-chat", "gemini-3.6-flash", effort)
675
+ const advanced = buildLlmCreditIdentifier("llm-chat", "gemini-3.6-flash", effort, true)
676
+ expect(plain, `effort=${effort}`).toBe("llm-chat:economy")
677
+ expect(advanced, `effort=${effort}`).toBe("llm-chat")
678
+ }
679
+ })
680
+
681
+ it("never bumps past premium", () => {
682
+ expect(buildLlmCreditIdentifier("llm-chat", "gemini-3.1-pro", "max", true)).toBe("llm-chat:premium")
683
+ })
684
+
685
+ it("ignores the flag on a model that cannot run advanced (no silent overcharge)", () => {
686
+ // gpt-5.2 has no direct lane — a stale advancedMode flag must not inflate it.
687
+ expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.2", undefined, true))
688
+ .toBe(buildLlmCreditIdentifier("llm-chat", "gpt-5.2"))
689
+ expect(buildLlmCreditIdentifier("llm-chat", "claude-opus-4.7", undefined, true))
690
+ .toBe(buildLlmCreditIdentifier("llm-chat", "claude-opus-4.7"))
691
+ })
692
+
693
+ it("back-compat: omitting the 4th arg is identical to before for every model", () => {
694
+ for (const m of LLM_MODELS) {
695
+ expect(buildLlmCreditIdentifier("x", m.id, "high", false)).toBe(buildLlmCreditIdentifier("x", m.id, "high"))
696
+ }
697
+ })
698
+
699
+ it("resolveLlmCreditId reads advancedMode from the raw body", () => {
700
+ expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash", advancedMode: true })).toBe("llm-chat")
701
+ expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash" })).toBe("llm-chat:economy")
702
+ // Only a real boolean true counts — a truthy string must not bump.
703
+ expect(resolveLlmCreditId("llm-chat", { llmModel: "gemini-3-flash", advancedMode: "yes" })).toBe("llm-chat:economy")
704
+ })
705
+ })
706
+
707
+ describe("lane-aware reasoning efforts", () => {
708
+ it("widens the ladder on the direct lane where the vendor accepts more", () => {
709
+ expect(availableReasoningEfforts("gemini-3.6-flash")).toEqual(["low", "high"])
710
+ expect(availableReasoningEfforts("gemini-3.6-flash", true)).toEqual(["none", "low", "medium", "high"])
711
+ })
712
+
713
+ it("exposes an effort lever on models that have none via KIE", () => {
714
+ // gemini-3-flash declares no reasoningEfforts at all — Advanced is the
715
+ // only way its effort picker appears.
716
+ expect(availableReasoningEfforts("gemini-3-flash")).toEqual([])
717
+ expect(availableReasoningEfforts("gemini-3-flash", true)).toEqual(["none", "low", "medium", "high"])
718
+ })
719
+
720
+ it("respects a shorter direct ladder (3.1 Pro has no minimal tier)", () => {
721
+ expect(availableReasoningEfforts("gemini-3.1-pro", true)).toEqual(["low", "medium", "high"])
722
+ })
723
+
724
+ it("ignores the advanced flag on a model with no direct lane", () => {
725
+ const claude = availableReasoningEfforts("claude-sonnet-4.6")
726
+ expect(availableReasoningEfforts("claude-sonnet-4.6", true)).toEqual(claude)
727
+ })
728
+
729
+ it("clamps against the lane's ladder, not the other lane's", () => {
730
+ // `medium` is not on 3.6-flash's KIE ladder → clamps down to `low`.
731
+ expect(effectiveReasoningEffort("gemini-3.6-flash", "medium")).toBe("low")
732
+ // On the direct lane `medium` is a real level → survives.
733
+ expect(effectiveReasoningEffort("gemini-3.6-flash", "medium", true)).toBe("medium")
734
+ })
735
+
736
+ it("every directReasoningEfforts entry is a valid effort, ascending", () => {
737
+ for (const m of LLM_MODELS.filter((x) => x.directReasoningEfforts)) {
738
+ const levels = m.directReasoningEfforts!
739
+ for (const l of levels) expect(LLM_REASONING_EFFORTS, m.id).toContain(l)
740
+ const ranks = levels.map((l) => LLM_REASONING_EFFORTS.indexOf(l))
741
+ expect(ranks, `${m.id} must be ascending`).toEqual([...ranks].sort((a, b) => a - b))
742
+ }
743
+ })
744
+
745
+ it("only advanced-capable models declare a direct ladder", () => {
746
+ const bad = LLM_MODELS.filter((m) => m.directReasoningEfforts && !supportsAdvancedMode(m.id)).map((m) => m.id)
747
+ expect(bad).toEqual([])
748
+ })
749
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The model catalog hand-copies video-analysis credit values that already live
3
+ * in `VIDEO_ANALYSIS_BUCKET_CREDITS`. Nothing cross-checked the two, so a
4
+ * reprice could update the table (and the DB migration) while the catalog kept
5
+ * quoting the old numbers — and the catalog is what the model browser shows a
6
+ * user BEFORE they run anything.
7
+ *
8
+ * This is the guard that was missing.
9
+ */
10
+ import { describe, it, expect } from "vitest"
11
+ import { MODEL_CATALOG } from "../model-catalog.js"
12
+ import {
13
+ VIDEO_ANALYSIS_BUCKET_CREDITS,
14
+ VIDEO_ANALYSIS_DURATION_BUCKETS,
15
+ VIDEO_ANALYSIS_MAX_DURATION_SEC,
16
+ } from "../video-analysis-pricing.js"
17
+
18
+ /** Every `video-analysis:*` pricing row declared anywhere in the catalog. */
19
+ const catalogRows = Object.values(MODEL_CATALOG)
20
+ .flatMap((m) => (m.pricing ?? []) as ReadonlyArray<{ identifier: string; credits: number }>)
21
+ .filter((r) => r.identifier.startsWith("video-analysis:"))
22
+
23
+ describe("model catalog video-analysis pricing", () => {
24
+ it("declares at least one row (guard is actually wired to something)", () => {
25
+ expect(catalogRows.length).toBeGreaterThan(0)
26
+ })
27
+
28
+ it("every bucketed catalog row matches VIDEO_ANALYSIS_BUCKET_CREDITS exactly", () => {
29
+ const bucketed = catalogRows.filter((r) => /:\d+s$/.test(r.identifier))
30
+ expect(bucketed.length).toBeGreaterThan(0)
31
+ for (const row of bucketed) {
32
+ expect(
33
+ row.credits,
34
+ `catalog "${row.identifier}" = ${row.credits} but the table says ${VIDEO_ANALYSIS_BUCKET_CREDITS[row.identifier]}`,
35
+ ).toBe(VIDEO_ANALYSIS_BUCKET_CREDITS[row.identifier])
36
+ }
37
+ })
38
+
39
+ it("every bare (no-duration) catalog row equals its max-duration ceiling", () => {
40
+ // A bare id means "duration unknown", which the route prices at the ceiling.
41
+ const bare = catalogRows.filter((r) => !/:\d+s$/.test(r.identifier))
42
+ expect(bare.length).toBeGreaterThan(0)
43
+ for (const row of bare) {
44
+ const ceiling = VIDEO_ANALYSIS_BUCKET_CREDITS[`${row.identifier}:${VIDEO_ANALYSIS_MAX_DURATION_SEC}s`]
45
+ expect(ceiling, `no ceiling row for ${row.identifier}`).toBeDefined()
46
+ expect(row.credits, `catalog "${row.identifier}" must equal its ${VIDEO_ANALYSIS_MAX_DURATION_SEC}s ceiling`).toBe(ceiling)
47
+ }
48
+ })
49
+
50
+ it("catalog covers every bucket it claims a model supports", () => {
51
+ const byModel = new Map<string, Set<number>>()
52
+ for (const r of catalogRows) {
53
+ const m = /^video-analysis:(.+):(\d+)s$/.exec(r.identifier)
54
+ if (!m) continue
55
+ if (!byModel.has(m[1]!)) byModel.set(m[1]!, new Set())
56
+ byModel.get(m[1]!)!.add(Number(m[2]))
57
+ }
58
+ for (const [model, buckets] of byModel) {
59
+ expect([...buckets].sort((a, b) => a - b), `${model} bucket coverage`)
60
+ .toEqual([...VIDEO_ANALYSIS_DURATION_BUCKETS])
61
+ }
62
+ })
63
+ })
@@ -113,10 +113,11 @@ describe("video-analysis-pricing", () => {
113
113
  }
114
114
  })
115
115
 
116
- // Full drift-detection against the live $-formula lives in
117
- // backend/src/lib/pricing/__tests__/video-analysis-cost.test.ts (this
118
- // package cannot see the formula post-S5). This is a lightweight shape
119
- // check that the precomputed table covers every legal id.
116
+ // Full drift-detection against the live $-formula lives in the PRIVATE
117
+ // plugin repo (src/plugins/video-analysis/__tests__/cost.test.ts) — the
118
+ // formula moved there in 2026-07 and the app-side test was deleted with it,
119
+ // so nothing in THIS repo can recompute these numbers. This is a lightweight
120
+ // shape check that the precomputed table covers every legal id.
120
121
  it("VIDEO_ANALYSIS_BUCKET_CREDITS has a positive-integer entry for every model × bucket id", () => {
121
122
  for (const model of VIDEO_ANALYSIS_LLM_MODELS) {
122
123
  for (const bucketSec of VIDEO_ANALYSIS_DURATION_BUCKETS) {
@@ -5,7 +5,9 @@ import {
5
5
  renderAnalyzedScene, isOversizedScene, aspectRatioFromDims,
6
6
  entitySlotSchema, analyzedSceneSchema,
7
7
  rewriteSceneBindings, dropUnknownBindings,
8
- rewriteSpeakerSlots, dropUnknownSpeakers,
8
+ rewriteSpeakerSlots, dropUnknownSpeakers, mergeClipLook,
9
+ VIDEO_ANALYSIS_SPEED_EFFECTS, VIDEO_ANALYSIS_SHOT_ANGLES, VIDEO_ANALYSIS_FACELESS_ANGLES,
10
+ VIDEO_ANALYSIS_VISUAL_EFFECTS, VIDEO_ANALYSIS_TRANSITIONS,
9
11
  VIDEO_ANALYSIS_MAX_VARIATIONS, VIDEO_ANALYSIS_VARIATION_SLUGS, VIDEO_ANALYSIS_DEFAULT_VARIATION,
10
12
  type EntitySlot, type AudioLayer,
11
13
  } from "../video-analysis.js"
@@ -216,6 +218,136 @@ describe("speech attribution (speakerSlot)", () => {
216
218
  })
217
219
  })
218
220
 
221
+ describe("cinematography fields (angle / speed / onScreenText / look)", () => {
222
+ it("carries angle and speed as closed enums through a window round-trip", () => {
223
+ const parsed = windowAnalysisSchema.parse({
224
+ slots: [slot],
225
+ scenes: [{ ...baseScene, angle: "low", speed: "slow-motion", onScreenText: "ACT I" }],
226
+ })
227
+ expect(parsed.scenes[0]).toMatchObject({ angle: "low", speed: "slow-motion", onScreenText: "ACT I" })
228
+ })
229
+
230
+ it("supports the RELATIONAL viewpoints, so shotType keeps the size", () => {
231
+ // These were conventions inside the `shotType` list, competing with the
232
+ // sizes for one slot — so an over-the-shoulder MEDIUM had to throw one away.
233
+ const parsed = windowAnalysisSchema.parse({
234
+ slots: [slot],
235
+ scenes: [{ ...baseScene, shotType: "Medium", angle: "over-the-shoulder" }],
236
+ })
237
+ expect(parsed.scenes[0]).toMatchObject({ shotType: "Medium", angle: "over-the-shoulder" })
238
+ for (const a of ["pov", "profile", "from-behind"]) {
239
+ expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, angle: a }] }).success).toBe(true)
240
+ }
241
+ })
242
+
243
+ it("carries picture EFFECTS as an array — a shot can be grainy and vignetted", () => {
244
+ const parsed = windowAnalysisSchema.parse({
245
+ slots: [slot], scenes: [{ ...baseScene, effects: ["grain", "vignette"] }],
246
+ })
247
+ expect(parsed.scenes[0]!.effects).toEqual(["grain", "vignette"])
248
+ })
249
+
250
+ it("keeps compositing OUT of effects — that is where the phantom slot came from", () => {
251
+ // A field for "there is an inset of a person here" would hand a legitimate
252
+ // home to the invented `{slot:creator} overlay talking to camera`. An effect
253
+ // is verifiable in the pixels; a claim about who is inset is not.
254
+ for (const bad of ["picture-in-picture", "split-screen", "overlay"]) {
255
+ expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, effects: [bad] }] }).success).toBe(false)
256
+ }
257
+ })
258
+
259
+ it("transitions distinguish DISSOLVE from FADE — they look nothing alike", () => {
260
+ // Collapsed onto `fade` before this, so a cross-dissolve was rendered as a
261
+ // fade through black.
262
+ expect(VIDEO_ANALYSIS_TRANSITIONS).toContain("dissolve")
263
+ for (const t of VIDEO_ANALYSIS_TRANSITIONS) {
264
+ expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [{ ...baseScene, transitionOut: t }] }).success).toBe(true)
265
+ }
266
+ })
267
+
268
+ it("effects and transitions are NOT the same axis", () => {
269
+ // `dissolve`/`fade` are edits BETWEEN shots; blur/pixelate are on the picture.
270
+ for (const t of ["dissolve", "fade", "wipe", "cut"]) {
271
+ expect(VIDEO_ANALYSIS_VISUAL_EFFECTS).not.toContain(t)
272
+ }
273
+ for (const e of VIDEO_ANALYSIS_VISUAL_EFFECTS) {
274
+ expect(VIDEO_ANALYSIS_TRANSITIONS).not.toContain(e as never)
275
+ }
276
+ })
277
+
278
+ it("marks the viewpoints where no face is visible — auto-cast reads this", () => {
279
+ // A reference frame shot from behind cannot cast a face, however well framed.
280
+ expect([...VIDEO_ANALYSIS_FACELESS_ANGLES].sort()).toEqual(["from-behind", "over-the-shoulder"])
281
+ for (const a of VIDEO_ANALYSIS_FACELESS_ANGLES) {
282
+ expect(VIDEO_ANALYSIS_SHOT_ANGLES).toContain(a) // never a stale literal
283
+ }
284
+ })
285
+
286
+ it("rejects free-text angle — improvising it is the failure being fixed", () => {
287
+ // The shipped defect: `"camera": "low angle static"`, because angle had no
288
+ // field. A free-text `angle` would just move the improvisation.
289
+ expect(windowAnalysisSchema.safeParse({
290
+ slots: [slot], scenes: [{ ...baseScene, angle: "low angle static" }],
291
+ }).success).toBe(false)
292
+ })
293
+
294
+ it("has NO 'normal' speed member — absence is normal, so there is one way to say it", () => {
295
+ expect(VIDEO_ANALYSIS_SPEED_EFFECTS).not.toContain("normal")
296
+ expect(windowAnalysisSchema.safeParse({
297
+ slots: [slot], scenes: [{ ...baseScene, speed: "normal" }],
298
+ }).success).toBe(false)
299
+ // …and omitting it is valid.
300
+ expect(windowAnalysisSchema.safeParse({ slots: [slot], scenes: [baseScene] }).success).toBe(true)
301
+ })
302
+
303
+ it("keeps every new field OPTIONAL so an older producer still validates", () => {
304
+ const parsed = windowAnalysisSchema.parse({ slots: [slot], scenes: [baseScene] })
305
+ expect(parsed.scenes[0]).not.toHaveProperty("angle")
306
+ expect(parsed.scenes[0]).not.toHaveProperty("speed")
307
+ expect(parsed.scenes[0]).not.toHaveProperty("onScreenText")
308
+ expect(parsed).not.toHaveProperty("look")
309
+ })
310
+
311
+ it("the new scene fields reach the RESULT schema, not just the window one", () => {
312
+ // analyzedSceneSchema extends windowSceneBase — this pins that it stays that
313
+ // way, since a field the merged result drops is invisible to every consumer.
314
+ const scene = { ...baseScene, sceneNumber: 1, visualResolved: "x", slotRefs: [], angle: "dutch", speed: "freeze", onScreenText: "THE END" }
315
+ expect(analyzedSceneSchema.parse(scene)).toMatchObject({ angle: "dutch", speed: "freeze", onScreenText: "THE END" })
316
+ })
317
+
318
+ it("look is a SIBLING of meta, not inside it — meta is measured, look is read", () => {
319
+ const r = videoAnalysisResultSchema.parse({
320
+ meta: { durationSec: 10, width: 1920, height: 1080, aspectRatio: "16:9" },
321
+ look: { grade: "muted teal", format: "anamorphic digital", genre: "cinematic trailer" },
322
+ slots: [],
323
+ scenes: [{ ...baseScene, sceneNumber: 1, visualResolved: "x", slotRefs: [] }],
324
+ })
325
+ expect(r.look?.format).toBe("anamorphic digital")
326
+ expect(r.meta).not.toHaveProperty("look")
327
+ })
328
+ })
329
+
330
+ describe("mergeClipLook", () => {
331
+ it("takes the first non-empty value PER FIELD, not the first window wholesale", () => {
332
+ // Windows see different footage: one may read the grade while only a later
333
+ // one contains the shot that reveals the format.
334
+ expect(mergeClipLook([
335
+ { grade: "muted teal" },
336
+ { grade: "warm", format: "16mm film grain" },
337
+ { lens: "anamorphic flare" },
338
+ ])).toEqual({ grade: "muted teal", format: "16mm film grain", lens: "anamorphic flare" })
339
+ })
340
+
341
+ it("ignores blank strings and trims what it keeps", () => {
342
+ expect(mergeClipLook([{ grade: " " }, { grade: " crushed blacks " }])).toEqual({ grade: "crushed blacks" })
343
+ })
344
+
345
+ it("returns undefined when nothing was read — never an empty object", () => {
346
+ expect(mergeClipLook([])).toBeUndefined()
347
+ expect(mergeClipLook([undefined, {}, { grade: "" }])).toBeUndefined()
348
+ })
349
+ })
350
+
219
351
  describe("misc", () => {
220
352
  it("isOversizedScene flags > 8s only", () => {
221
353
  expect(isOversizedScene(0, 8)).toBe(false)
package/src/index.ts CHANGED
@@ -277,6 +277,9 @@ export {
277
277
  getLlmModalityCaps,
278
278
  buildLlmCreditIdentifier,
279
279
  resolveLlmCreditId,
280
+ supportsAdvancedMode,
281
+ availableReasoningEfforts,
282
+ ADVANCED_MODE_UNAVAILABLE_REASON,
280
283
  motionGraphicsFeature,
281
284
  effectiveReasoningEffort,
282
285
  type LlmTier,
package/src/llm-models.ts CHANGED
@@ -51,8 +51,44 @@ export interface LlmModelDef {
51
51
  structuredOutputMode?: "anthropic-tool" | "kie-response-format" | "responses-json-schema"
52
52
  /** If set, fallback to direct Anthropic SDK with this model ID when KIE.ai fails */
53
53
  directFallbackModel?: string
54
+ /**
55
+ * Google Gemini API model id for the DIRECT lane (generativelanguage, keyed
56
+ * by `GEMINI_API_KEY`) — the Google-side twin of `directFallbackModel`.
57
+ * Presence declares "this model CAN be served straight from Google"; absence
58
+ * pins it to KIE forever. Stated, never derived: Google carries `-preview`
59
+ * suffixes on unreleased models, so the id routinely differs from both `id`
60
+ * and `kieSlugOrModel` (`gemini-3.1-pro` → `gemini-3.1-pro-preview`).
61
+ */
62
+ directGeminiModel?: string
63
+ /**
64
+ * Try the direct-vendor lane FIRST for this model, with KIE as the failure
65
+ * fallback. Absent (while `directGeminiModel` is set) = KIE first, direct
66
+ * only when KIE fails.
67
+ *
68
+ * This is a per-model COST decision, not just a routing one: the two lanes
69
+ * bill the same model at materially different unit rates, so a model that
70
+ * backs a high-volume default (see `LLM_FEATURE_DEFAULTS`) is usually better
71
+ * left on whichever lane is cheaper. The rate tables for both lanes live in
72
+ * `backend/src/lib/pricing/llm-cost.ts` — deliberately not in this package,
73
+ * which is published to npm.
74
+ *
75
+ * Mutually exclusive with `preferKie` — the Claude-side half of the same
76
+ * idea. Guarded by a registry test so the two can't both be set.
77
+ */
78
+ preferDirect?: true
54
79
  /** Effort levels this model accepts (ascending). Absent/empty = no effort lever, picker hidden. */
55
80
  reasoningEfforts?: readonly LlmReasoningEffort[]
81
+ /**
82
+ * Effort levels available on the DIRECT lane, when the vendor's own API
83
+ * accepts more than the aggregator does. Absent = the direct lane offers the
84
+ * same set as `reasoningEfforts`.
85
+ *
86
+ * This exists because `reasoningEfforts` has to stay at the KIE-safe
87
+ * intersection — sending a level KIE rejects is a hard failure — while the
88
+ * vendor API accepts the full ladder. Unlocking those extra levels is one of
89
+ * the concrete things Advanced mode buys.
90
+ */
91
+ directReasoningEfforts?: readonly LlmReasoningEffort[]
56
92
  /** false = model rejects `temperature` (Claude 5-era, GPT-5.6). Absent = accepts. */
57
93
  supportsTemperature?: false
58
94
  /** Claude-only: KIE is the preferred routing, direct Anthropic the fallback. */
@@ -85,6 +121,13 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
85
121
  structuredOutputMode: "kie-response-format",
86
122
  supportsImages: true,
87
123
  maxOutputTokens: 8192,
124
+ // KIE-first: no `preferDirect` — direct is the reliability fallback only.
125
+ // (Per-lane rates are deliberately NOT in this published package; see
126
+ // backend/src/lib/pricing/llm-cost.ts.)
127
+ directGeminiModel: "gemini-3-flash-preview",
128
+ // No `reasoningEfforts` at all on the KIE lane, but the vendor API accepts
129
+ // the full minimal→high ladder (`none` maps to Google's `minimal`).
130
+ directReasoningEfforts: ["none", "low", "medium", "high"],
88
131
  },
89
132
  {
90
133
  id: "gemini-3.6-flash",
@@ -102,7 +145,19 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
102
145
  maxOutputTokens: 8192,
103
146
  // KIE's 3.6 endpoint accepts `reasoning_effort: low | high` (thinking
104
147
  // level) — exactly the chat-completions wire mapping deriveParams sends.
148
+ // Google's own API additionally accepts `minimal` and `medium` on this
149
+ // model; the set stays at the KIE-safe intersection because ONE field
150
+ // feeds both lanes and this model is KIE-first. Widen it only if/when
151
+ // `preferDirect` is set here.
105
152
  reasoningEfforts: ["low", "high"],
153
+ // Google's own API additionally accepts `minimal` and `medium` here —
154
+ // live-verified 2026-07-28. Advanced mode unlocks them.
155
+ directReasoningEfforts: ["none", "low", "medium", "high"],
156
+ // KIE-first: this model backs 5 of the LLM_FEATURE_DEFAULTS plus the
157
+ // video-analysis fast tier, so it carries the highest call volume of any
158
+ // Gemini entry — the lane with the lower unit cost wins by default and
159
+ // direct is the reliability fallback only.
160
+ directGeminiModel: "gemini-3.6-flash",
106
161
  },
107
162
  {
108
163
  id: "claude-haiku-4.5",
@@ -153,6 +208,18 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
153
208
  structuredOutputMode: "kie-response-format",
154
209
  supportsImages: true,
155
210
  maxOutputTokens: 16384,
211
+ directGeminiModel: "gemini-3.1-pro-preview",
212
+ // Google documents low/medium/high for 3.1 Pro — no `minimal` tier, so
213
+ // this ladder is deliberately shorter than the flash models'.
214
+ directReasoningEfforts: ["low", "medium", "high"],
215
+ // The ONE Gemini model routed direct-first. It is the premium/low-volume
216
+ // tier (video-analysis `pro`, no LLM_FEATURE_DEFAULTS entry), so the ~4×
217
+ // list-price premium lands on the smallest call volume — and it is where
218
+ // the direct lane's capability wins actually matter: real `thinkingLevel`
219
+ // control, native media ingestion, and a `responseJsonSchema` that honours
220
+ // `additionalProperties` (KIE's `response_format` silently DROPS
221
+ // record/map-shaped fields — see the z.record rule in backend/CLAUDE.md).
222
+ preferDirect: true,
156
223
  },
157
224
  {
158
225
  id: "claude-opus-4.7",
@@ -425,21 +492,45 @@ export function getLlmModel(id: string): LlmModelDef | undefined {
425
492
  if (aliased) return aliased
426
493
  }
427
494
  // Last resort: provider slugs double as historical aliases (e.g. the
428
- // dated Anthropic slugs) — accept any model whose slug matches exactly.
429
- return LLM_MODELS.find((m) => m.kieSlugOrModel === id || m.directFallbackModel === id)
495
+ // dated Anthropic slugs, the `-preview`-suffixed Google ids) — accept any
496
+ // model whose slug matches exactly, on either lane.
497
+ return LLM_MODELS.find(
498
+ (m) => m.kieSlugOrModel === id || m.directFallbackModel === id || m.directGeminiModel === id,
499
+ )
430
500
  }
431
501
 
432
502
  export function getLlmTier(id: string): LlmTier {
433
503
  return getLlmModel(id)?.tier ?? "standard"
434
504
  }
435
505
 
506
+ /**
507
+ * Effort levels this model actually accepts on the lane it will be served on.
508
+ *
509
+ * The two lanes do NOT offer the same ladder: the aggregator accepts a narrower
510
+ * set than the vendor's own API does, which is one of the concrete things
511
+ * Advanced mode buys. Kept as one lookup so the UI picker and the wire-side
512
+ * clamp can never disagree about what's selectable.
513
+ */
514
+ export function availableReasoningEfforts(
515
+ modelId: string | undefined,
516
+ advanced = false,
517
+ ): readonly LlmReasoningEffort[] {
518
+ const model = getLlmModel(modelId ?? "")
519
+ if (!model) return []
520
+ if (advanced && supportsAdvancedMode(modelId)) {
521
+ return model.directReasoningEfforts ?? model.reasoningEfforts ?? []
522
+ }
523
+ return model.reasoningEfforts ?? []
524
+ }
525
+
436
526
  /** Highest level the model supports that is ≤ the requested level; undefined = treat as Auto. */
437
527
  export function effectiveReasoningEffort(
438
528
  modelId: string | undefined,
439
529
  requested?: string,
530
+ advanced = false,
440
531
  ): LlmReasoningEffort | undefined {
441
532
  if (!requested || !(requested in EFFORT_RANK)) return undefined
442
- const levels = getLlmModel(modelId ?? "")?.reasoningEfforts
533
+ const levels = availableReasoningEfforts(modelId, advanced)
443
534
  if (!levels || levels.length === 0) return undefined
444
535
  const req = requested as LlmReasoningEffort
445
536
  let best: LlmReasoningEffort | undefined
@@ -459,25 +550,64 @@ export function effectiveReasoningEffort(
459
550
  * premium stays premium). `high` is the Claude-family server default and
460
551
  * never bumps.
461
552
  */
462
- export function buildLlmCreditIdentifier(feature: string, modelId?: string, reasoningEffort?: string): string {
553
+ /** One step up the economy standard premium ladder. Premium is the ceiling. */
554
+ function bumpTier(tier: LlmTier): LlmTier {
555
+ if (tier === "economy") return "standard"
556
+ if (tier === "standard") return "premium"
557
+ return tier
558
+ }
559
+
560
+ /**
561
+ * Can this model be run in Advanced mode?
562
+ *
563
+ * Advanced mode pins the call to the vendor's own API, which is the only lane
564
+ * where sampling levers (`temperature`, `maxTokens`) and the full effort range
565
+ * actually take effect. Capability-derived from the registry — a model without
566
+ * a direct lane simply cannot offer it, so UI and routes both gate on this
567
+ * rather than on a hand-maintained model list.
568
+ */
569
+ export function supportsAdvancedMode(modelId: string | undefined): boolean {
570
+ return Boolean(modelId && getLlmModel(modelId)?.directGeminiModel)
571
+ }
572
+
573
+ /** User-facing reason a model can't offer Advanced mode. Single-sourced so the
574
+ * config panel's disabled hint and the route's 400 say the same thing. */
575
+ export const ADVANCED_MODE_UNAVAILABLE_REASON =
576
+ "Advanced mode is available on Gemini models — switch the model to enable it."
577
+
578
+ export function buildLlmCreditIdentifier(
579
+ feature: string,
580
+ modelId?: string,
581
+ reasoningEffort?: string,
582
+ advancedMode?: boolean,
583
+ ): string {
463
584
  if (!modelId) return feature
464
585
  let tier = getLlmTier(modelId)
465
586
  const eff = effectiveReasoningEffort(modelId, reasoningEffort)
466
- if (eff !== undefined && EFFORT_TIER_BUMP.has(eff)) {
467
- if (tier === "economy") tier = "standard"
468
- else if (tier === "standard") tier = "premium"
469
- }
587
+ if (eff !== undefined && EFFORT_TIER_BUMP.has(eff)) tier = bumpTier(tier)
588
+ // Advanced mode routes to the vendor's own API, which bills materially more
589
+ // per token than the aggregator. It bumps INDEPENDENTLY of the effort bump —
590
+ // the two are separate cost levers and genuinely stack, so a max-effort
591
+ // advanced economy call lands at premium. The bump is ignored on a model that
592
+ // can't run advanced at all, so a stale flag never inflates a bill.
593
+ if (advancedMode && supportsAdvancedMode(modelId)) tier = bumpTier(tier)
470
594
  if (tier === "standard") return feature
471
595
  return `${feature}:${tier}`
472
596
  }
473
597
 
474
598
  /**
475
- * Resolve llmModel (+ reasoningEffort) from raw body for creditGuard preHandler
476
- * (before Zod parsing). Returns the credit identifier for the given feature.
599
+ * Resolve llmModel (+ reasoningEffort, advancedMode) from raw body for the
600
+ * creditGuard preHandler (before Zod parsing). Returns the credit identifier
601
+ * for the given feature.
477
602
  */
478
603
  export function resolveLlmCreditId(feature: string, body: unknown): string {
479
604
  const b = body as Record<string, unknown> | undefined
480
- return buildLlmCreditIdentifier(feature, b?.llmModel as string | undefined, b?.reasoningEffort as string | undefined)
605
+ return buildLlmCreditIdentifier(
606
+ feature,
607
+ b?.llmModel as string | undefined,
608
+ b?.reasoningEffort as string | undefined,
609
+ b?.advancedMode === true,
610
+ )
481
611
  }
482
612
 
483
613
  /** Models capable of video-analysis: capability-derived, never hand-listed (route-enum-sync convention). */