vidspotai-shared 1.0.124 → 1.0.126

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 (153) hide show
  1. package/lib/globals/aiModels/enums.d.ts +38 -0
  2. package/lib/globals/aiModels/enums.d.ts.map +1 -1
  3. package/lib/globals/aiModels/enums.js +49 -1
  4. package/lib/globals/aiModels/index.d.ts.map +1 -1
  5. package/lib/globals/aiModels/index.js +2 -0
  6. package/lib/globals/aiModels/providers/alibaba.d.ts.map +1 -1
  7. package/lib/globals/aiModels/providers/alibaba.js +460 -53
  8. package/lib/globals/aiModels/providers/bytedance.d.ts.map +1 -1
  9. package/lib/globals/aiModels/providers/bytedance.js +221 -39
  10. package/lib/globals/aiModels/providers/google.d.ts.map +1 -1
  11. package/lib/globals/aiModels/providers/google.js +178 -32
  12. package/lib/globals/aiModels/providers/kling.d.ts.map +1 -1
  13. package/lib/globals/aiModels/providers/kling.js +86 -12
  14. package/lib/globals/aiModels/providers/minimax.d.ts.map +1 -1
  15. package/lib/globals/aiModels/providers/minimax.js +77 -0
  16. package/lib/globals/aiModels/providers/openai.d.ts.map +1 -1
  17. package/lib/globals/aiModels/providers/openai.js +12 -0
  18. package/lib/globals/aiModels/providers/pixverse.d.ts.map +1 -1
  19. package/lib/globals/aiModels/providers/pixverse.js +101 -28
  20. package/lib/globals/aiModels/providers/runway.d.ts.map +1 -1
  21. package/lib/globals/aiModels/providers/runway.js +450 -24
  22. package/lib/globals/aiModels/providers/xai.d.ts +4 -0
  23. package/lib/globals/aiModels/providers/xai.d.ts.map +1 -0
  24. package/lib/globals/aiModels/providers/xai.js +72 -0
  25. package/lib/globals/aiModels/tierHelpers.d.ts +10 -0
  26. package/lib/globals/aiModels/tierHelpers.d.ts.map +1 -1
  27. package/lib/globals/aiModels/tierHelpers.js +48 -7
  28. package/lib/globals/aiModels/types.d.ts +21 -0
  29. package/lib/globals/aiModels/types.d.ts.map +1 -1
  30. package/lib/models/image.model.d.ts +24 -0
  31. package/lib/models/image.model.d.ts.map +1 -1
  32. package/lib/models/image.model.js +12 -0
  33. package/lib/models/index.d.ts +1 -0
  34. package/lib/models/index.d.ts.map +1 -1
  35. package/lib/models/index.js +1 -0
  36. package/lib/models/klingElement.model.d.ts +52 -0
  37. package/lib/models/klingElement.model.d.ts.map +1 -0
  38. package/lib/models/klingElement.model.js +24 -0
  39. package/lib/models/user.model.d.ts +7 -1
  40. package/lib/models/user.model.d.ts.map +1 -1
  41. package/lib/models/video.model.d.ts +11 -0
  42. package/lib/models/video.model.d.ts.map +1 -1
  43. package/lib/schemas/videoPlan.schema.d.ts +88 -0
  44. package/lib/schemas/videoPlan.schema.d.ts.map +1 -1
  45. package/lib/schemas/videoPlan.schema.js +27 -1
  46. package/lib/services/agent/executor/visual.d.ts.map +1 -1
  47. package/lib/services/agent/executor/visual.js +40 -2
  48. package/lib/services/agent/mentionedRefs.d.ts.map +1 -1
  49. package/lib/services/agent/mentionedRefs.js +4 -1
  50. package/lib/services/agent/planner/structuralRules.d.ts.map +1 -1
  51. package/lib/services/agent/planner/structuralRules.js +8 -3
  52. package/lib/services/agent/poster/posterAgent.schema.d.ts +3 -3
  53. package/lib/services/agent/providerFallback/chains.d.ts +25 -0
  54. package/lib/services/agent/providerFallback/chains.d.ts.map +1 -1
  55. package/lib/services/agent/providerFallback/chains.js +59 -5
  56. package/lib/services/agent/repair/strategies/llmRepair.strategy.d.ts.map +1 -1
  57. package/lib/services/agent/repair/strategies/llmRepair.strategy.js +2 -0
  58. package/lib/services/agent/tools/animateImage.tool.d.ts +31 -0
  59. package/lib/services/agent/tools/animateImage.tool.d.ts.map +1 -1
  60. package/lib/services/agent/tools/animateImage.tool.js +10 -3
  61. package/lib/services/agent/tools/generateImage.tool.d.ts +8 -4
  62. package/lib/services/agent/tools/generateImage.tool.d.ts.map +1 -1
  63. package/lib/services/agent/tools/generateImage.tool.js +24 -7
  64. package/lib/services/agent/tools/generateVideo.tool.d.ts +32 -0
  65. package/lib/services/agent/tools/generateVideo.tool.d.ts.map +1 -1
  66. package/lib/services/agent/tools/generateVideo.tool.js +37 -9
  67. package/lib/services/agent/tools/planVideo.tool.d.ts +19 -0
  68. package/lib/services/agent/tools/planVideo.tool.d.ts.map +1 -1
  69. package/lib/services/aiGen/aiGenFactory.service.d.ts.map +1 -1
  70. package/lib/services/aiGen/aiGenFactory.service.js +40 -0
  71. package/lib/services/aiGen/canonicalAdapters/cameraControl.types.d.ts +6 -3
  72. package/lib/services/aiGen/canonicalAdapters/cameraControl.types.d.ts.map +1 -1
  73. package/lib/services/aiGen/canonicalAdapters/cameraControl.types.js +35 -0
  74. package/lib/services/aiGen/canonicalAdapters/cameraDirection.d.ts +36 -0
  75. package/lib/services/aiGen/canonicalAdapters/cameraDirection.d.ts.map +1 -0
  76. package/lib/services/aiGen/canonicalAdapters/cameraDirection.js +168 -0
  77. package/lib/services/aiGen/canonicalAdapters/index.d.ts +1 -0
  78. package/lib/services/aiGen/canonicalAdapters/index.d.ts.map +1 -1
  79. package/lib/services/aiGen/canonicalAdapters/index.js +1 -0
  80. package/lib/services/aiGen/canonicalAdapters/multiShot.types.d.ts +3 -2
  81. package/lib/services/aiGen/canonicalAdapters/multiShot.types.d.ts.map +1 -1
  82. package/lib/services/aiGen/helpers.d.ts +40 -0
  83. package/lib/services/aiGen/helpers.d.ts.map +1 -1
  84. package/lib/services/aiGen/helpers.js +48 -2
  85. package/lib/services/aiGen/index.d.ts +1 -0
  86. package/lib/services/aiGen/index.d.ts.map +1 -1
  87. package/lib/services/aiGen/index.js +3 -0
  88. package/lib/services/aiGen/providers/alibaba/alibaba.d.ts +13 -1
  89. package/lib/services/aiGen/providers/alibaba/alibaba.d.ts.map +1 -1
  90. package/lib/services/aiGen/providers/alibaba/alibaba.js +313 -33
  91. package/lib/services/aiGen/providers/bytedance/bytedance.service.d.ts +29 -1
  92. package/lib/services/aiGen/providers/bytedance/bytedance.service.d.ts.map +1 -1
  93. package/lib/services/aiGen/providers/bytedance/bytedance.service.js +371 -54
  94. package/lib/services/aiGen/providers/google/google.service.d.ts +16 -0
  95. package/lib/services/aiGen/providers/google/google.service.d.ts.map +1 -1
  96. package/lib/services/aiGen/providers/google/google.service.js +372 -116
  97. package/lib/services/aiGen/providers/google/googleKeyPool.d.ts +6 -0
  98. package/lib/services/aiGen/providers/google/googleKeyPool.d.ts.map +1 -1
  99. package/lib/services/aiGen/providers/google/googleKeyPool.js +8 -0
  100. package/lib/services/aiGen/providers/kling/kling.service.d.ts +17 -1
  101. package/lib/services/aiGen/providers/kling/kling.service.d.ts.map +1 -1
  102. package/lib/services/aiGen/providers/kling/kling.service.js +59 -3
  103. package/lib/services/aiGen/providers/kling/klingCredits.d.ts +1 -1
  104. package/lib/services/aiGen/providers/kling/klingCredits.d.ts.map +1 -1
  105. package/lib/services/aiGen/providers/kling/klingCredits.js +19 -1
  106. package/lib/services/aiGen/providers/kling/klingRequests.d.ts +37 -7
  107. package/lib/services/aiGen/providers/kling/klingRequests.d.ts.map +1 -1
  108. package/lib/services/aiGen/providers/kling/klingRequests.js +191 -13
  109. package/lib/services/aiGen/providers/kling/types.d.ts +78 -0
  110. package/lib/services/aiGen/providers/kling/types.d.ts.map +1 -1
  111. package/lib/services/aiGen/providers/minimax/minimax.service.d.ts +11 -2
  112. package/lib/services/aiGen/providers/minimax/minimax.service.d.ts.map +1 -1
  113. package/lib/services/aiGen/providers/minimax/minimax.service.js +168 -2
  114. package/lib/services/aiGen/providers/minimax/types.d.ts +63 -0
  115. package/lib/services/aiGen/providers/minimax/types.d.ts.map +1 -1
  116. package/lib/services/aiGen/providers/pixverse/pixverse.service.d.ts +12 -0
  117. package/lib/services/aiGen/providers/pixverse/pixverse.service.d.ts.map +1 -1
  118. package/lib/services/aiGen/providers/pixverse/pixverse.service.js +131 -17
  119. package/lib/services/aiGen/providers/runway/hostedModels.d.ts +84 -0
  120. package/lib/services/aiGen/providers/runway/hostedModels.d.ts.map +1 -0
  121. package/lib/services/aiGen/providers/runway/hostedModels.js +266 -0
  122. package/lib/services/aiGen/providers/runway/index.d.ts +1 -0
  123. package/lib/services/aiGen/providers/runway/index.d.ts.map +1 -1
  124. package/lib/services/aiGen/providers/runway/index.js +1 -0
  125. package/lib/services/aiGen/providers/runway/runway.service.d.ts +1 -1
  126. package/lib/services/aiGen/providers/runway/runway.service.d.ts.map +1 -1
  127. package/lib/services/aiGen/providers/runway/runway.service.js +118 -21
  128. package/lib/services/aiGen/providers/runway/types.d.ts +18 -3
  129. package/lib/services/aiGen/providers/runway/types.d.ts.map +1 -1
  130. package/lib/services/aiGen/providers/runway/types.js +31 -3
  131. package/lib/services/aiGen/providers/types.d.ts +55 -0
  132. package/lib/services/aiGen/providers/types.d.ts.map +1 -1
  133. package/lib/services/aiGen/providers/xai/xai.service.d.ts +19 -0
  134. package/lib/services/aiGen/providers/xai/xai.service.d.ts.map +1 -0
  135. package/lib/services/aiGen/providers/xai/xai.service.js +205 -0
  136. package/lib/services/firestore.service.d.ts +4 -0
  137. package/lib/services/firestore.service.d.ts.map +1 -1
  138. package/lib/services/firestore.service.js +5 -0
  139. package/lib/services/index.d.ts +2 -0
  140. package/lib/services/index.d.ts.map +1 -1
  141. package/lib/services/index.js +2 -0
  142. package/lib/services/klingElements/klingElementLibrary.service.d.ts +37 -0
  143. package/lib/services/klingElements/klingElementLibrary.service.d.ts.map +1 -0
  144. package/lib/services/klingElements/klingElementLibrary.service.js +179 -0
  145. package/lib/services/trialGuard/index.d.ts +2 -0
  146. package/lib/services/trialGuard/index.d.ts.map +1 -0
  147. package/lib/services/trialGuard/index.js +17 -0
  148. package/lib/services/trialGuard/trialSignals.d.ts +88 -0
  149. package/lib/services/trialGuard/trialSignals.d.ts.map +1 -0
  150. package/lib/services/trialGuard/trialSignals.js +160 -0
  151. package/lib/utils/helpers.d.ts.map +1 -1
  152. package/lib/utils/helpers.js +5 -1
  153. package/package.json +2 -1
@@ -11,6 +11,7 @@ const aiModels_1 = require("../../../../globals/aiModels");
11
11
  const types_1 = require("../../../../globals/types");
12
12
  const firebase_1 = require("../../../../libs/firebase");
13
13
  const storage_1 = require("../../../../libs/storage");
14
+ const mp4Duration_1 = require("../../../../utils/mp4Duration");
14
15
  const helpers_1 = require("../../../../utils/helpers");
15
16
  const logger_1 = require("../../../../utils/logger");
16
17
  const errors_1 = require("../../../../utils/errors");
@@ -81,6 +82,31 @@ async function mp4IsComplete(path) {
81
82
  return (buf.includes(Buffer.from("ftyp", "ascii")) &&
82
83
  buf.includes(Buffer.from("moov", "ascii")));
83
84
  }
85
+ // ── Gemini Omni (Interactions API) ─────────────────────────────────────────
86
+ // Omni is not a Veo `generateVideos` model: it is served by the Interactions
87
+ // API, which the pinned @google/genai (1.23) predates, so it is called over
88
+ // REST. Docs: ai.google.dev/gemini-api/docs/omni + /api/interactions-api.
89
+ const INTERACTIONS_BASE = "https://generativelanguage.googleapis.com/v1beta/interactions";
90
+ /** Task prefix that routes checkVideoStatus to the Omni poller (Veo operation
91
+ * names are `models/…/operations/…`, so the two can't collide). */
92
+ const OMNI_TASK_PREFIX = "interactions/";
93
+ function isOmniModelId(modelId) {
94
+ return modelId.startsWith("gemini-omni");
95
+ }
96
+ /** Throw a Google REST error body verbatim as the Error message — the same
97
+ * JSON shape the SDK's ApiError carries, so classifyGoogleApiError /
98
+ * classifyGoogleKeyHealth work unchanged. */
99
+ async function omniFetch(url, apiKey, init) {
100
+ const res = await fetch(url, {
101
+ ...init,
102
+ headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
103
+ });
104
+ const text = await res.text();
105
+ if (!res.ok) {
106
+ throw new Error(text || JSON.stringify({ error: { code: res.status, message: res.statusText } }));
107
+ }
108
+ return JSON.parse(text);
109
+ }
84
110
  const VEO_3_1_MODELS = new Set([
85
111
  aiModels_1.EVideoGenModels.GOOGLE_VEO_3_1,
86
112
  aiModels_1.EVideoGenModels.GOOGLE_VEO_3_1_FAST,
@@ -209,6 +235,8 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
209
235
  (0, helpers_2.validateParams)(params);
210
236
  const modelConfig = aiModels_1.aiModelConfigs[params.modelKey];
211
237
  const modelId = modelConfig.modelId;
238
+ if (isOmniModelId(modelId))
239
+ return this.generateOmniVideo(params, modelId);
212
240
  const isVeo3_1 = VEO_3_1_MODELS.has(params.modelKey);
213
241
  // ── Veo generation mode ──────────────────────────────────────────────
214
242
  // Several Veo constraints are MODE-dependent (not just model-dependent),
@@ -475,11 +503,157 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
475
503
  // covers the no-key-left case) — surface the last error.
476
504
  throw lastErr ?? new Error("Failed to initiate video generation task");
477
505
  }
506
+ /**
507
+ * Gemini Omni submit (Interactions API, `background: true`). Input mapping per
508
+ * the Omni guide: one image = image-to-video, two images = first/last-frame
509
+ * interpolation, several images = subject references. Omni has no
510
+ * negativePrompt / seed / duration-seconds params, so a negative prompt and
511
+ * the requested length are folded into the prompt text (the guide's own
512
+ * advice for negatives: "Do not do X").
513
+ */
514
+ async generateOmniVideo(params, modelId) {
515
+ if (params.lastFrameImageUrl && !params.inputImageUrl) {
516
+ throw new errors_1.UserFacingError("Gemini Omni needs a starting image as well as a last-frame image to interpolate between them. " +
517
+ "Please add a first-frame image or remove the last-frame image.", errors_1.USER_FACING_ERROR_CODES.CAPABILITY_MISMATCH);
518
+ }
519
+ // Frames and subject references are both plain image parts to Omni; mixing
520
+ // them is undocumented and would make two frames + refs ambiguous, so
521
+ // explicit frames win and references are dropped (logged).
522
+ const frameUrls = params.inputImageUrl
523
+ ? [params.inputImageUrl, ...(params.lastFrameImageUrl ? [params.lastFrameImageUrl] : [])]
524
+ : [];
525
+ const refUrls = (params.referenceImageUrls ?? []).slice(0, 3);
526
+ if (frameUrls.length && refUrls.length) {
527
+ logger_1.logger.warn("Gemini Omni: frame image(s) and reference images both supplied — using frames only", {
528
+ modelKey: params.modelKey,
529
+ referenceImageCount: refUrls.length,
530
+ });
531
+ }
532
+ const imageUrls = frameUrls.length ? frameUrls : refUrls;
533
+ const images = await Promise.all(imageUrls.map((url) => this.withTransientRetry("omni image fetch", () => (0, googleFetch_1.fetchAsImage)(url))));
534
+ const textParts = [params.prompt];
535
+ if (params.duration)
536
+ textParts.push(`The video is about ${params.duration} seconds long.`);
537
+ if (params.negativePrompt)
538
+ textParts.push(`Do not include: ${params.negativePrompt}.`);
539
+ const text = textParts.join("\n\n");
540
+ // `task` is only set where the image count alone is ambiguous (the guide
541
+ // recommends letting the model infer otherwise — the field adds constraints).
542
+ const task = !frameUrls.length && refUrls.length ? "reference_to_video" : undefined;
543
+ const body = JSON.stringify({
544
+ model: modelId,
545
+ input: images.length
546
+ ? [
547
+ ...images.map((img) => ({ type: "image", data: img.imageBytes, mime_type: img.mimeType })),
548
+ { type: "text", text },
549
+ ]
550
+ : text,
551
+ response_format: {
552
+ type: "video",
553
+ ...(params.aspectRatio ? { aspect_ratio: params.aspectRatio } : {}),
554
+ ...(params.resolution ? { resolution: params.resolution } : {}),
555
+ // URI delivery: the guide's advice for anything over ~4 MB (>720p), and
556
+ // it keeps multi-MB base64 out of every status poll.
557
+ delivery: "uri",
558
+ },
559
+ ...(task ? { generation_config: { video_config: { task } } } : {}),
560
+ // Async: returns an interaction id immediately; checkVideoStatus polls it.
561
+ // (Requires store=true, the default.)
562
+ background: true,
563
+ });
564
+ const modelConfig = aiModels_1.aiModelConfigs[params.modelKey];
565
+ const keyEntry = await this.keyPool.pickForSubmit(modelId, modelConfig.requestPerMin ?? 0, modelConfig.requestPerDay ?? 0);
566
+ try {
567
+ const interaction = await this.withTransientRetry("interactions.create", () => omniFetch(INTERACTIONS_BASE, keyEntry.apiKey, { method: "POST", body }));
568
+ if (!interaction?.id)
569
+ throw new Error("Failed to initiate Gemini Omni interaction");
570
+ return {
571
+ task: (0, googleApiKeys_1.encodeVeoTask)(keyEntry.id, `${OMNI_TASK_PREFIX}${interaction.id}`, this.keyPool.size),
572
+ status: types_1.EVideoSceneStatus.TRIGGERED,
573
+ };
574
+ }
575
+ catch (err) {
576
+ const keyFailure = (0, googleErrors_1.classifyGoogleKeyHealth)(err);
577
+ if (keyFailure && keyFailure.kind !== "quota") {
578
+ await this.keyPool.markDepleted(keyEntry.id, keyFailure.ttlSeconds, keyFailure.reason);
579
+ }
580
+ logger_1.logger.warn("Gemini Omni: request rejected — submitted shape", {
581
+ modelKey: params.modelKey,
582
+ modelId,
583
+ keyId: keyEntry.id,
584
+ aspectRatio: params.aspectRatio,
585
+ resolution: params.resolution,
586
+ duration: params.duration,
587
+ frameCount: frameUrls.length,
588
+ referenceImageCount: frameUrls.length ? 0 : refUrls.length,
589
+ task: task ?? null,
590
+ promptChars: text.length,
591
+ error: err instanceof Error ? err.message.slice(0, 500) : String(err),
592
+ });
593
+ throw err;
594
+ }
595
+ }
596
+ async checkOmniStatus(keyId, client, interactionId, outputFilename, outputFilePath) {
597
+ const apiKey = this.keyPool.apiKeyById(keyId);
598
+ const interaction = await this.withTransientRetry("interactions.get", () => omniFetch(`${INTERACTIONS_BASE}/${encodeURIComponent(interactionId)}`, apiKey));
599
+ if (interaction.status === "in_progress")
600
+ return { status: types_1.EVideoSceneStatus.PENDING };
601
+ if (interaction.status !== "completed") {
602
+ const apiErr = interaction.errors?.[0] ?? interaction.error;
603
+ const classified = apiErr ? (0, googleErrors_1.classifyGoogleApiError)({ message: JSON.stringify({ error: apiErr }) }) : null;
604
+ return {
605
+ status: types_1.EVideoSceneStatus.FAILED,
606
+ errorMessage: classified
607
+ ? JSON.stringify(classified.toJSON())
608
+ : `Gemini Omni interaction ${interaction.status}` +
609
+ (apiErr?.message ? `: ${apiErr.message}` : ""),
610
+ };
611
+ }
612
+ const outputs = (interaction.steps ?? []).filter((s) => s.type === "model_output");
613
+ const contents = outputs.flatMap((s) => s.content ?? []);
614
+ const videoPart = contents.find((c) => c.type === "video" && (c.data || c.uri));
615
+ logger_1.logger.info("Gemini Omni: interaction completed", {
616
+ interactionId,
617
+ outputTokens: interaction.usage?.total_output_tokens ?? null,
618
+ delivery: videoPart?.data ? "inline" : videoPart?.uri ? "uri" : "none",
619
+ });
620
+ if (!videoPart) {
621
+ // Omni answers a blocked/refused prompt with text instead of a video.
622
+ // Surface it as a user-input error (sceneMonitor demotes the log level).
623
+ const reply = contents.filter((c) => c.type === "text" && c.text).map((c) => c.text).join(" ").trim();
624
+ return {
625
+ status: types_1.EVideoSceneStatus.FAILED,
626
+ errorMessage: `user_input_error: ${reply.slice(0, 500) || "Gemini Omni returned no video"}`,
627
+ };
628
+ }
629
+ // GET returns the video inline even for `delivery: "uri"` (Omni guide), so
630
+ // this branch is a fallback. It bills the requested duration, since the
631
+ // shared Veo persist path doesn't measure the file.
632
+ if (videoPart.uri) {
633
+ return this.persistFilesApiVideo(client, { uri: videoPart.uri, mimeType: videoPart.mime_type ?? "video/mp4" }, outputFilename, outputFilePath);
634
+ }
635
+ // Inline delivery (the API may still inline small outputs).
636
+ const bytes = Buffer.from(videoPart.data, "base64");
637
+ if (!bytes.includes(Buffer.from("ftyp", "ascii")) || !bytes.includes(Buffer.from("moov", "ascii"))) {
638
+ return {
639
+ status: types_1.EVideoSceneStatus.FAILED,
640
+ errorMessage: "Generated video failed integrity validation (incomplete inline payload).",
641
+ };
642
+ }
643
+ const videoUrl = await storage_1.mediaStorage.put(`${outputFilePath}/${outputFilename}.mp4`, bytes, videoPart.mime_type ?? "video/mp4");
644
+ // Omni picks its own length (3–10s; the requested duration is only a prompt
645
+ // hint), so report the delivered length and finalization bills that.
646
+ const durationSec = (0, mp4Duration_1.readMp4DurationSec)(bytes);
647
+ return { videoUrl, status: types_1.EVideoSceneStatus.COMPLETED, ...(durationSec !== undefined ? { durationSec } : {}) };
648
+ }
478
649
  async checkVideoStatus({ task, outputFilename, outputFilePath = "videos", }) {
479
650
  // Re-select the key that submitted this task (Veo operations are
480
651
  // project-scoped). Un-tagged / unknown ids fall back to the legacy client.
481
652
  const { keyId, operationName } = (0, googleApiKeys_1.decodeVeoTask)(task);
482
653
  const client = this.keyPool.clientById(keyId) ?? this.ai;
654
+ if (operationName.startsWith(OMNI_TASK_PREFIX)) {
655
+ return this.checkOmniStatus(keyId, client, operationName.slice(OMNI_TASK_PREFIX.length), outputFilename, outputFilePath);
656
+ }
483
657
  const operation = new genai_1.GenerateVideosOperation();
484
658
  operation.name = operationName;
485
659
  const result = await this.withTransientRetry("getVideosOperation", () => client.operations.getVideosOperation({ operation }));
@@ -524,94 +698,102 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
524
698
  };
525
699
  }
526
700
  const video = result.response?.generatedVideos?.[0]?.video;
527
- const localPath = `${outputFilename}.mp4`; // use /tmp for cloud functions
528
- const filePath = `${outputFilePath}/${outputFilename}.mp4`;
529
- const file = (0, firebase_1.getBucket)().file(filePath);
530
- // Download with the SAME key that submitted the task. The Veo output is
531
- // stored via the Files API in the issuing key's GCP project, so it is
532
- // project-scoped exactly like the operation — the default `this.ai`
533
- // (legacy GOOGLE_API_KEY) client can only read files from its own
534
- // project. With the multi-key pool, the drain-first key (k0) lives in a
535
- // separate project, so downloading its videos via `this.ai` would 403/404.
536
- // Use the re-selected `client` (falls back to `this.ai` for un-tagged
537
- // legacy tasks, which were submitted on GOOGLE_API_KEY anyway).
538
- // Download → validate-complete → upload, retried as a unit. The Files API
539
- // downloader intermittently resolves before the clip is fully flushed to
540
- // disk; uploading then truncates the scene at a chunk boundary (commonly
541
- // exactly 4 MiB), dropping the trailing `moov` atom and yielding a corrupt
542
- // MP4 that ffprobe rejects downstream (poster/watermark "moov atom not
543
- // found", and a broken deliverable). Guard with (1) a stable-size wait so
544
- // we only read a fully-written file, (2) a structural ftyp+moov check, and
545
- // (3) a stored-vs-local size match after upload. Any failure re-runs the
546
- // whole download instead of shipping corruption.
547
- //
548
- // GCS-PRIMARY (not mediaStorage.put): the size-verified integrity guard
549
- // above needs GCS's own writeFromFile + getMetadata size readback: R2
550
- // gets a best-effort mirror via mediaStorage.mirrorFromFile once the
551
- // GCS copy is confirmed intact. See notes/R2_MEDIA_MIGRATION_PLAN.md §10.
552
- const MAX_FETCH_ATTEMPTS = 3;
553
- let uploaded = false;
554
- for (let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS && !uploaded; attempt++) {
555
- await this.withTransientRetry("files.download", () => client.files.download({ file: video, downloadPath: localPath }));
556
- const localSize = await waitForStableFile(localPath, 30000, 500);
557
- if (await mp4IsComplete(localPath)) {
558
- await (0, promises_2.pipeline)((0, fs_1.createReadStream)(localPath), file.createWriteStream({ contentType: "video/mp4" }));
559
- const [meta] = await file.getMetadata();
560
- const storedSize = Number(meta.size);
561
- if (Number.isFinite(storedSize) && storedSize === localSize) {
562
- uploaded = true;
563
- break; // faithful, complete upload
564
- }
565
- logger_1.logger.warn("google: scene upload size mismatch — retrying", {
566
- filePath,
567
- attempt,
568
- localSize,
569
- storedSize,
570
- });
571
- }
572
- else {
573
- logger_1.logger.warn("google: downloaded scene missing moov atom — retrying", {
574
- filePath,
575
- attempt,
576
- localSize,
577
- });
701
+ return this.persistFilesApiVideo(client, video, outputFilename, outputFilePath);
702
+ }
703
+ return { status: types_1.EVideoSceneStatus.PENDING };
704
+ }
705
+ /**
706
+ * Download a Files-API video (Veo output, or Gemini Omni `delivery:"uri"`
707
+ * output) with the key that produced it and persist it GCS-primary with the
708
+ * truncation guards below. Returns the scene's terminal status.
709
+ */
710
+ async persistFilesApiVideo(client, video, outputFilename, outputFilePath) {
711
+ const localPath = `${outputFilename}.mp4`; // use /tmp for cloud functions
712
+ const filePath = `${outputFilePath}/${outputFilename}.mp4`;
713
+ const file = (0, firebase_1.getBucket)().file(filePath);
714
+ // Download with the SAME key that submitted the task. The Veo output is
715
+ // stored via the Files API in the issuing key's GCP project, so it is
716
+ // project-scoped exactly like the operation — the default `this.ai`
717
+ // (legacy GOOGLE_API_KEY) client can only read files from its own
718
+ // project. With the multi-key pool, the drain-first key (k0) lives in a
719
+ // separate project, so downloading its videos via `this.ai` would 403/404.
720
+ // Use the re-selected `client` (falls back to `this.ai` for un-tagged
721
+ // legacy tasks, which were submitted on GOOGLE_API_KEY anyway).
722
+ // Download → validate-complete → upload, retried as a unit. The Files API
723
+ // downloader intermittently resolves before the clip is fully flushed to
724
+ // disk; uploading then truncates the scene at a chunk boundary (commonly
725
+ // exactly 4 MiB), dropping the trailing `moov` atom and yielding a corrupt
726
+ // MP4 that ffprobe rejects downstream (poster/watermark "moov atom not
727
+ // found", and a broken deliverable). Guard with (1) a stable-size wait so
728
+ // we only read a fully-written file, (2) a structural ftyp+moov check, and
729
+ // (3) a stored-vs-local size match after upload. Any failure re-runs the
730
+ // whole download instead of shipping corruption.
731
+ //
732
+ // GCS-PRIMARY (not mediaStorage.put): the size-verified integrity guard
733
+ // above needs GCS's own writeFromFile + getMetadata size readback: R2
734
+ // gets a best-effort mirror via mediaStorage.mirrorFromFile once the
735
+ // GCS copy is confirmed intact. See notes/R2_MEDIA_MIGRATION_PLAN.md §10.
736
+ const MAX_FETCH_ATTEMPTS = 3;
737
+ let uploaded = false;
738
+ for (let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS && !uploaded; attempt++) {
739
+ await this.withTransientRetry("files.download", () => client.files.download({ file: video, downloadPath: localPath }));
740
+ const localSize = await waitForStableFile(localPath, 30000, 500);
741
+ if (await mp4IsComplete(localPath)) {
742
+ await (0, promises_2.pipeline)((0, fs_1.createReadStream)(localPath), file.createWriteStream({ contentType: "video/mp4" }));
743
+ const [meta] = await file.getMetadata();
744
+ const storedSize = Number(meta.size);
745
+ if (Number.isFinite(storedSize) && storedSize === localSize) {
746
+ uploaded = true;
747
+ break; // faithful, complete upload
578
748
  }
579
- // Failed validation — discard the partial file before the next attempt.
580
- await promises_1.default.unlink(localPath).catch(() => { });
581
- }
582
- if (!uploaded) {
583
- logger_1.logger.error("google: scene download/upload failed integrity validation — giving up", { filePath });
584
- return {
585
- status: types_1.EVideoSceneStatus.FAILED,
586
- errorMessage: "Generated video failed integrity validation (incomplete/truncated download).",
587
- };
749
+ logger_1.logger.warn("google: scene upload size mismatch — retrying", {
750
+ filePath,
751
+ attempt,
752
+ localSize,
753
+ storedSize,
754
+ });
588
755
  }
589
- const [signedUrl] = await file.getSignedUrl({
590
- action: "read",
591
- expires: "03-09-2491",
592
- });
593
- // mediaStorage: best-effort R2 mirror of the integrity-validated local clip
594
- // (zero-egress delivery) — the GCS streaming write above stays the guarded
595
- // system-of-record; this only runs when R2_WRITE_ENABLED. Before cleanup.
596
- await storage_1.mediaStorage.mirrorFromFile(filePath, localPath, "video/mp4");
597
- // Optionally clean up local file
598
- await promises_1.default
599
- .unlink(localPath)
600
- .then(() => {
601
- // console.log("Local file deleted:", localPath);
602
- })
603
- .catch((err) => {
604
- logger_1.logger.warn("google: local file cleanup failed", {
605
- localPath,
606
- err: err?.stack ?? err?.message ?? String(err),
756
+ else {
757
+ logger_1.logger.warn("google: downloaded scene missing moov atom — retrying", {
758
+ filePath,
759
+ attempt,
760
+ localSize,
607
761
  });
608
- });
762
+ }
763
+ // Failed validation — discard the partial file before the next attempt.
764
+ await promises_1.default.unlink(localPath).catch(() => { });
765
+ }
766
+ if (!uploaded) {
767
+ logger_1.logger.error("google: scene download/upload failed integrity validation — giving up", { filePath });
609
768
  return {
610
- videoUrl: signedUrl,
611
- status: types_1.EVideoSceneStatus.COMPLETED,
769
+ status: types_1.EVideoSceneStatus.FAILED,
770
+ errorMessage: "Generated video failed integrity validation (incomplete/truncated download).",
612
771
  };
613
772
  }
614
- return { status: types_1.EVideoSceneStatus.PENDING };
773
+ const [signedUrl] = await file.getSignedUrl({
774
+ action: "read",
775
+ expires: "03-09-2491",
776
+ });
777
+ // mediaStorage: best-effort R2 mirror of the integrity-validated local clip
778
+ // (zero-egress delivery) — the GCS streaming write above stays the guarded
779
+ // system-of-record; this only runs when R2_WRITE_ENABLED. Before cleanup.
780
+ await storage_1.mediaStorage.mirrorFromFile(filePath, localPath, "video/mp4");
781
+ // Optionally clean up local file
782
+ await promises_1.default
783
+ .unlink(localPath)
784
+ .then(() => {
785
+ // console.log("Local file deleted:", localPath);
786
+ })
787
+ .catch((err) => {
788
+ logger_1.logger.warn("google: local file cleanup failed", {
789
+ localPath,
790
+ err: err?.stack ?? err?.message ?? String(err),
791
+ });
792
+ });
793
+ return {
794
+ videoUrl: signedUrl,
795
+ status: types_1.EVideoSceneStatus.COMPLETED,
796
+ };
615
797
  }
616
798
  async generateImage(params) {
617
799
  try {
@@ -654,6 +836,9 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
654
836
  // 200 with the images filtered out — so the reason is only available here.
655
837
  // Mirrors the Veo video path (raiMediaFilteredReasons, above).
656
838
  const blockReasons = [];
839
+ // Nano Banana declining with a text-only answer instead of an image —
840
+ // see the 2026-09-30 comment below where this is populated.
841
+ const textDeclines = [];
657
842
  if (isImagen) {
658
843
  const resp = await this.ai.models.generateImages({
659
844
  model: modelId,
@@ -698,12 +883,28 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
698
883
  // Prefix the requested ratio so the model produces an output that at
699
884
  // least matches orientation (Gemini still won't honor exact pixel
700
885
  // dimensions, but landscape vs portrait is reliable).
701
- const aspectHint = params.aspectRatio
702
- ? `Generate the image with a ${params.aspectRatio} aspect ratio (${params.aspectRatio === "9:16" || params.aspectRatio === "3:4" || params.aspectRatio === "4:5"
703
- ? "vertical portrait"
704
- : params.aspectRatio === "1:1"
705
- ? "square"
706
- : "horizontal landscape"} orientation). `
886
+ //
887
+ // 2026-09-29: the Gemini 3.x image models take a structured
888
+ // `imageConfig { aspectRatio, imageSize }` (ai.google.dev image-generation
889
+ // "Aspect ratios and image size"). When the model declares the requested
890
+ // ratio we send it structurally and skip the prompt hint; the hint stays
891
+ // the fallback for a ratio the model's allowedValues don't list.
892
+ const declaredAspects = modelConfig.fields?.aspectRatio
893
+ ?.allowedValues;
894
+ const structuredAspect = params.aspectRatio && declaredAspects?.includes(params.aspectRatio) ? params.aspectRatio : undefined;
895
+ // imageSize was declared + PRICED per size (nano-banana-pro) but never sent,
896
+ // so a 4K request billed $0.24 and returned the 1K default. "auto_2K" is
897
+ // the picker's legacy default → 2K.
898
+ const declaredSizes = modelConfig.fields?.imageSize
899
+ ?.allowedValues;
900
+ const requestedSize = params.imageSize === "auto_2K" ? "2K" : params.imageSize;
901
+ const structuredSize = requestedSize && declaredSizes?.includes(requestedSize) ? requestedSize : undefined;
902
+ // Orientation is derived from the ratio itself (a fixed portrait list
903
+ // mislabeled 2:3 / 1:4 / 1:8 as landscape).
904
+ const [aw, ah] = (params.aspectRatio ?? "").split(":").map(Number);
905
+ const orientation = aw && ah ? (aw < ah ? "vertical portrait" : aw === ah ? "square" : "horizontal landscape") : "";
906
+ const aspectHint = params.aspectRatio && !structuredAspect
907
+ ? `Generate the image with a ${params.aspectRatio} aspect ratio${orientation ? ` (${orientation} orientation)` : ""}. `
707
908
  : "";
708
909
  const parts = [{ text: `${aspectHint}${params.prompt}` }];
709
910
  const refs = [
@@ -714,34 +915,71 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
714
915
  const ref = await (0, googleFetch_1.fetchAsImage)(url);
715
916
  parts.push({ inlineData: { mimeType: ref.mimeType, data: ref.imageBytes } });
716
917
  }
717
- const resp = await this.ai.models.generateContent({
918
+ // The pinned SDK's ImageConfig type predates `imageSize`, but its request
919
+ // transformer copies `imageConfig` through verbatim, so the field reaches
920
+ // the API. (Newer SDKs type it: `imageSize?: string`.)
921
+ const imageConfig = {
922
+ ...(structuredAspect ? { aspectRatio: structuredAspect } : {}),
923
+ ...(structuredSize ? { imageSize: structuredSize } : {}),
924
+ };
925
+ // generateContent returns ONE image per call, but the route bills
926
+ // numImages (1–8) up front with no shortfall reconciliation — so issue one
927
+ // call per requested image instead of charging N× for a single output.
928
+ const responses = await Promise.all(Array.from({ length: Math.max(1, params.numImages ?? 1) }, () => this.ai.models.generateContent({
718
929
  model: modelId,
719
930
  contents: [{ role: "user", parts }],
720
- });
721
- const candidates = resp.candidates ?? [];
722
- for (const cand of candidates) {
723
- for (const part of cand.content?.parts ?? []) {
724
- const inline = part.inlineData;
725
- if (inline?.data) {
726
- allBytes.push({
727
- bytes: Buffer.from(inline.data, "base64"),
728
- mime: inline.mimeType ?? "image/png",
729
- });
931
+ ...(Object.keys(imageConfig).length ? { config: { imageConfig } } : {}),
932
+ })));
933
+ for (const resp of responses) {
934
+ const candidates = resp.candidates ?? [];
935
+ let gotImageThisResponse = false;
936
+ for (const cand of candidates) {
937
+ for (const part of cand.content?.parts ?? []) {
938
+ const inline = part.inlineData;
939
+ if (inline?.data) {
940
+ allBytes.push({
941
+ bytes: Buffer.from(inline.data, "base64"),
942
+ mime: inline.mimeType ?? "image/png",
943
+ });
944
+ gotImageThisResponse = true;
945
+ }
730
946
  }
731
947
  }
732
- }
733
- // Gemini reports a safety stop two ways, neither of which throws:
734
- // promptFeedback.blockReason (whole prompt rejected → candidates empty)
735
- // and candidate.finishReason (generation halted: IMAGE_SAFETY,
736
- // PROHIBITED_CONTENT, RECITATION, SPII).
737
- const promptBlock = resp
738
- .promptFeedback?.blockReason;
739
- if (promptBlock)
740
- blockReasons.push(promptBlock);
741
- for (const cand of candidates) {
742
- const finish = cand.finishReason;
743
- if (finish && finish !== "STOP" && finish !== "MAX_TOKENS") {
744
- blockReasons.push(finish);
948
+ // Gemini reports a safety stop two ways, neither of which throws:
949
+ // promptFeedback.blockReason (whole prompt rejected → candidates empty)
950
+ // and candidate.finishReason (generation halted: IMAGE_SAFETY,
951
+ // PROHIBITED_CONTENT, RECITATION, SPII).
952
+ const promptBlock = resp
953
+ .promptFeedback?.blockReason;
954
+ if (promptBlock)
955
+ blockReasons.push(promptBlock);
956
+ for (const cand of candidates) {
957
+ const finish = cand.finishReason;
958
+ if (finish && finish !== "STOP" && finish !== "MAX_TOKENS") {
959
+ blockReasons.push(finish);
960
+ }
961
+ }
962
+ // 2026-09-30: gemini-3.1-flash-image (Nano Banana 2) refuses some
963
+ // requests with finishReason "STOP" and NO promptFeedback block — it
964
+ // just answers with a TEXT part instead of an image. Verified by
965
+ // replaying prod job 9NyH60DhQD5wZiS6UenP (an edit asking to undress
966
+ // a person in an uploaded photo): candidates[0] = one text part, in
967
+ // the user's language ("Saya tidak bisa mengubah gambar ... karena
968
+ // adanya pelanggaran kebijakan terkait konten seksual"),
969
+ // finishReason STOP, no promptFeedback. Neither block signal above
970
+ // fires, so this used to fall through to the generic "no image
971
+ // bytes" Error and page Slack on every refusal. Harvest the model's
972
+ // text as the reason. It is logged (warn) for triage but never shown
973
+ // to the user — it is model-language-specific free text.
974
+ if (!gotImageThisResponse) {
975
+ const declineText = candidates
976
+ .flatMap((cand) => cand.content?.parts ?? [])
977
+ .map((part) => part.text)
978
+ .filter((text) => Boolean(text && text.trim()))
979
+ .join(" ")
980
+ .trim();
981
+ if (declineText)
982
+ textDeclines.push(declineText);
745
983
  }
746
984
  }
747
985
  }
@@ -763,8 +1001,20 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
763
1001
  });
764
1002
  throw new errors_1.UserFacingError("Your prompt was flagged by Google's safety filters. Please rephrase and try again.", errors_1.USER_FACING_ERROR_CODES.CONTENT_POLICY_VIOLATION);
765
1003
  }
766
- // No block reason at all — genuinely unexpected; keep it loud so a real
767
- // platform/SDK regression isn't muted by the branch above.
1004
+ if (textDeclines.length) {
1005
+ logger_1.logger.warn("Google image generation declined with text instead of an image", {
1006
+ modelKey: params.modelKey,
1007
+ modelId,
1008
+ declines: textDeclines,
1009
+ });
1010
+ // A text-only STOP from an image model is a refusal (policy), same
1011
+ // outcome as the safety-filter branch above, so same code: the
1012
+ // frontend already maps it to "flagged by the model's safety filters".
1013
+ throw new errors_1.UserFacingError("Your prompt was flagged by Google's safety filters. Please rephrase and try again.", errors_1.USER_FACING_ERROR_CODES.CONTENT_POLICY_VIOLATION);
1014
+ }
1015
+ // No block reason and no text at all — genuinely unexpected; keep it
1016
+ // loud so a real platform/SDK regression isn't muted by the branches
1017
+ // above.
768
1018
  throw new Error("Google image generation returned no image bytes");
769
1019
  }
770
1020
  const ts = Date.now();
@@ -805,8 +1055,14 @@ class GoogleService extends baseAiGenProvider_service_1.BaseAiGenProviderService
805
1055
  // Image-gen per-image cost keyed by output size (nano-banana-pro). Falls
806
1056
  // back to `cost.fixed` if `imageSize` isn't supplied by the caller.
807
1057
  if (cost?.perImageSize) {
808
- const key = imageSize ?? "2K";
809
- const perImage = cost.perImageSize[key] ?? cost.fixed ?? 0;
1058
+ // No size → nothing is sent and the model renders its default (1K), which
1059
+ // `cost.fixed` prices. "auto_2K" is normalized to "2K" exactly as
1060
+ // _generateImage does, so we bill the size actually requested.
1061
+ // REMOVED FOR v2 — kept for reference: defaulted to 2K, overbilling
1062
+ // size-less requests on models whose 1K and 2K prices differ.
1063
+ // const key = imageSize ?? "2K";
1064
+ const key = imageSize === "auto_2K" ? "2K" : imageSize;
1065
+ const perImage = (key !== undefined ? cost.perImageSize[key] : undefined) ?? cost.fixed ?? 0;
810
1066
  return (0, helpers_1.getCreditsFromCost)(perImage * Math.max(1, numImages), applyFloor, creditFloor);
811
1067
  }
812
1068
  // Image-gen flat per-image cost (Imagen / Nano Banana).
@@ -39,6 +39,12 @@ declare class GoogleKeyPool {
39
39
  get primaryClient(): GoogleGenAI;
40
40
  /** Resolve a tagged task's key id back to its client (undefined if unknown). */
41
41
  clientById(id: string | undefined): GoogleGenAI | undefined;
42
+ /**
43
+ * Raw API key for a tagged task's key id, for REST paths the pinned SDK
44
+ * doesn't wrap (the Interactions API behind Gemini Omni). Untagged/unknown
45
+ * ids resolve to the primary key, which is where legacy tasks were issued.
46
+ */
47
+ apiKeyById(id: string | undefined): string;
42
48
  /**
43
49
  * Pick the key that should serve a new submission. Walks keys in priority
44
50
  * order and returns the first whose per-key minute + day budgets still have
@@ -1 +1 @@
1
- {"version":3,"file":"googleKeyPool.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/google/googleKeyPool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAG5C,OAAO,EACL,iBAAiB,EAKlB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;;;;;GAgBG;AAEH,UAAU,QAAQ;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,CAAC;IACpB,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;CACd;AAiDD,cAAM,aAAa;IACjB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAa;;IA4BrC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,8EAA8E;IAC9E,IAAI,MAAM,IAAI,MAAM,EAAE,CAErB;IAUD,OAAO,CAAC,WAAW;IAInB,kFAAkF;IAC5E,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjF,qFAAqF;YACvE,WAAW;IAiBzB,mDAAmD;IACnD,IAAI,aAAa,IAAI,WAAW,CAE/B;IAED,gFAAgF;IAChF,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS;IAK3D;;;;;;;;OAQG;IACG,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACjB,OAAO,CAAC,QAAQ,CAAC;IA4DpB,8EAA8E;YAChE,OAAO;CAUtB;AAID,sCAAsC;AACtC,wBAAgB,gBAAgB,IAAI,aAAa,CAGhD;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,YAAY,EAAE,aAAa,EAAE,CAAC"}
1
+ {"version":3,"file":"googleKeyPool.d.ts","sourceRoot":"","sources":["../../../../../src/services/aiGen/providers/google/googleKeyPool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAG5C,OAAO,EACL,iBAAiB,EAKlB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;;;;;GAgBG;AAEH,UAAU,QAAQ;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,CAAC;IACpB,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;CACd;AAiDD,cAAM,aAAa;IACjB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAa;;IA4BrC,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,8EAA8E;IAC9E,IAAI,MAAM,IAAI,MAAM,EAAE,CAErB;IAUD,OAAO,CAAC,WAAW;IAInB,kFAAkF;IAC5E,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBjF,qFAAqF;YACvE,WAAW;IAiBzB,mDAAmD;IACnD,IAAI,aAAa,IAAI,WAAW,CAE/B;IAED,gFAAgF;IAChF,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS;IAK3D;;;;OAIG;IACH,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM;IAI1C;;;;;;;;OAQG;IACG,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACjB,OAAO,CAAC,QAAQ,CAAC;IA4DpB,8EAA8E;YAChE,OAAO;CAUtB;AAID,sCAAsC;AACtC,wBAAgB,gBAAgB,IAAI,aAAa,CAGhD;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,YAAY,EAAE,aAAa,EAAE,CAAC"}
@@ -141,6 +141,14 @@ class GoogleKeyPool {
141
141
  return undefined;
142
142
  return this.entries.find((e) => e.id === id)?.client;
143
143
  }
144
+ /**
145
+ * Raw API key for a tagged task's key id, for REST paths the pinned SDK
146
+ * doesn't wrap (the Interactions API behind Gemini Omni). Untagged/unknown
147
+ * ids resolve to the primary key, which is where legacy tasks were issued.
148
+ */
149
+ apiKeyById(id) {
150
+ return (id && this.entries.find((e) => e.id === id)?.apiKey) || this.entries[0].apiKey;
151
+ }
144
152
  /**
145
153
  * Pick the key that should serve a new submission. Walks keys in priority
146
154
  * order and returns the first whose per-key minute + day budgets still have