@nodaro/shared 1.7.0 → 1.10.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.7.0",
3
+ "version": "1.10.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",
@@ -17,6 +17,8 @@ describe("buildCreditModelIdentifier", () => {
17
17
  "seedream-edit",
18
18
  "seedream-5-lite",
19
19
  "seedream-5-lite-i2i",
20
+ "seedream-5-pro",
21
+ "seedream-5-pro-i2i",
20
22
  ]
21
23
 
22
24
  it.each(highQualityProviders)(
@@ -16,7 +16,9 @@ describe("imageReferenceLimit", () => {
16
16
  "flux-2-max": 8,
17
17
  "gpt-image-2": 16, // → gpt-image-2-i2i
18
18
  "seedream-5-lite": 16, // → seedream-5-lite-i2i
19
+ "seedream-5-pro": 16, // → seedream-5-pro-i2i
19
20
  "nano-banana-2": 4,
21
+ "nano-banana-2-lite": 10, // schema maxItems (image_urls)
20
22
  flux: 4, // → flux-pro-i2i
21
23
  "flux-2-klein": 1, // product cap (schema array slices at 5)
22
24
  "flux-2-pro": 4, // product cap (BFL schema accepts 8)
@@ -3,11 +3,13 @@ import {
3
3
  LLM_MODEL_IDS,
4
4
  LLM_FEATURE_DEFAULTS,
5
5
  STRUCTURED_VISION_MODELS,
6
+ LLM_REASONING_EFFORTS,
6
7
  getLlmModel,
7
8
  getLlmTier,
8
9
  buildLlmCreditIdentifier,
9
10
  resolveLlmCreditId,
10
11
  motionGraphicsFeature,
12
+ effectiveReasoningEffort,
11
13
  } from "../llm-models.js"
12
14
  import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
13
15
 
@@ -21,8 +23,8 @@ import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
21
23
  // LLM_MODELS data integrity
22
24
  // ---------------------------------------------------------------------------
23
25
  describe("LLM_MODELS data integrity", () => {
24
- it("should have exactly 7 models", () => {
25
- expect(LLM_MODELS).toHaveLength(7)
26
+ it("should have exactly 13 models", () => {
27
+ expect(LLM_MODELS).toHaveLength(13)
26
28
  })
27
29
 
28
30
  it("each model has all required fields", () => {
@@ -57,14 +59,14 @@ describe("LLM_MODELS data integrity", () => {
57
59
  expect(new Set(ids).size).toBe(ids.length)
58
60
  })
59
61
 
60
- it("has 2 economy, 2 standard, 3 premium models", () => {
62
+ it("has 3 economy, 4 standard, 6 premium models", () => {
61
63
  const tierCounts: Record<LlmTier, number> = { economy: 0, standard: 0, premium: 0 }
62
64
  for (const model of LLM_MODELS) {
63
65
  tierCounts[model.tier]++
64
66
  }
65
- expect(tierCounts.economy).toBe(2)
66
- expect(tierCounts.standard).toBe(2)
67
- expect(tierCounts.premium).toBe(3)
67
+ expect(tierCounts.economy).toBe(3)
68
+ expect(tierCounts.standard).toBe(4)
69
+ expect(tierCounts.premium).toBe(6)
68
70
  })
69
71
 
70
72
  it("all three kieFormats are represented", () => {
@@ -111,6 +113,12 @@ describe("LLM_MODEL_IDS", () => {
111
113
  "gemini-3.1-pro",
112
114
  "claude-opus-4.7",
113
115
  "gpt-5.4",
116
+ "gpt-5.5",
117
+ "gpt-5.6-luna",
118
+ "gpt-5.6-terra",
119
+ "gpt-5.6-sol",
120
+ "claude-sonnet-5",
121
+ "claude-opus-4.8",
114
122
  ]
115
123
  expect(LLM_MODEL_IDS).toEqual(expected)
116
124
  })
@@ -420,20 +428,29 @@ describe("STRUCTURED_VISION_MODELS", () => {
420
428
  "claude-sonnet-4.6",
421
429
  "gemini-3-flash",
422
430
  "gemini-3.1-pro",
431
+ "claude-sonnet-5",
432
+ "claude-opus-4.8",
433
+ // responses-format GPTs — KIE text.format json_schema live-verified
434
+ // 2026-07-14 (text AND vision inputs).
435
+ "gpt-5.4",
436
+ "gpt-5.5",
437
+ "gpt-5.6-luna",
438
+ "gpt-5.6-terra",
439
+ "gpt-5.6-sol",
423
440
  ].sort(),
424
441
  )
425
442
  })
426
443
 
427
- it("includes Anthropic (forced-tool) AND Gemini (response_format) vendors", () => {
444
+ it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI (responses text.format) vendors", () => {
428
445
  const vendors = new Set(STRUCTURED_VISION_MODELS.map((m) => m.vendor))
429
446
  expect(vendors).toContain("anthropic")
430
447
  expect(vendors).toContain("google")
448
+ expect(vendors).toContain("openai")
431
449
  })
432
450
 
433
- it("excludes GPT models — no native structured mode (parse+retry only)", () => {
451
+ it("excludes chat-completions GPT models — no native structured mode there (parse+retry only)", () => {
434
452
  const ids = STRUCTURED_VISION_MODELS.map((m) => m.id)
435
453
  expect(ids).not.toContain("gpt-5.2")
436
- expect(ids).not.toContain("gpt-5.4")
437
454
  })
438
455
 
439
456
  it("every member is vision-capable and has a structuredOutputMode", () => {
@@ -443,3 +460,78 @@ describe("STRUCTURED_VISION_MODELS", () => {
443
460
  }
444
461
  })
445
462
  })
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8)
466
+ // grok-4.5 is DEFERRED — its chat endpoint is not live on the provider yet
467
+ // (2026-07-13); its tests are intentionally omitted here.
468
+ // ---------------------------------------------------------------------------
469
+ describe("reasoning effort registry", () => {
470
+ it("every reasoningEfforts list is a subset of the superset, in ascending order", () => {
471
+ const rank = Object.fromEntries(LLM_REASONING_EFFORTS.map((e, i) => [e, i]))
472
+ for (const m of LLM_MODELS) {
473
+ for (const e of m.reasoningEfforts ?? []) expect(LLM_REASONING_EFFORTS).toContain(e)
474
+ const ranks = (m.reasoningEfforts ?? []).map((e) => rank[e])
475
+ expect([...ranks].sort((a, b) => a - b)).toEqual(ranks)
476
+ }
477
+ })
478
+ it("new models exist with expected tiers", () => {
479
+ expect(getLlmTier("gpt-5.6-luna")).toBe("economy")
480
+ expect(getLlmTier("gpt-5.6-terra")).toBe("standard")
481
+ expect(getLlmTier("gpt-5.6-sol")).toBe("premium")
482
+ expect(getLlmTier("claude-sonnet-5")).toBe("standard")
483
+ expect(getLlmTier("claude-opus-4.8")).toBe("premium")
484
+ expect(getLlmTier("gpt-5.5")).toBe("premium")
485
+ })
486
+ })
487
+
488
+ describe("effectiveReasoningEffort", () => {
489
+ it("passes through a supported level", () => {
490
+ expect(effectiveReasoningEffort("claude-sonnet-5", "max")).toBe("max")
491
+ })
492
+ it("clamps down to the highest supported level ≤ requested", () => {
493
+ expect(effectiveReasoningEffort("gpt-5.4", "xhigh")).toBe("high")
494
+ })
495
+ it("returns undefined when the model has no levels", () => {
496
+ expect(effectiveReasoningEffort("gemini-3-flash", "high")).toBeUndefined()
497
+ })
498
+ it("returns undefined for none on Claude (below its lowest level)", () => {
499
+ expect(effectiveReasoningEffort("claude-sonnet-5", "none")).toBeUndefined()
500
+ })
501
+ it("returns undefined for undefined/garbage input", () => {
502
+ expect(effectiveReasoningEffort("claude-sonnet-5", undefined)).toBeUndefined()
503
+ expect(effectiveReasoningEffort("claude-sonnet-5", "turbo")).toBeUndefined()
504
+ })
505
+ })
506
+
507
+ describe("buildLlmCreditIdentifier effort bump (xhigh/max only)", () => {
508
+ it("economy + max → standard (bare feature)", () => {
509
+ expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-luna", "max")).toBe("llm-chat")
510
+ })
511
+ it("standard + xhigh → premium", () => {
512
+ expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-terra", "xhigh")).toBe("llm-chat:premium")
513
+ })
514
+ it("premium + max stays premium", () => {
515
+ expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-sol", "max")).toBe("llm-chat:premium")
516
+ })
517
+ it("high never bumps", () => {
518
+ expect(buildLlmCreditIdentifier("llm-chat", "claude-sonnet-5", "high")).toBe("llm-chat")
519
+ })
520
+ it("clamp on a partial-list standard model never bumps (sonnet-4.6 @ xhigh → high)", () => {
521
+ expect(buildLlmCreditIdentifier("llm-chat", "claude-sonnet-4.6", "xhigh")).toBe("llm-chat")
522
+ })
523
+ it("bump uses the CLAMPED effort (xhigh on a low/medium/high model clamps to high → no bump)", () => {
524
+ expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.4", "xhigh")).toBe("llm-chat:premium")
525
+ // gpt-5.4 is premium anyway; the real clamp case:
526
+ expect(buildLlmCreditIdentifier("llm-chat", "gemini-3-flash", "max")).toBe("llm-chat:economy")
527
+ })
528
+ it("back-compat: no effort arg → identical to today for every model", () => {
529
+ for (const m of LLM_MODELS) {
530
+ const before = m.tier === "standard" ? "x" : `x:${m.tier}`
531
+ expect(buildLlmCreditIdentifier("x", m.id)).toBe(before)
532
+ }
533
+ })
534
+ it("resolveLlmCreditId reads reasoningEffort from the raw body", () => {
535
+ expect(resolveLlmCreditId("llm-chat", { llmModel: "gpt-5.6-terra", reasoningEffort: "max" })).toBe("llm-chat:premium")
536
+ })
537
+ })
@@ -28,6 +28,7 @@ describe("per-model prompt length limits", () => {
28
28
  describe("getMaxImagePromptChars", () => {
29
29
  it("returns the verified higher caps (provider supports more than 5000)", () => {
30
30
  expect(getMaxImagePromptChars("nano-banana-2")).toBe(20000)
31
+ expect(getMaxImagePromptChars("nano-banana-2-lite")).toBe(20000)
31
32
  expect(getMaxImagePromptChars("nano-banana-pro")).toBe(20000)
32
33
  expect(getMaxImagePromptChars("gpt-image-2-i2i")).toBe(20000)
33
34
  })
@@ -44,6 +45,8 @@ describe("per-model prompt length limits", () => {
44
45
  it("falls back to IMAGE_PROMPT_MAX for verified-default + unknown providers", () => {
45
46
  expect(getMaxImagePromptChars("flux")).toBe(IMAGE_PROMPT_MAX)
46
47
  expect(getMaxImagePromptChars("imagen4")).toBe(IMAGE_PROMPT_MAX)
48
+ expect(getMaxImagePromptChars("seedream-5-pro")).toBe(IMAGE_PROMPT_MAX) // verified 5000 in KIE schema
49
+ expect(getMaxImagePromptChars("seedream-5-pro-i2i")).toBe(IMAGE_PROMPT_MAX) // verified 5000 in KIE schema
47
50
  expect(getMaxImagePromptChars("grok-i2i")).toBe(IMAGE_PROMPT_MAX) // 390000 doc = sanity-capped
48
51
  expect(getMaxImagePromptChars("flux-kontext")).toBe(IMAGE_PROMPT_MAX) // UNVERIFIED
49
52
  expect(getMaxImagePromptChars(undefined)).toBe(IMAGE_PROMPT_MAX)
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ SEEDANCE_2_CONTINUATION_REF_SEC,
4
+ SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC,
5
+ SEEDANCE_2_EXTEND_STITCH,
6
+ } from "../model-constants.js"
7
+
8
+ /** KIE hard-rejects r2v reference videos below the floor: "the parameter
9
+ * video duration (seconds) specified in the request must be greater than or
10
+ * equal to 1.8 for model dreamina-seedance-2-0-fast in r2v" (2026-07-13,
11
+ * job dbf95612 — the original 1.0s tails made every generate-video-pro
12
+ * continuation segment fail deterministically). Everything this platform
13
+ * sends as a Seedance-2 video reference cuts SEEDANCE_2_CONTINUATION_REF_SEC
14
+ * seconds; do NOT "optimize" it back below the floor. The private-plugin
15
+ * twin constants (nodaro-cloud-plugins chain.ts TAIL_SEC / bridge-math.ts
16
+ * MIN_REF) are guarded by that repo's r2v-ref-floor.test.ts — keep in sync. */
17
+ describe("seedance-2 continuation-reference floor (do not regress)", () => {
18
+ it("the platform's continuation-ref length clears KIE's r2v minimum", () => {
19
+ expect(SEEDANCE_2_CONTINUATION_REF_SEC).toBeGreaterThanOrEqual(SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC)
20
+ })
21
+ it("the extend node's tail IS the shared continuation-ref length (single source)", () => {
22
+ expect(SEEDANCE_2_EXTEND_STITCH.referenceTailSeconds).toBe(SEEDANCE_2_CONTINUATION_REF_SEC)
23
+ })
24
+ })
@@ -5,13 +5,16 @@ import {
5
5
  pickVideoAnalysisBucket, buildVideoAnalysisCreditId, bucketSecondsFromCreditId,
6
6
  videoAnalysisNumWindows,
7
7
  } from "../video-analysis-pricing.js"
8
- import { VIDEO_ANALYSIS_LLM_MODELS } from "../llm-models.js"
8
+ import {
9
+ VIDEO_ANALYSIS_LLM_MODELS, VIDEO_ANALYSIS_TIERS, VIDEO_ANALYSIS_TIER_ORDER,
10
+ DEFAULT_VIDEO_ANALYSIS_TIER, DEFAULT_VIDEO_ANALYSIS_MODEL, resolveVideoAnalysisModel,
11
+ } from "../llm-models.js"
9
12
 
10
13
  // The measured-rate constants and the $-derived `videoAnalysisBucketCredits`
11
- // formula moved to backend/src/lib/pricing/video-analysis-cost.ts (S5) — its
12
- // tests (including the worked-example bucket-credit values and the
13
- // cross-check against VIDEO_ANALYSIS_BUCKET_CREDITS below) live in
14
- // backend/src/lib/pricing/__tests__/video-analysis-cost.test.ts. This file
14
+ // formula are PRIVATE, in @nodaroai/cloud-plugins
15
+ // (src/plugins/video-analysis/cost.ts) — its tests (the worked-example
16
+ // bucket-credit values and the cross-check against VIDEO_ANALYSIS_BUCKET_CREDITS
17
+ // below) live in that private package's __tests__/cost.test.ts. This file
15
18
  // covers only the NON-monetary duration-bucketing, window-batching, and
16
19
  // credit-id-construction logic that stays in the published package.
17
20
 
@@ -40,6 +43,26 @@ describe("video-analysis-pricing", () => {
40
43
  expect(VIDEO_ANALYSIS_LLM_MODELS).toEqual(["gemini-3-flash", "gemini-3.1-pro"])
41
44
  })
42
45
 
46
+ it("tier layer: every tier maps to a real model AND every model is tier-reachable (no vendor leak)", () => {
47
+ // Adding a video-analysis model without a tier would silently leave it
48
+ // unreachable / unnamed — this fails until a tier decision is made.
49
+ const tierTargets = Object.values(VIDEO_ANALYSIS_TIERS)
50
+ for (const m of tierTargets) expect(VIDEO_ANALYSIS_LLM_MODELS).toContain(m)
51
+ for (const m of VIDEO_ANALYSIS_LLM_MODELS) expect(tierTargets).toContain(m)
52
+ expect(new Set(VIDEO_ANALYSIS_TIER_ORDER)).toEqual(new Set(Object.keys(VIDEO_ANALYSIS_TIERS)))
53
+ })
54
+
55
+ it("resolveVideoAnalysisModel: tier → model, raw model passthrough, default pro on empty/unknown", () => {
56
+ expect(DEFAULT_VIDEO_ANALYSIS_TIER).toBe("pro")
57
+ expect(DEFAULT_VIDEO_ANALYSIS_MODEL).toBe("gemini-3.1-pro")
58
+ expect(resolveVideoAnalysisModel("pro")).toBe("gemini-3.1-pro")
59
+ expect(resolveVideoAnalysisModel("fast")).toBe("gemini-3-flash")
60
+ expect(resolveVideoAnalysisModel("gemini-3-flash")).toBe("gemini-3-flash") // raw passthrough
61
+ expect(resolveVideoAnalysisModel(undefined)).toBe("gemini-3.1-pro") // default → pro
62
+ expect(resolveVideoAnalysisModel("")).toBe("gemini-3.1-pro")
63
+ expect(resolveVideoAnalysisModel("nonsense")).toBe("gemini-3.1-pro") // unknown → default, never throws
64
+ })
65
+
43
66
  // Full drift-detection against the live $-formula lives in
44
67
  // backend/src/lib/pricing/__tests__/video-analysis-cost.test.ts (this
45
68
  // package cannot see the formula post-S5). This is a lightweight shape
@@ -7,7 +7,7 @@ import {
7
7
  } from "../video-analysis.js"
8
8
 
9
9
  const slot: EntitySlot = { slotId: "hero", label: "Protagonist", source: "wired-character", role: "person", description: "tan man, mustache, black tee" }
10
- const baseScene = { startSec: 0, endSec: 4, label: "Hook", shotType: "Medium Close-Up", camera: "slow push-in", visual: "{slot:hero} juggles a ball", audio: { mode: "speech" as const, content: "As a kid…", voice: "male, warm" } }
10
+ const baseScene = { startSec: 0, endSec: 4, label: "Hook", shotType: "Medium Close-Up", camera: "slow push-in", visual: "{slot:hero} juggles a ball", audio: [{ mode: "speech" as const, content: "As a kid…", voice: "male, warm" }] }
11
11
 
12
12
  describe("windowAnalysisSchema", () => {
13
13
  it("accepts a zero-scene window (quiet footage is a VALID result)", () => {
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  MAX_IMAGE_PROMPT_CHARS_BY_PROVIDER,
20
20
  getMaxImagePromptChars,
21
21
  PROMPT_HARD_CEILING,
22
+ LLM_TEXT_INPUT_MAX,
22
23
  MAX_VIDEO_PROMPT_CHARS_BY_PROVIDER,
23
24
  getMaxVideoPromptChars,
24
25
  NEGATIVE_PROMPT_MAX,
@@ -92,8 +93,11 @@ export {
92
93
  SEED_SUPPORT,
93
94
  RENDERING_SPEED_SUPPORT,
94
95
  GUIDANCE_SCALE_SUPPORT,
96
+ SEEDANCE_2_PROVIDERS,
95
97
  SEEDANCE_2_REF_LIMITS,
96
98
  SEEDANCE_2_EXTEND_STITCH,
99
+ SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC,
100
+ SEEDANCE_2_CONTINUATION_REF_SEC,
97
101
  NATIVE_ADAPTIVE_ASPECT,
98
102
  VIDEO_REF_LIMITS_BY_PROVIDER,
99
103
  VIDEO_PROVIDERS_REQUIRING_IMAGE,
@@ -249,18 +253,30 @@ export {
249
253
  LLM_MODEL_IDS,
250
254
  STRUCTURED_VISION_MODELS,
251
255
  VIDEO_ANALYSIS_LLM_MODELS,
256
+ VIDEO_ANALYSIS_TIERS,
257
+ type VideoAnalysisTier,
258
+ VIDEO_ANALYSIS_TIER_ORDER,
259
+ DEFAULT_VIDEO_ANALYSIS_TIER,
260
+ DEFAULT_VIDEO_ANALYSIS_MODEL,
261
+ VIDEO_ANALYSIS_TIER_LABELS,
262
+ isVideoAnalysisTier,
263
+ resolveVideoAnalysisModel,
252
264
  LLM_FEATURE_DEFAULTS,
253
265
  LLM_MODALITY_CAPS,
266
+ LLM_REASONING_EFFORTS,
267
+ EFFORT_TIER_BUMP,
254
268
  getLlmModel,
255
269
  getLlmTier,
256
270
  getLlmModalityCaps,
257
271
  buildLlmCreditIdentifier,
258
272
  resolveLlmCreditId,
259
273
  motionGraphicsFeature,
274
+ effectiveReasoningEffort,
260
275
  type LlmTier,
261
276
  type LlmFeature,
262
277
  type KieApiFormat,
263
278
  type LlmModelDef,
279
+ type LlmReasoningEffort,
264
280
  } from "./llm-models.js"
265
281
 
266
282
  export {
package/src/llm-models.ts CHANGED
@@ -16,6 +16,12 @@
16
16
  export type LlmTier = "economy" | "standard" | "premium"
17
17
  export type KieApiFormat = "chat-completions" | "messages" | "responses"
18
18
 
19
+ export const LLM_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const
20
+ export type LlmReasoningEffort = (typeof LLM_REASONING_EFFORTS)[number]
21
+ /** Levels that bill one tier up. `high` is the Claude-family server default — it never bumps. */
22
+ export const EFFORT_TIER_BUMP: ReadonlySet<LlmReasoningEffort> = new Set(["xhigh", "max"])
23
+ const EFFORT_RANK: Record<LlmReasoningEffort, number> = { none: 0, low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
24
+
19
25
  export interface LlmModelDef {
20
26
  id: string
21
27
  displayName: string
@@ -31,16 +37,26 @@ export interface LlmModelDef {
31
37
  maxOutputTokens: number
32
38
  /**
33
39
  * How this model can be forced into schema-valid structured output.
34
- * - "anthropic-tool" direct Anthropic SDK forced tool_choice (guaranteed).
35
- * - "kie-response-format" → KIE chat-completions `response_format: json_schema`
36
- * (verified enforced for Gemini via KIE).
37
- * - undefined no native structured mode (GPT-via-KIE doesn't honor
38
- * response_format); callers fall back to parse + retry.
40
+ * - "anthropic-tool" forced tool_choice on the Claude wire (direct SDK
41
+ * guaranteed; KIE's proxy re-serializes the tool call
42
+ * as a `<tool_calls>` text tag — decoded in llm-client).
43
+ * - "kie-response-format" KIE chat-completions `response_format: json_schema`
44
+ * (verified enforced for Gemini via KIE).
45
+ * - "responses-json-schema" → KIE codex/v1/responses `text.format: json_schema`
46
+ * (live-verified 2026-07-14 for gpt-5.4/5.5 and the
47
+ * whole GPT-5.6 family, text AND vision inputs).
48
+ * - undefined → no native mode; callers fall back to parse + retry.
39
49
  * Capability-driven so `llmCompleteStructured` never hardcodes provider ids.
40
50
  */
41
- structuredOutputMode?: "anthropic-tool" | "kie-response-format"
51
+ structuredOutputMode?: "anthropic-tool" | "kie-response-format" | "responses-json-schema"
42
52
  /** If set, fallback to direct Anthropic SDK with this model ID when KIE.ai fails */
43
53
  directFallbackModel?: string
54
+ /** Effort levels this model accepts (ascending). Absent/empty = no effort lever, picker hidden. */
55
+ reasoningEfforts?: readonly LlmReasoningEffort[]
56
+ /** false = model rejects `temperature` (Claude 5-era, GPT-5.6). Absent = accepts. */
57
+ supportsTemperature?: false
58
+ /** Claude-only: KIE is the preferred routing, direct Anthropic the fallback. */
59
+ preferKie?: true
44
60
  }
45
61
 
46
62
  export const LLM_MODELS: readonly LlmModelDef[] = [
@@ -81,6 +97,7 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
81
97
  supportsImages: true,
82
98
  maxOutputTokens: 16384,
83
99
  directFallbackModel: "claude-sonnet-4-6",
100
+ reasoningEfforts: ["low", "medium", "high", "max"],
84
101
  },
85
102
  {
86
103
  id: "gpt-5.2",
@@ -117,6 +134,9 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
117
134
  supportsImages: true,
118
135
  maxOutputTokens: 16384,
119
136
  directFallbackModel: "claude-opus-4-7",
137
+ reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
138
+ supportsTemperature: false,
139
+ preferKie: true,
120
140
  },
121
141
  {
122
142
  id: "gpt-5.4",
@@ -126,8 +146,99 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
126
146
  kieFormat: "responses",
127
147
  kieSlugOrModel: "gpt-5-4",
128
148
  vendor: "openai",
149
+ structuredOutputMode: "responses-json-schema",
150
+ supportsImages: true,
151
+ maxOutputTokens: 16384,
152
+ reasoningEfforts: ["low", "medium", "high"],
153
+ },
154
+ {
155
+ id: "gpt-5.5",
156
+ displayName: "GPT-5.5",
157
+ desc: "Previous flagship GPT, deep reasoning",
158
+ tier: "premium",
159
+ kieFormat: "responses",
160
+ kieSlugOrModel: "gpt-5-5",
161
+ vendor: "openai",
162
+ structuredOutputMode: "responses-json-schema",
163
+ supportsImages: true,
164
+ maxOutputTokens: 16384,
165
+ reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
166
+ supportsTemperature: false,
167
+ },
168
+ {
169
+ id: "gpt-5.6-luna",
170
+ displayName: "GPT-5.6 Luna",
171
+ desc: "Fastest GPT-5.6, high-volume workloads",
172
+ tier: "economy",
173
+ kieFormat: "responses",
174
+ kieSlugOrModel: "gpt-5-6-luna",
175
+ vendor: "openai",
176
+ structuredOutputMode: "responses-json-schema",
177
+ supportsImages: true,
178
+ maxOutputTokens: 16384,
179
+ reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
180
+ supportsTemperature: false,
181
+ },
182
+ {
183
+ id: "gpt-5.6-terra",
184
+ displayName: "GPT-5.6 Terra",
185
+ desc: "Balanced GPT-5.6 for production work",
186
+ tier: "standard",
187
+ kieFormat: "responses",
188
+ kieSlugOrModel: "gpt-5-6-terra",
189
+ vendor: "openai",
190
+ structuredOutputMode: "responses-json-schema",
191
+ supportsImages: true,
192
+ maxOutputTokens: 16384,
193
+ reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
194
+ supportsTemperature: false,
195
+ },
196
+ {
197
+ id: "gpt-5.6-sol",
198
+ displayName: "GPT-5.6 Sol",
199
+ desc: "Flagship GPT-5.6, deepest reasoning",
200
+ tier: "premium",
201
+ kieFormat: "responses",
202
+ kieSlugOrModel: "gpt-5-6-sol",
203
+ vendor: "openai",
204
+ structuredOutputMode: "responses-json-schema",
205
+ supportsImages: true,
206
+ maxOutputTokens: 16384,
207
+ reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
208
+ supportsTemperature: false,
209
+ },
210
+ // grok-4.5 deferred — KIE chat endpoint not yet live (2026-07-13); add entry + rate row + docs when it activates.
211
+ {
212
+ id: "claude-sonnet-5",
213
+ displayName: "Claude Sonnet 5",
214
+ desc: "Near-Opus quality at Sonnet cost",
215
+ tier: "standard",
216
+ kieFormat: "messages",
217
+ kieSlugOrModel: "claude-sonnet-5",
218
+ vendor: "anthropic",
219
+ structuredOutputMode: "anthropic-tool",
220
+ supportsImages: true,
221
+ maxOutputTokens: 16384,
222
+ directFallbackModel: "claude-sonnet-5",
223
+ reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
224
+ supportsTemperature: false,
225
+ preferKie: true,
226
+ },
227
+ {
228
+ id: "claude-opus-4.8",
229
+ displayName: "Claude Opus 4.8",
230
+ desc: "Most capable Claude, long-horizon work",
231
+ tier: "premium",
232
+ kieFormat: "messages",
233
+ kieSlugOrModel: "claude-opus-4-8",
234
+ vendor: "anthropic",
235
+ structuredOutputMode: "anthropic-tool",
129
236
  supportsImages: true,
130
237
  maxOutputTokens: 16384,
238
+ directFallbackModel: "claude-opus-4-8",
239
+ reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
240
+ supportsTemperature: false,
241
+ preferKie: true,
131
242
  },
132
243
  ] as const
133
244
 
@@ -136,9 +247,10 @@ export const LLM_MODEL_IDS = LLM_MODELS.map((m) => m.id)
136
247
  /** Vision models that can return GUARANTEED structured output — the
137
248
  * describe-to-picker analyzer forces a schema over an image, so its model
138
249
  * pickers AND the backend route gate offer exactly these: Anthropic (forced
139
- * tool) + Gemini (KIE `response_format`). GPT-via-KIE has no native structured
140
- * mode (parse+retry only), so it's excluded. Single source of truth so the
141
- * picker, the config panel, and the route gate can't drift. */
250
+ * tool), Gemini (KIE `response_format`), and GPT responses-format models
251
+ * (KIE `text.format` json_schema vision+schema live-verified 2026-07-14).
252
+ * Single source of truth so the picker, the config panel, and the route gate
253
+ * can't drift. */
142
254
  export const STRUCTURED_VISION_MODELS = LLM_MODELS.filter(
143
255
  (m) => m.supportsImages && m.structuredOutputMode != null,
144
256
  )
@@ -201,6 +313,12 @@ export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean;
201
313
  "claude-opus-4.7": { image: true, video: false, audio: false },
202
314
  "gpt-5.2": { image: true, video: false, audio: false },
203
315
  "gpt-5.4": { image: true, video: false, audio: false },
316
+ "gpt-5.5": { image: true, video: false, audio: false },
317
+ "gpt-5.6-luna": { image: true, video: false, audio: false },
318
+ "gpt-5.6-terra": { image: true, video: false, audio: false },
319
+ "gpt-5.6-sol": { image: true, video: false, audio: false },
320
+ "claude-sonnet-5": { image: true, video: false, audio: false },
321
+ "claude-opus-4.8": { image: true, video: false, audio: false },
204
322
  }
205
323
 
206
324
  /** Capability lookup with safe default — unknown models get image-only. */
@@ -217,29 +335,87 @@ export function getLlmTier(id: string): LlmTier {
217
335
  return getLlmModel(id)?.tier ?? "standard"
218
336
  }
219
337
 
338
+ /** Highest level the model supports that is ≤ the requested level; undefined = treat as Auto. */
339
+ export function effectiveReasoningEffort(
340
+ modelId: string | undefined,
341
+ requested?: string,
342
+ ): LlmReasoningEffort | undefined {
343
+ if (!requested || !(requested in EFFORT_RANK)) return undefined
344
+ const levels = getLlmModel(modelId ?? "")?.reasoningEfforts
345
+ if (!levels || levels.length === 0) return undefined
346
+ const req = requested as LlmReasoningEffort
347
+ let best: LlmReasoningEffort | undefined
348
+ for (const l of levels) {
349
+ if (EFFORT_RANK[l] <= EFFORT_RANK[req] && (best === undefined || EFFORT_RANK[l] > EFFORT_RANK[best])) best = l
350
+ }
351
+ return best
352
+ }
353
+
220
354
  /**
221
355
  * Build a composite credit identifier for an LLM feature.
222
356
  * - economy tier → "ai-writer:economy"
223
357
  * - standard tier → "ai-writer" (no suffix — backward compatible)
224
358
  * - premium tier → "ai-writer:premium"
359
+ * A reasoning effort of "xhigh" or "max" (after clamping to what the model
360
+ * actually supports) bills one tier up (economy→standard, standard→premium;
361
+ * premium stays premium). `high` is the Claude-family server default and
362
+ * never bumps.
225
363
  */
226
- export function buildLlmCreditIdentifier(feature: string, modelId?: string): string {
364
+ export function buildLlmCreditIdentifier(feature: string, modelId?: string, reasoningEffort?: string): string {
227
365
  if (!modelId) return feature
228
- const tier = getLlmTier(modelId)
366
+ let tier = getLlmTier(modelId)
367
+ const eff = effectiveReasoningEffort(modelId, reasoningEffort)
368
+ if (eff !== undefined && EFFORT_TIER_BUMP.has(eff)) {
369
+ if (tier === "economy") tier = "standard"
370
+ else if (tier === "standard") tier = "premium"
371
+ }
229
372
  if (tier === "standard") return feature
230
373
  return `${feature}:${tier}`
231
374
  }
232
375
 
233
376
  /**
234
- * Resolve llmModel from raw body for creditGuard preHandler (before Zod parsing).
235
- * Returns the credit identifier for the given feature + optional model.
377
+ * Resolve llmModel (+ reasoningEffort) from raw body for creditGuard preHandler
378
+ * (before Zod parsing). Returns the credit identifier for the given feature.
236
379
  */
237
380
  export function resolveLlmCreditId(feature: string, body: unknown): string {
238
- const llmModel = (body as Record<string, unknown>)?.llmModel as string | undefined
239
- return buildLlmCreditIdentifier(feature, llmModel)
381
+ const b = body as Record<string, unknown> | undefined
382
+ return buildLlmCreditIdentifier(feature, b?.llmModel as string | undefined, b?.reasoningEffort as string | undefined)
240
383
  }
241
384
 
242
385
  /** Models capable of video-analysis: capability-derived, never hand-listed (route-enum-sync convention). */
243
386
  export const VIDEO_ANALYSIS_LLM_MODELS: string[] = LLM_MODELS
244
387
  .filter((m) => getLlmModalityCaps(m.id).video && getLlmModalityCaps(m.id).audio)
245
388
  .map((m) => m.id)
389
+
390
+ /**
391
+ * Video-analysis quality TIERS — the ONLY analyzer identifiers exposed to users
392
+ * (API, UI, docs). Each maps to an internal analysis model; the underlying
393
+ * vendor/model name is never surfaced. Default is `pro`. The guard test
394
+ * (video-analysis-pricing.test.ts) asserts every tier target is a real
395
+ * VIDEO_ANALYSIS_LLM_MODELS member AND every such model is reachable by a tier,
396
+ * so adding a video model forces a tier decision instead of silently leaking.
397
+ */
398
+ export const VIDEO_ANALYSIS_TIERS = { fast: "gemini-3-flash", pro: "gemini-3.1-pro" } as const
399
+ export type VideoAnalysisTier = keyof typeof VIDEO_ANALYSIS_TIERS
400
+ /** UI/listing order — recommended (pro) first. */
401
+ export const VIDEO_ANALYSIS_TIER_ORDER = ["pro", "fast"] as const
402
+ export const DEFAULT_VIDEO_ANALYSIS_TIER: VideoAnalysisTier = "pro"
403
+ export const DEFAULT_VIDEO_ANALYSIS_MODEL: string = VIDEO_ANALYSIS_TIERS[DEFAULT_VIDEO_ANALYSIS_TIER]
404
+ /** Neutral, vendor-free display labels for the UI. */
405
+ export const VIDEO_ANALYSIS_TIER_LABELS: Record<VideoAnalysisTier, string> = { fast: "Fast", pro: "Pro" }
406
+
407
+ export function isVideoAnalysisTier(v: string): v is VideoAnalysisTier {
408
+ return Object.prototype.hasOwnProperty.call(VIDEO_ANALYSIS_TIERS, v)
409
+ }
410
+
411
+ /**
412
+ * Resolve a user-supplied tier (`"fast"`/`"pro"`) OR a raw internal model id to
413
+ * the internal analysis model id. Empty/unknown → the default tier's model.
414
+ * Real model ids pass through (back-compat for existing stored `llmModel`
415
+ * values); anything else falls back to the default rather than erroring.
416
+ */
417
+ export function resolveVideoAnalysisModel(input?: string | null): string {
418
+ if (input && isVideoAnalysisTier(input)) return VIDEO_ANALYSIS_TIERS[input]
419
+ if (input && VIDEO_ANALYSIS_LLM_MODELS.includes(input)) return input
420
+ return DEFAULT_VIDEO_ANALYSIS_MODEL
421
+ }