@nodaro/shared 3.11.0 → 3.12.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.
Files changed (52) hide show
  1. package/dist/index.cjs +2047 -84
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1556 -27
  4. package/dist/index.d.ts +1556 -27
  5. package/dist/index.js +1861 -85
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/presentation-utils.test.ts +67 -0
  17. package/src/__tests__/producer-types.test.ts +19 -0
  18. package/src/__tests__/schedule-rules.test.ts +265 -0
  19. package/src/__tests__/speaker-layouts.test.ts +203 -0
  20. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  21. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  22. package/src/__tests__/trigger-feeds.test.ts +39 -0
  23. package/src/__tests__/video-duration-auto.test.ts +65 -0
  24. package/src/__tests__/video-duration.test.ts +56 -0
  25. package/src/__tests__/video-link.test.ts +137 -0
  26. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  27. package/src/caption-styles.ts +240 -0
  28. package/src/credit-identifiers.ts +31 -0
  29. package/src/edit-plan-contract.ts +96 -0
  30. package/src/edl-multicam.ts +185 -0
  31. package/src/edl.ts +747 -0
  32. package/src/entity-image-handle.ts +24 -1
  33. package/src/fan-out-rows.ts +213 -0
  34. package/src/index.ts +206 -3
  35. package/src/instagram-scrape.ts +204 -0
  36. package/src/llm-models.ts +80 -3
  37. package/src/meta-ads-scrape.ts +463 -0
  38. package/src/model-catalog.ts +48 -5
  39. package/src/model-constants.ts +148 -5
  40. package/src/node-mappable-fields.ts +2 -0
  41. package/src/node-runtime-keys.ts +28 -0
  42. package/src/presentation-utils.ts +49 -0
  43. package/src/producer-types.ts +20 -0
  44. package/src/schedule-rules.ts +484 -0
  45. package/src/speaker-layouts.ts +220 -0
  46. package/src/transcribe-preflight.ts +101 -0
  47. package/src/trigger-feeds.ts +59 -0
  48. package/src/trigger-node-types.ts +20 -0
  49. package/src/video-duration-auto.ts +18 -0
  50. package/src/video-duration.ts +32 -0
  51. package/src/video-link.ts +167 -0
  52. package/src/workflow-export.ts +37 -1
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Instagram scraper node — shared vocabulary + credit identifiers.
3
+ *
4
+ * Pulls PUBLIC Instagram posts (feed images, carousels, reels) by profile or
5
+ * by hashtag and emits a normalized JSON array. Same shape of contract as the
6
+ * Meta Ads node: everything the backend guard/reservation, the frontend credit
7
+ * badge and the docs formula must agree on lives here.
8
+ *
9
+ * Pricing: 1 credit per REQUESTED post, rounded UP to a fixed tier of
10
+ * `count × sources` (a profile / hashtag is one source, up to 5). Optional
11
+ * per-post AI analysis folds into the same identifier, priced by the model's
12
+ * tier — reusing the Meta analysis per-item values so the two nodes stay in
13
+ * lockstep.
14
+ */
15
+ import {
16
+ META_ADS_ANALYSIS_CREDITS_PER_AD,
17
+ META_ADS_ANALYSIS_TIERS,
18
+ metaAdsAnalysisTier,
19
+ type MetaAdsAnalysisTier,
20
+ } from "./meta-ads-scrape.js"
21
+ import { classifyCreativeFormat, type MetaAdsFormat } from "./meta-ads-scrape.js"
22
+
23
+ export const INSTAGRAM_SCRAPE_NODE_TYPE = "instagram-scrape" as const
24
+
25
+ export const INSTAGRAM_SCRAPE_MODES = ["profile", "hashtag"] as const
26
+ export type InstagramScrapeMode = (typeof INSTAGRAM_SCRAPE_MODES)[number]
27
+
28
+ export function isInstagramScrapeMode(value: unknown): value is InstagramScrapeMode {
29
+ return typeof value === "string" && (INSTAGRAM_SCRAPE_MODES as readonly string[]).includes(value)
30
+ }
31
+ export function instagramScrapeMode(value: unknown): InstagramScrapeMode {
32
+ return isInstagramScrapeMode(value) ? value : "profile"
33
+ }
34
+
35
+ /** Same window vocabulary as Meta Ads; the Instagram actor honours it server-side (`onlyPostsNewerThan`). */
36
+ export const INSTAGRAM_SCRAPE_PERIODS = ["24h", "7d", "30d", "all"] as const
37
+ export type InstagramScrapePeriod = (typeof INSTAGRAM_SCRAPE_PERIODS)[number]
38
+
39
+ export const INSTAGRAM_SCRAPE_DEFAULT_COUNT = 20
40
+ export const INSTAGRAM_SCRAPE_MAX_COUNT = 100
41
+ export const INSTAGRAM_SCRAPE_MAX_SOURCES = 5
42
+ /** Instagram usernames / hashtags are short; cap a single target well under a URL. */
43
+ export const INSTAGRAM_SCRAPE_MAX_TARGET_LENGTH = 200
44
+
45
+ /** Requested-total buckets — identical shape to Meta (the pricing model is the same). */
46
+ export const INSTAGRAM_SCRAPE_TIERS = [10, 20, 50, 100, 200, 500] as const
47
+ export type InstagramScrapeTier = (typeof INSTAGRAM_SCRAPE_TIERS)[number]
48
+
49
+ export function instagramScrapeTier(requestedTotal: number): InstagramScrapeTier {
50
+ for (const tier of INSTAGRAM_SCRAPE_TIERS) if (requestedTotal <= tier) return tier
51
+ return INSTAGRAM_SCRAPE_TIERS[INSTAGRAM_SCRAPE_TIERS.length - 1]
52
+ }
53
+
54
+ /** The analysis tier a request / node asks for, or null when analysis is off. */
55
+ export function instagramAnalysisTierFrom(data: { readonly analyze?: unknown; readonly analysisModel?: unknown }): MetaAdsAnalysisTier | null {
56
+ return data.analyze === true ? metaAdsAnalysisTier(data.analysisModel) : null
57
+ }
58
+
59
+ function analysisSuffix(tier: MetaAdsAnalysisTier): string {
60
+ return tier === "standard" ? ":analysis" : `:analysis:${tier}`
61
+ }
62
+
63
+ /** Per-post settlement SKU for a tier (the bare id is the standard tier). */
64
+ export const INSTAGRAM_ANALYSIS_CREDIT_ID = "instagram-analysis" as const
65
+ export function instagramAnalysisCreditId(tier: MetaAdsAnalysisTier): string {
66
+ return tier === "standard" ? INSTAGRAM_ANALYSIS_CREDIT_ID : `${INSTAGRAM_ANALYSIS_CREDIT_ID}:${tier}`
67
+ }
68
+
69
+ export interface InstagramScrapeCreditInput {
70
+ count: number
71
+ sources: number
72
+ analysis?: MetaAdsAnalysisTier | null
73
+ }
74
+
75
+ export function buildInstagramScrapeCreditId(input: InstagramScrapeCreditInput): string {
76
+ const sources = Math.min(Math.max(Math.trunc(input.sources) || 1, 1), INSTAGRAM_SCRAPE_MAX_SOURCES)
77
+ const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), INSTAGRAM_SCRAPE_MAX_COUNT)
78
+ const base = `${INSTAGRAM_SCRAPE_NODE_TYPE}:${instagramScrapeTier(count * sources)}`
79
+ return input.analysis ? `${base}${analysisSuffix(input.analysis)}` : base
80
+ }
81
+
82
+ /**
83
+ * Cost per SKU — mirror of the backend `STATIC_CREDIT_COSTS` rows / migration,
84
+ * for the frontend badge / estimator. 1 credit per requested post at every
85
+ * tier, plus the analysis multiples.
86
+ */
87
+ export const INSTAGRAM_SCRAPE_CREDIT_COSTS: Record<string, number> = (() => {
88
+ const table: Record<string, number> = { [INSTAGRAM_SCRAPE_NODE_TYPE]: 20 }
89
+ for (const tier of META_ADS_ANALYSIS_TIERS) table[instagramAnalysisCreditId(tier)] = META_ADS_ANALYSIS_CREDITS_PER_AD[tier]
90
+ for (const t of INSTAGRAM_SCRAPE_TIERS) {
91
+ table[`${INSTAGRAM_SCRAPE_NODE_TYPE}:${t}`] = t
92
+ for (const tier of META_ADS_ANALYSIS_TIERS) {
93
+ table[`${INSTAGRAM_SCRAPE_NODE_TYPE}:${t}${analysisSuffix(tier)}`] = t * (1 + META_ADS_ANALYSIS_CREDITS_PER_AD[tier])
94
+ }
95
+ }
96
+ return table
97
+ })()
98
+
99
+ export const INSTAGRAM_SCRAPE_FALLBACK_CREDIT_ID = "instagram-scrape:20"
100
+
101
+ export function isInstagramScrapeCount(value: unknown): value is number {
102
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= INSTAGRAM_SCRAPE_MAX_COUNT
103
+ }
104
+
105
+ /**
106
+ * Targets (profile usernames/URLs or hashtags) are typed one per line; the
107
+ * route wants an array. Unlike page urls a target can be a bare username /
108
+ * `#tag`, so split on lines / commas only (never whitespace), strip a leading
109
+ * `@` or `#`, dedupe, cap at MAX_SOURCES.
110
+ */
111
+ export function splitInstagramTargets(value: unknown): string[] {
112
+ const raw = Array.isArray(value)
113
+ ? value.filter((v): v is string => typeof v === "string")
114
+ : typeof value === "string"
115
+ ? value.split(/[\n,]+/)
116
+ : []
117
+ const seen = new Set<string>()
118
+ const out: string[] = []
119
+ for (const item of raw) {
120
+ const t = item.trim().replace(/^[@#]+/, "").trim()
121
+ if (t.length < 1 || t.length > INSTAGRAM_SCRAPE_MAX_TARGET_LENGTH) continue
122
+ const key = t.toLowerCase()
123
+ if (seen.has(key)) continue
124
+ seen.add(key)
125
+ out.push(t)
126
+ if (out.length >= INSTAGRAM_SCRAPE_MAX_SOURCES) break
127
+ }
128
+ return out
129
+ }
130
+
131
+ /** The featured post index, clamped. */
132
+ export function clampInstagramFeaturedIndex(stored: unknown, count: number): number {
133
+ if (count <= 0) return 0
134
+ const n = typeof stored === "number" && Number.isFinite(stored) ? Math.trunc(stored) : 0
135
+ return Math.min(Math.max(n, 0), count - 1)
136
+ }
137
+
138
+ export interface FeaturedInstagramOutputs {
139
+ readonly text?: string
140
+ readonly imageUrl?: string
141
+ readonly videoUrl?: string
142
+ }
143
+
144
+ function urlStrings(value: unknown): string[] {
145
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && v.trim().length > 0) : []
146
+ }
147
+
148
+ /** The featured post's caption (`text`), first image / cover (`image`), and first video (`video`). */
149
+ export function featuredInstagramOutputs(json: unknown, featuredIndex: unknown): FeaturedInstagramOutputs {
150
+ if (!Array.isArray(json) || json.length === 0) return {}
151
+ const post = json[clampInstagramFeaturedIndex(featuredIndex, json.length)]
152
+ if (!post || typeof post !== "object") return {}
153
+ const p = post as Record<string, unknown>
154
+ const text = typeof p.caption === "string" ? p.caption.trim() : ""
155
+ const imageUrl = urlStrings(p.images)[0] ?? urlStrings(p.videoPreviews)[0]
156
+ const videoUrl = urlStrings(p.videos)[0]
157
+ return {
158
+ ...(text ? { text } : {}),
159
+ ...(imageUrl ? { imageUrl } : {}),
160
+ ...(videoUrl ? { videoUrl } : {}),
161
+ }
162
+ }
163
+
164
+ /** The node-data fields a quote reads. */
165
+ export interface InstagramNodeQuoteFields {
166
+ readonly [key: string]: unknown
167
+ readonly mode?: unknown
168
+ readonly targets?: unknown
169
+ readonly count?: unknown
170
+ readonly analyze?: unknown
171
+ readonly analysisModel?: unknown
172
+ }
173
+
174
+ /** Billable source count: number of targets (min 1). */
175
+ export function instagramScrapeSources(data: InstagramNodeQuoteFields): number {
176
+ return Math.max(1, Math.min(splitInstagramTargets(data.targets).length, INSTAGRAM_SCRAPE_MAX_SOURCES))
177
+ }
178
+
179
+ /** The ONE credit identifier for a node's current settings. */
180
+ export function instagramScrapeCreditIdFromNode(data: InstagramNodeQuoteFields): string {
181
+ const count = typeof data.count === "number" ? data.count : INSTAGRAM_SCRAPE_DEFAULT_COUNT
182
+ return buildInstagramScrapeCreditId({ count, sources: instagramScrapeSources(data), analysis: instagramAnalysisTierFrom(data) })
183
+ }
184
+
185
+ /**
186
+ * Resolve the credit identifier from an UNVALIDATED request body (the guard
187
+ * runs before Zod). Lands on the SAME tier the reservation computes.
188
+ */
189
+ export function resolveInstagramScrapeCreditId(body: unknown): string {
190
+ const raw = body as { count?: unknown; targets?: unknown; analyze?: unknown; analysisModel?: unknown } | null | undefined
191
+ if (!raw || typeof raw !== "object") return INSTAGRAM_SCRAPE_FALLBACK_CREDIT_ID
192
+ const count = raw.count === undefined ? INSTAGRAM_SCRAPE_DEFAULT_COUNT : raw.count
193
+ if (!isInstagramScrapeCount(count)) return INSTAGRAM_SCRAPE_FALLBACK_CREDIT_ID
194
+ // Same splitter the handler uses for `sources` (dedupes, caps at MAX), so the
195
+ // pre-Zod guard and the post-Zod reservation always land on the same tier —
196
+ // a duplicate target bills once, not per copy.
197
+ const sources = splitInstagramTargets(raw.targets).length
198
+ if (sources < 1) return INSTAGRAM_SCRAPE_FALLBACK_CREDIT_ID
199
+ return buildInstagramScrapeCreditId({ count, sources, analysis: instagramAnalysisTierFrom(raw) })
200
+ }
201
+
202
+ // Re-export the shared creative-format vocabulary so the node imports one place.
203
+ export { classifyCreativeFormat }
204
+ export type InstagramFormat = MetaAdsFormat
package/src/llm-models.ts CHANGED
@@ -118,12 +118,37 @@ export interface LlmModelDef {
118
118
  *
119
119
  * Consumers MUST give such a model output headroom regardless of the
120
120
  * requested effort, or reasoning silently eats a small legacy cap and the
121
- * answer truncates with `stop_reason: max_tokens` — a paid-for empty reply,
122
- * not an error. `deriveParams` (llm-client.ts) and the film-pipeline's
123
- * `callLLM` both floor on this flag; keep it in sync with the vendor's
121
+ * answer truncates with `stop_reason: max_tokens` — a paid-for empty reply
122
+ * (since #1588 the client fails such a call rather than return the
123
+ * fragment, but the floor is what keeps it from happening). `deriveParams`
124
+ * (llm-client.ts) floors to {@link reasoningOutputFloor}; the film
125
+ * pipeline's `callLLM` — Anthropic SDK only, so only its Claude members
126
+ * matter — floors at the default. Keep the flag in sync with the vendor's
124
127
  * documented default rather than inferring it from the model name.
125
128
  */
126
129
  thinkingDefaultOn?: true
130
+ /**
131
+ * The output-token cap a REASONING call on this model is floored to — the
132
+ * room its thinking shares with the answer (`thinkingDefaultOn`, or an
133
+ * xhigh/max effort). Absent = {@link REASONING_OUTPUT_FLOOR}; read it through
134
+ * {@link reasoningOutputFloor}, never directly.
135
+ *
136
+ * Declare it ONLY where a lane serving this model is not known to accept the
137
+ * default: the floor rides every lane the model can be served on (KIE AND its
138
+ * direct fallback), so it has to sit at the intersection of what they take —
139
+ * the rule `maxOutputTokens` already follows for the Gemini flash entries.
140
+ * Never below `maxOutputTokens` (a floor under the default cap is not a
141
+ * floor — guarded by a registry test).
142
+ */
143
+ reasoningOutputFloor?: number
144
+ }
145
+
146
+ /** The reasoning floor for a model that declares no lane limit of its own. */
147
+ export const REASONING_OUTPUT_FLOOR = 32768
148
+
149
+ /** The output cap a reasoning call on `model` is floored to (see `LlmModelDef.reasoningOutputFloor`). */
150
+ export function reasoningOutputFloor(model: LlmModelDef): number {
151
+ return model.reasoningOutputFloor ?? REASONING_OUTPUT_FLOOR
127
152
  }
128
153
 
129
154
  export const LLM_MODELS: readonly LlmModelDef[] = [
@@ -145,6 +170,11 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
145
170
  // No `reasoningEfforts` at all on the KIE lane, but the vendor API accepts
146
171
  // the full minimal→high ladder (`none` maps to Google's `minimal`).
147
172
  directReasoningEfforts: ["none", "low", "medium", "high"],
173
+ // Reasons with no thinking param sent — Google's Gemini 3 default (dynamic
174
+ // thinking; `minimal` is its floor, never off), measured on 3.6 in #1588.
175
+ // Floored at the KIE-safe 8192, the same intersection as `maxOutputTokens`.
176
+ thinkingDefaultOn: true,
177
+ reasoningOutputFloor: 8192,
148
178
  },
149
179
  {
150
180
  id: "gemini-3.6-flash",
@@ -177,6 +207,15 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
177
207
  // Gemini entry — the lane with the lower unit cost wins by default and
178
208
  // direct is the reliability fallback only.
179
209
  directGeminiModel: "gemini-3.6-flash",
210
+ // Reasons with NO thinking param sent — measured, issue #1588: a Generate
211
+ // Text node capped at 1,100 tokens fell back to the direct lane (KIE 500),
212
+ // spent ~1,060 of them reasoning, and returned 120 characters cut mid-URL.
213
+ // On the same input the KIE runs used ~500 output tokens in all, so only
214
+ // the fallback runs broke — every other run of a 5-minute schedule.
215
+ // Floored at 8192, NOT the default 32768: the floor rides the KIE endpoint
216
+ // too, and 8192 is all it is known to take (see `maxOutputTokens`).
217
+ thinkingDefaultOn: true,
218
+ reasoningOutputFloor: 8192,
180
219
  },
181
220
  {
182
221
  id: "gemini-3.7-flash",
@@ -205,6 +244,10 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
205
244
  // Assumed parity with 3.6 pending a live probe on the direct lane.
206
245
  directReasoningEfforts: ["none", "low", "medium", "high"],
207
246
  directGeminiModel: "gemini-3.7-flash",
247
+ // Gemini 3 default: reasons with no thinking param sent (measured on 3.6,
248
+ // #1588). KIE-safe floor, same intersection as `maxOutputTokens`.
249
+ thinkingDefaultOn: true,
250
+ reasoningOutputFloor: 8192,
208
251
  },
209
252
  {
210
253
  id: "gemini-3.8-flash",
@@ -245,6 +288,11 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
245
288
  // KIE-first (no `preferDirect`) — 3.7's posture exactly: the cheap lane
246
289
  // serves the A/B, direct is Advanced mode + the reliability fallback.
247
290
  directGeminiModel: "gemini-3.8-flash",
291
+ // Gemini 3 default: reasons with no thinking param sent (measured on 3.6,
292
+ // #1588). Floored at its own 16384, inside the 20000 its KIE endpoint was
293
+ // measured to honour.
294
+ thinkingDefaultOn: true,
295
+ reasoningOutputFloor: 16384,
248
296
  },
249
297
  {
250
298
  id: "claude-haiku-4.5",
@@ -318,6 +366,11 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
318
366
  // `additionalProperties` (KIE's `response_format` silently DROPS
319
367
  // record/map-shaped fields — see the z.record rule in backend/CLAUDE.md).
320
368
  preferDirect: true,
369
+ // Reasons with no thinking param sent on both lanes — the proxied endpoint
370
+ // DEFAULTS to "high" (above), and the direct lane reasons harder still.
371
+ // Floored at its own 16384: its KIE fallback is not known to take more.
372
+ thinkingDefaultOn: true,
373
+ reasoningOutputFloor: 16384,
321
374
  },
322
375
  {
323
376
  id: "claude-opus-4.7",
@@ -413,6 +466,22 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
413
466
  maxOutputTokens: 16384,
414
467
  reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
415
468
  supportsTemperature: false,
469
+ // SERVED COLLAPSED, on the astra precedent (2026-09-17). The 2026-07-14
470
+ // verification that KIE's non-stream responses endpoint serves the GPT-5.6
471
+ // family reliably no longer holds: a live call — a recast continuity
472
+ // review, ~3k tokens, `reasoning.effort: high`, `text.format: json_schema`
473
+ // — came back `500 {"error":{"type":"server_error"}}` / "Server exception,
474
+ // please try again later". Same endpoint family, same dialect and the same
475
+ // signature astra was measured on (12 calls: non-stream 2/6, streaming
476
+ // 5/6; a schema-less non-stream call 500'd too, so the lane is the trigger
477
+ // and not the schema). ONE sighting here rather than a fresh 12-call probe
478
+ // — the precedent is strong and the flag is cheap to reverse.
479
+ //
480
+ // THE COST: SSE does not reliably carry `credits_consumed`, so this model's
481
+ // provider cost becomes the rate-table estimate instead of the billed
482
+ // figure. That is the price of a lane that answers, and it is the same
483
+ // trade astra already makes.
484
+ kieCollapseStream: true,
416
485
  },
417
486
  {
418
487
  id: "gpt-6-astra",
@@ -649,6 +718,10 @@ export type LlmFeature =
649
718
  // zero-cost `3d-scene-ops` identifier instead.
650
719
  | "3d-scene"
651
720
  | "image-to-text"
721
+ // Per-ad creative analysis on the social scraper nodes (Meta Ads first):
722
+ // one structured vision call per ad, priced per REQUESTED ad by tier and
723
+ // folded into the scrape's own identifier (packages/shared/meta-ads-scrape).
724
+ | "meta-ads-analysis"
652
725
  | "describe-to-picker"
653
726
  | "qa-check"
654
727
  | "generate-script"
@@ -680,6 +753,10 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
680
753
  "3d-title": "claude-sonnet-4.6",
681
754
  "3d-scene": "claude-sonnet-4.6",
682
755
  "image-to-text": "claude-sonnet-4.6",
756
+ // Economy on purpose: the analysis reads ONE frame + the copy per ad and
757
+ // runs once per returned ad — volume, not depth. Must stay an image-capable
758
+ // structured-output model (STRUCTURED_VISION_MODELS); a registry test pins it.
759
+ "meta-ads-analysis": "gemini-3.6-flash",
683
760
  "describe-to-picker": "claude-opus-5",
684
761
  "qa-check": "gemini-3.6-flash",
685
762
  "generate-script": "gemini-3.6-flash",