@nodaro/shared 2.7.0 → 2.8.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.7.0",
3
+ "version": "2.8.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",
@@ -6,12 +6,18 @@ import {
6
6
  LLM_REASONING_EFFORTS,
7
7
  getLlmModel,
8
8
  getLlmTier,
9
+ getLlmModalityCaps,
10
+ VIDEO_ANALYSIS_LLM_MODELS,
9
11
  buildLlmCreditIdentifier,
10
12
  resolveLlmCreditId,
11
13
  motionGraphicsFeature,
12
14
  effectiveReasoningEffort,
13
15
  supportsAdvancedMode,
14
16
  availableReasoningEfforts,
17
+ LLM_VENDOR_ORDER,
18
+ LLM_VENDOR_LABELS,
19
+ groupLlmModelsByVendor,
20
+ orderedLlmModels,
15
21
  } from "../llm-models.js"
16
22
  import type { LlmModelDef, LlmTier, LlmFeature } from "../llm-models.js"
17
23
  import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
@@ -28,6 +34,7 @@ import { PIPELINE_PINNABLE_SCRIPT_LLMS } from "../pipeline-types.js"
28
34
  const EXPECTED_MODEL_IDS = [
29
35
  "gemini-3-flash",
30
36
  "gemini-3.6-flash",
37
+ "gemini-3.7-flash",
31
38
  "claude-haiku-4.5",
32
39
  "claude-sonnet-4.6",
33
40
  "gpt-5.2",
@@ -38,6 +45,7 @@ const EXPECTED_MODEL_IDS = [
38
45
  "gpt-5.6-luna",
39
46
  "gpt-5.6-terra",
40
47
  "gpt-5.6-sol",
48
+ "grok-4.6",
41
49
  "claude-sonnet-5",
42
50
  "claude-opus-4.8",
43
51
  "claude-opus-5",
@@ -86,13 +94,13 @@ describe("LLM_MODELS data integrity", () => {
86
94
  expect(new Set(ids).size).toBe(ids.length)
87
95
  })
88
96
 
89
- it("has 4 economy, 4 standard, 8 premium models", () => {
97
+ it("has 5 economy, 5 standard, 8 premium models", () => {
90
98
  const tierCounts: Record<LlmTier, number> = { economy: 0, standard: 0, premium: 0 }
91
99
  for (const model of LLM_MODELS) {
92
100
  tierCounts[model.tier]++
93
101
  }
94
- expect(tierCounts.economy).toBe(4)
95
- expect(tierCounts.standard).toBe(4)
102
+ expect(tierCounts.economy).toBe(5)
103
+ expect(tierCounts.standard).toBe(5)
96
104
  expect(tierCounts.premium).toBe(8)
97
105
  })
98
106
 
@@ -103,11 +111,12 @@ describe("LLM_MODELS data integrity", () => {
103
111
  expect(formats).toContain("responses")
104
112
  })
105
113
 
106
- it("all three vendors are represented", () => {
114
+ it("all four vendors are represented", () => {
107
115
  const vendors = new Set(LLM_MODELS.map((m) => m.vendor))
108
116
  expect(vendors).toContain("anthropic")
109
117
  expect(vendors).toContain("google")
110
118
  expect(vendors).toContain("openai")
119
+ expect(vendors).toContain("xai")
111
120
  })
112
121
 
113
122
  it("all models support images", () => {
@@ -472,6 +481,7 @@ describe("STRUCTURED_VISION_MODELS", () => {
472
481
  "claude-sonnet-4.6",
473
482
  "gemini-3-flash",
474
483
  "gemini-3.6-flash",
484
+ "gemini-3.7-flash",
475
485
  "gemini-3.1-pro",
476
486
  "claude-sonnet-5",
477
487
  "claude-opus-4.8",
@@ -484,15 +494,18 @@ describe("STRUCTURED_VISION_MODELS", () => {
484
494
  "gpt-5.6-luna",
485
495
  "gpt-5.6-terra",
486
496
  "gpt-5.6-sol",
497
+ // responses-format Grok — vision + text.format live-verified 2026-08-18.
498
+ "grok-4.6",
487
499
  ].sort(),
488
500
  )
489
501
  })
490
502
 
491
- it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI (responses text.format) vendors", () => {
503
+ it("includes Anthropic (forced-tool), Gemini (response_format), and OpenAI/xAI (responses text.format) vendors", () => {
492
504
  const vendors = new Set(STRUCTURED_VISION_MODELS.map((m) => m.vendor))
493
505
  expect(vendors).toContain("anthropic")
494
506
  expect(vendors).toContain("google")
495
507
  expect(vendors).toContain("openai")
508
+ expect(vendors).toContain("xai")
496
509
  })
497
510
 
498
511
  it("excludes chat-completions GPT models — no native structured mode there (parse+retry only)", () => {
@@ -509,9 +522,9 @@ describe("STRUCTURED_VISION_MODELS", () => {
509
522
  })
510
523
 
511
524
  // ---------------------------------------------------------------------------
512
- // Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8)
513
- // grok-4.5 is DEFERRED its chat endpoint is not live on the provider yet
514
- // (2026-07-13); its tests are intentionally omitted here.
525
+ // Reasoning effort registry (GPT-5.6 / Claude Sonnet 5 / Claude Opus 4.8 /
526
+ // Grok 4.6 — grok-4.5 was deferred on 2026-07-13 because its chat endpoint
527
+ // wasn't live; grok-4.6 is its activation, live-verified 2026-08-18.)
515
528
  // ---------------------------------------------------------------------------
516
529
  describe("reasoning effort registry", () => {
517
530
  it("every reasoningEfforts list is a subset of the superset, in ascending order", () => {
@@ -526,6 +539,7 @@ describe("reasoning effort registry", () => {
526
539
  expect(getLlmTier("gpt-5.6-luna")).toBe("economy")
527
540
  expect(getLlmTier("gpt-5.6-terra")).toBe("standard")
528
541
  expect(getLlmTier("gpt-5.6-sol")).toBe("premium")
542
+ expect(getLlmTier("grok-4.6")).toBe("standard")
529
543
  expect(getLlmTier("claude-sonnet-5")).toBe("standard")
530
544
  expect(getLlmTier("claude-opus-4.8")).toBe("premium")
531
545
  expect(getLlmTier("gpt-5.5")).toBe("premium")
@@ -557,6 +571,62 @@ describe("reasoning effort registry", () => {
557
571
  expect(getLlmModel("claude-fable-5")?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"])
558
572
  expect(getLlmModel("claude-fable-5")?.directFallbackModel).toBe("claude-fable-5")
559
573
  })
574
+
575
+ it("grok-4.6: responses format on the grok family path, KIE's documented effort enum, no sampling params, reasons by default", () => {
576
+ const m = getLlmModel("grok-4.6")
577
+ expect(m?.vendor).toBe("xai")
578
+ expect(m?.kieFormat).toBe("responses")
579
+ expect(m?.kieSlugOrModel).toBe("grok-4-6")
580
+ expect(m?.structuredOutputMode).toBe("responses-json-schema")
581
+ // KIE's enum is low..xhigh with NO `none` — the endpoint reasons
582
+ // unconditionally (thinkingDefaultOn), so `none` would be a lie.
583
+ expect(m?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"])
584
+ // Live-probed 2026-08-18: temperature is silently ignored — never send it.
585
+ expect(m?.supportsTemperature).toBe(false)
586
+ // Load-bearing: consumers floor max_tokens off this flag (a trivial probe
587
+ // spent 169/170 output tokens on reasoning with no reasoning param sent).
588
+ expect(m?.thinkingDefaultOn).toBe(true)
589
+ // KIE-only: no direct lane on either vendor SDK.
590
+ expect(m?.directFallbackModel).toBeUndefined()
591
+ expect(m?.directGeminiModel).toBeUndefined()
592
+ })
593
+
594
+ it("grok-4.6 resolves from its dash-form wire slug", () => {
595
+ expect(getLlmModel("grok-4-6")?.id).toBe("grok-4.6")
596
+ })
597
+ })
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // gemini-3.7-flash exposure (both lanes live-verified 2026-08-18: KIE
601
+ // chat-completions with reasoning_effort + enforced response_format, and the
602
+ // direct Google id resolving on generativelanguage)
603
+ // ---------------------------------------------------------------------------
604
+ describe("gemini-3.7-flash exposure", () => {
605
+ it("KIE-first chat-completions with a direct lane — 3.6-flash's proven shape", () => {
606
+ const m = getLlmModel("gemini-3.7-flash")
607
+ expect(m?.tier).toBe("economy")
608
+ expect(m?.vendor).toBe("google")
609
+ expect(m?.kieFormat).toBe("chat-completions")
610
+ expect(m?.kieSlugOrModel).toBe("gemini-3-7-flash-openai")
611
+ // KIE-first on purpose (no preferDirect): the cheap lane serves Generate
612
+ // Text; the direct lane is Advanced mode + the reliability fallback.
613
+ expect(m?.preferDirect).toBeUndefined()
614
+ expect(m?.directGeminiModel).toBe("gemini-3.7-flash")
615
+ expect(m?.reasoningEfforts).toEqual(["low", "high"])
616
+ expect(m?.structuredOutputMode).toBe("kie-response-format")
617
+ expect(supportsAdvancedMode("gemini-3.7-flash")).toBe(true)
618
+ expect(availableReasoningEfforts("gemini-3.7-flash", true)).toEqual(["none", "low", "medium", "high"])
619
+ })
620
+
621
+ it("stays OUT of video-analysis — image-only modality caps by decision, not omission", () => {
622
+ // Full video+audio caps would auto-enroll it in VIDEO_ANALYSIS_LLM_MODELS
623
+ // and force a VA tier + pricing decision that is deliberately deferred
624
+ // while the smart-family A/B routes this model internally (2026-08-18).
625
+ // If this test goes red, someone flipped the caps — that flip is only
626
+ // valid TOGETHER with the VA-side tier/pricing decision.
627
+ expect(getLlmModalityCaps("gemini-3.7-flash")).toEqual({ image: true, video: false, audio: false })
628
+ expect(VIDEO_ANALYSIS_LLM_MODELS).not.toContain("gemini-3.7-flash")
629
+ })
560
630
  })
561
631
 
562
632
  describe("effectiveReasoningEffort", () => {
@@ -584,6 +654,7 @@ describe("buildLlmCreditIdentifier effort bump (xhigh/max only)", () => {
584
654
  })
585
655
  it("standard + xhigh → premium", () => {
586
656
  expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-terra", "xhigh")).toBe("llm-chat:premium")
657
+ expect(buildLlmCreditIdentifier("llm-chat", "grok-4.6", "xhigh")).toBe("llm-chat:premium")
587
658
  })
588
659
  it("premium + max stays premium", () => {
589
660
  expect(buildLlmCreditIdentifier("llm-chat", "gpt-5.6-sol", "max")).toBe("llm-chat:premium")
@@ -767,3 +838,58 @@ describe("lane-aware reasoning efforts", () => {
767
838
  expect(bad).toEqual([])
768
839
  })
769
840
  })
841
+
842
+ // ---------------------------------------------------------------------------
843
+ // Vendor grouping — the ONE ordering every LLM model menu renders
844
+ // ---------------------------------------------------------------------------
845
+ describe("groupLlmModelsByVendor / orderedLlmModels", () => {
846
+ it("every vendor in the union has an order slot AND a label (totality)", () => {
847
+ // A new vendor added to LlmVendor without a menu decision would silently
848
+ // sort its models to the end of every picker unlabeled — fail here instead.
849
+ const vendorsInUse = new Set(LLM_MODELS.map((m) => m.vendor))
850
+ for (const v of vendorsInUse) {
851
+ expect(LLM_VENDOR_ORDER, `vendor "${v}" missing from LLM_VENDOR_ORDER`).toContain(v)
852
+ expect(LLM_VENDOR_LABELS[v], `vendor "${v}" missing from LLM_VENDOR_LABELS`).toBeTruthy()
853
+ }
854
+ for (const v of LLM_VENDOR_ORDER) {
855
+ expect(LLM_VENDOR_LABELS[v]).toBeTruthy()
856
+ }
857
+ })
858
+
859
+ it("covers every model exactly once, in vendor order", () => {
860
+ const groups = groupLlmModelsByVendor()
861
+ const flattened = groups.flatMap((g) => g.models.map((m) => m.id))
862
+ expect(flattened.sort()).toEqual(LLM_MODELS.map((m) => m.id).sort())
863
+ expect(new Set(flattened).size).toBe(flattened.length)
864
+ const groupVendors = groups.map((g) => g.vendor)
865
+ expect(groupVendors).toEqual(LLM_VENDOR_ORDER.filter((v) => groupVendors.includes(v)))
866
+ })
867
+
868
+ it("inside each group models are tier-ordered economy → standard → premium", () => {
869
+ const rank = { economy: 0, standard: 1, premium: 2 } as const
870
+ for (const g of groupLlmModelsByVendor()) {
871
+ const ranks = g.models.map((m) => rank[m.tier])
872
+ expect(ranks, g.vendor).toEqual([...ranks].sort((a, b) => a - b))
873
+ }
874
+ })
875
+
876
+ it("groups carry their display label and omit empty groups after a filter", () => {
877
+ const groups = groupLlmModelsByVendor(LLM_MODELS.filter((m) => m.vendor === "xai"))
878
+ expect(groups).toHaveLength(1)
879
+ expect(groups[0].label).toBe("xAI")
880
+ expect(groups[0].models.map((m) => m.id)).toEqual(["grok-4.6"])
881
+ })
882
+
883
+ it("does not mutate the registry (registry order is load-bearing for LLM_MODEL_IDS)", () => {
884
+ const before = LLM_MODELS.map((m) => m.id)
885
+ groupLlmModelsByVendor()
886
+ orderedLlmModels()
887
+ expect(LLM_MODELS.map((m) => m.id)).toEqual(before)
888
+ })
889
+
890
+ it("orderedLlmModels is the flattened grouping", () => {
891
+ expect(orderedLlmModels().map((m) => m.id)).toEqual(
892
+ groupLlmModelsByVendor().flatMap((g) => g.models.map((m) => m.id)),
893
+ )
894
+ })
895
+ })
package/src/index.ts CHANGED
@@ -281,6 +281,12 @@ export {
281
281
  LLM_MODELS,
282
282
  LLM_MODEL_IDS,
283
283
  STRUCTURED_VISION_MODELS,
284
+ LLM_VENDOR_ORDER,
285
+ LLM_VENDOR_LABELS,
286
+ groupLlmModelsByVendor,
287
+ orderedLlmModels,
288
+ type LlmVendor,
289
+ type LlmModelGroup,
284
290
  VIDEO_ANALYSIS_LLM_MODELS,
285
291
  VIDEO_ANALYSIS_TIERS,
286
292
  VIDEO_ANALYSIS_LEGACY_MODELS,
package/src/llm-models.ts CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  export type LlmTier = "economy" | "standard" | "premium"
17
17
  export type KieApiFormat = "chat-completions" | "messages" | "responses"
18
+ export type LlmVendor = "anthropic" | "google" | "openai" | "xai"
18
19
 
19
20
  export const LLM_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const
20
21
  export type LlmReasoningEffort = (typeof LLM_REASONING_EFFORTS)[number]
@@ -32,7 +33,7 @@ export interface LlmModelDef {
32
33
  * For messages: the model id sent in the body (e.g. "claude-haiku-4-5-v1messages").
33
34
  * For responses: the model id sent in the body (e.g. "gpt-5-4"). */
34
35
  kieSlugOrModel: string
35
- vendor: "anthropic" | "google" | "openai"
36
+ vendor: LlmVendor
36
37
  supportsImages: boolean
37
38
  maxOutputTokens: number
38
39
  /**
@@ -159,6 +160,30 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
159
160
  // direct is the reliability fallback only.
160
161
  directGeminiModel: "gemini-3.6-flash",
161
162
  },
163
+ {
164
+ id: "gemini-3.7-flash",
165
+ displayName: "Gemini 3.7 Flash",
166
+ desc: "Newest fast Gemini, agentic-tuned",
167
+ tier: "economy",
168
+ kieFormat: "chat-completions",
169
+ // KIE serves it on the OpenAI-compatible dialect under this slug
170
+ // (docs.kie.ai/market/gemini/gemini-3-7-flash-openai.md) — same
171
+ // chat-completions path shape as gemini-3.6-flash.
172
+ kieSlugOrModel: "gemini-3-7-flash-openai",
173
+ vendor: "google",
174
+ structuredOutputMode: "kie-response-format",
175
+ supportsImages: true,
176
+ // Google's own cap is 65,536, but the field feeds BOTH lanes and the KIE
177
+ // flash endpoints cap at 8192 (the measured 3.6 posture) — stay at the
178
+ // KIE-safe intersection, same reasoning as `reasoningEfforts` below.
179
+ maxOutputTokens: 8192,
180
+ // KIE's 3.7 endpoint enumerates reasoning_effort low | high (verified
181
+ // against its OpenAPI spec 2026-08-18), identical to 3.6.
182
+ reasoningEfforts: ["low", "high"],
183
+ // Assumed parity with 3.6 pending a live probe on the direct lane.
184
+ directReasoningEfforts: ["none", "low", "medium", "high"],
185
+ directGeminiModel: "gemini-3.7-flash",
186
+ },
162
187
  {
163
188
  id: "claude-haiku-4.5",
164
189
  displayName: "Claude Haiku 4.5",
@@ -321,7 +346,35 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
321
346
  reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
322
347
  supportsTemperature: false,
323
348
  },
324
- // grok-4.5 deferred — KIE chat endpoint not yet live (2026-07-13); add entry + rate row + docs when it activates.
349
+ {
350
+ id: "grok-4.6",
351
+ displayName: "Grok 4.6",
352
+ desc: "xAI flagship, strong reasoning",
353
+ tier: "standard",
354
+ kieFormat: "responses",
355
+ // KIE serves Grok on the responses dialect under its own family path —
356
+ // grok/v1/responses, NOT codex/v1/responses (llm-client derives the path
357
+ // from `vendor`). Live-verified end-to-end 2026-08-18: array `input`,
358
+ // `developer` system role, `input_image` URL vision, `text.format`
359
+ // json_schema enforcement, SSE `response.output_text.delta` stream, and
360
+ // `credits_consumed` actual-cost capture. (grok-4.5 was deferred 2026-07-13
361
+ // because none of this was live; 4.6 is its activation.)
362
+ kieSlugOrModel: "grok-4-6",
363
+ vendor: "xai",
364
+ structuredOutputMode: "responses-json-schema",
365
+ supportsImages: true,
366
+ maxOutputTokens: 16384,
367
+ // KIE's documented enum, each level live-verified (echoed back) 2026-08-18.
368
+ // No `none`: the endpoint reasons unconditionally (see thinkingDefaultOn).
369
+ reasoningEfforts: ["low", "medium", "high", "xhigh"],
370
+ // Live-probed 2026-08-18: `temperature` is silently IGNORED (request echo
371
+ // stays at the 0.7 default), so never send it — same treatment as GPT-5.5+.
372
+ supportsTemperature: false,
373
+ // Reasons with NO reasoning param sent (effort defaults to "low" server-side
374
+ // — a trivial probe spent 169 of 170 output tokens on reasoning), so every
375
+ // call needs output headroom, not just xhigh.
376
+ thinkingDefaultOn: true,
377
+ },
325
378
  {
326
379
  id: "claude-sonnet-5",
327
380
  displayName: "Claude Sonnet 5",
@@ -407,6 +460,55 @@ export const STRUCTURED_VISION_MODELS = LLM_MODELS.filter(
407
460
  (m) => m.supportsImages && m.structuredOutputMode != null,
408
461
  )
409
462
 
463
+ /**
464
+ * Vendor presentation order + labels for model pickers. Every LlmVendor MUST
465
+ * appear in the order list (guarded by a registry test) so a new vendor can't
466
+ * ship with its models silently sorted to the end of every menu unlabeled.
467
+ * Alphabetical on purpose: stable, and no vendor-preference fights.
468
+ */
469
+ export const LLM_VENDOR_ORDER: readonly LlmVendor[] = ["anthropic", "google", "openai", "xai"]
470
+ export const LLM_VENDOR_LABELS: Record<LlmVendor, string> = {
471
+ anthropic: "Anthropic",
472
+ google: "Google",
473
+ openai: "OpenAI",
474
+ xai: "xAI",
475
+ }
476
+
477
+ const TIER_RANK: Record<LlmTier, number> = { economy: 0, standard: 1, premium: 2 }
478
+
479
+ export interface LlmModelGroup {
480
+ vendor: LlmVendor
481
+ /** Display heading for the group (LLM_VENDOR_LABELS[vendor]). */
482
+ label: string
483
+ models: LlmModelDef[]
484
+ }
485
+
486
+ /**
487
+ * The ONE ordering every LLM model menu renders: grouped by vendor (in
488
+ * LLM_VENDOR_ORDER), and inside each group sorted economy → standard → premium
489
+ * (registry order breaks ties, which keeps family generations adjacent).
490
+ * A flat registry-order dump was genuinely hard to scan at 17 models — every
491
+ * picker (config panel, quick strips, quick toolbar) derives from this so the
492
+ * menus can't drift apart. Groups with no models (after `filter`) are omitted.
493
+ */
494
+ export function groupLlmModelsByVendor(models: readonly LlmModelDef[] = LLM_MODELS): LlmModelGroup[] {
495
+ const groups: LlmModelGroup[] = []
496
+ for (const vendor of LLM_VENDOR_ORDER) {
497
+ const members = models
498
+ .filter((m) => m.vendor === vendor)
499
+ .sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier])
500
+ if (members.length > 0) groups.push({ vendor, label: LLM_VENDOR_LABELS[vendor], models: members })
501
+ }
502
+ return groups
503
+ }
504
+
505
+ /** {@link groupLlmModelsByVendor} flattened — for menus that can't render
506
+ * group headers (e.g. the compact node quick strips) but should still read
507
+ * vendor-clustered and tier-ordered. */
508
+ export function orderedLlmModels(models: readonly LlmModelDef[] = LLM_MODELS): LlmModelDef[] {
509
+ return groupLlmModelsByVendor(models).flatMap((g) => g.models)
510
+ }
511
+
410
512
  export type LlmFeature =
411
513
  | "ai-writer"
412
514
  | "llm-chat"
@@ -465,6 +567,12 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
465
567
  export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean; audio: boolean }> = {
466
568
  "gemini-3-flash": { image: true, video: true, audio: true },
467
569
  "gemini-3.6-flash": { image: true, video: true, audio: true },
570
+ // gemini-3.7-flash is IMAGE-ONLY by DECISION, not omission: full video+audio
571
+ // caps would auto-enroll it in VIDEO_ANALYSIS_LLM_MODELS (derived below) and
572
+ // force a video-analysis tier + pricing decision that is deliberately
573
+ // deferred while the smart-family A/B routes this model internally (#747).
574
+ // Flip these two flags ONLY together with that VA-side decision.
575
+ "gemini-3.7-flash": { image: true, video: false, audio: false },
468
576
  "gemini-3.1-pro": { image: true, video: true, audio: true },
469
577
  "claude-haiku-4.5": { image: true, video: false, audio: false },
470
578
  "claude-sonnet-4.6": { image: true, video: false, audio: false },
@@ -475,6 +583,7 @@ export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean;
475
583
  "gpt-5.6-luna": { image: true, video: false, audio: false },
476
584
  "gpt-5.6-terra": { image: true, video: false, audio: false },
477
585
  "gpt-5.6-sol": { image: true, video: false, audio: false },
586
+ "grok-4.6": { image: true, video: false, audio: false },
478
587
  "claude-sonnet-5": { image: true, video: false, audio: false },
479
588
  "claude-opus-4.8": { image: true, video: false, audio: false },
480
589
  "claude-opus-5": { image: true, video: false, audio: false },