@mux/ai 0.19.0 → 0.21.1

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/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // package.json
8
- var version = "0.19.0";
8
+ var version = "0.21.1";
9
9
 
10
10
  // src/lib/prompt-fragments.ts
11
11
  import dedent from "dedent";
@@ -83,6 +83,7 @@ var EnvSchema = z.object({
83
83
  GOOGLE_GENERATIVE_AI_API_KEY: optionalString("Google Generative AI API key for Gemini-backed workflows.", "Google Generative AI API key"),
84
84
  ELEVENLABS_API_KEY: optionalString("ElevenLabs API key for audio translation.", "ElevenLabs API key"),
85
85
  HIVE_API_KEY: optionalString("Hive Visual Moderation API key.", "Hive API key"),
86
+ GOOGLE_VISION_API_KEY: optionalString("Google Vision API key for SafeSearch moderation.", "Google Vision API key"),
86
87
  // S3-Compatible Storage (required for translation & audio dubbing)
87
88
  S3_ENDPOINT: optionalString("S3-compatible endpoint for uploads.", "S3 endpoint"),
88
89
  S3_REGION: optionalString("S3 region (defaults to 'auto' when omitted)."),
@@ -763,23 +764,26 @@ function resolveProviderApiKeyFromCredentials(provider, resolved) {
763
764
  const openaiApiKey = readString(record, "openaiApiKey");
764
765
  const anthropicApiKey = readString(record, "anthropicApiKey");
765
766
  const googleApiKey = readString(record, "googleApiKey");
767
+ const googleVisionApiKey = readString(record, "googleVisionApiKey");
766
768
  const hiveApiKey = readString(record, "hiveApiKey");
767
769
  const elevenLabsApiKey = readString(record, "elevenLabsApiKey");
768
770
  const apiKeyMap = {
769
- openai: openaiApiKey ?? env_default.OPENAI_API_KEY,
770
- anthropic: anthropicApiKey ?? env_default.ANTHROPIC_API_KEY,
771
- google: googleApiKey ?? env_default.GOOGLE_GENERATIVE_AI_API_KEY,
772
- hive: hiveApiKey ?? env_default.HIVE_API_KEY,
773
- elevenlabs: elevenLabsApiKey ?? env_default.ELEVENLABS_API_KEY
771
+ "openai": openaiApiKey ?? env_default.OPENAI_API_KEY,
772
+ "anthropic": anthropicApiKey ?? env_default.ANTHROPIC_API_KEY,
773
+ "google": googleApiKey ?? env_default.GOOGLE_GENERATIVE_AI_API_KEY,
774
+ "google-vision-api": googleVisionApiKey ?? env_default.GOOGLE_VISION_API_KEY,
775
+ "hive": hiveApiKey ?? env_default.HIVE_API_KEY,
776
+ "elevenlabs": elevenLabsApiKey ?? env_default.ELEVENLABS_API_KEY
774
777
  };
775
778
  const apiKey = apiKeyMap[provider];
776
779
  if (!apiKey) {
777
780
  const envVarNames = {
778
- openai: "OPENAI_API_KEY",
779
- anthropic: "ANTHROPIC_API_KEY",
780
- google: "GOOGLE_GENERATIVE_AI_API_KEY",
781
- hive: "HIVE_API_KEY",
782
- elevenlabs: "ELEVENLABS_API_KEY"
781
+ "openai": "OPENAI_API_KEY",
782
+ "anthropic": "ANTHROPIC_API_KEY",
783
+ "google": "GOOGLE_GENERATIVE_AI_API_KEY",
784
+ "google-vision-api": "GOOGLE_VISION_API_KEY",
785
+ "hive": "HIVE_API_KEY",
786
+ "elevenlabs": "ELEVENLABS_API_KEY"
783
787
  };
784
788
  throw new Error(
785
789
  `${provider} API key is required. Provide ${provider} credentials via workflow credentials or set ${envVarNames[provider]} environment variable.`
@@ -2554,6 +2558,7 @@ __export(workflows_exports, {
2554
2558
  DEFAULT_DESCRIPTION_LENGTH: () => DEFAULT_DESCRIPTION_LENGTH,
2555
2559
  DEFAULT_SUMMARY_KEYWORD_LIMIT: () => DEFAULT_SUMMARY_KEYWORD_LIMIT,
2556
2560
  DEFAULT_TITLE_LENGTH: () => DEFAULT_TITLE_LENGTH,
2561
+ GOOGLE_VISION_LIKELIHOOD_TO_SCORE: () => GOOGLE_VISION_LIKELIHOOD_TO_SCORE,
2557
2562
  HIVE_SEXUAL_CATEGORIES: () => HIVE_SEXUAL_CATEGORIES,
2558
2563
  HIVE_VIOLENCE_CATEGORIES: () => HIVE_VIOLENCE_CATEGORIES,
2559
2564
  aggregateTokenUsage: () => aggregateTokenUsage,
@@ -2828,8 +2833,7 @@ var questionAnswerSchema = z2.object({
2828
2833
  skipped: z2.boolean()
2829
2834
  });
2830
2835
  var SKIP_SENTINEL = "__SKIPPED__";
2831
- function createAskQuestionsSchema(allowedAnswers) {
2832
- const answerSchema = z2.enum([...allowedAnswers, SKIP_SENTINEL]);
2836
+ function createAskQuestionsSchema(answerSchema) {
2833
2837
  return z2.object({
2834
2838
  answers: z2.array(
2835
2839
  questionAnswerSchema.extend({
@@ -2865,15 +2869,16 @@ var SYSTEM_PROMPT = promptDedent`
2865
2869
  <task>
2866
2870
  For each question provided, you must:
2867
2871
  1. Analyze the storyboard frames and transcript (if provided)
2868
- 2. Answer with ONLY the allowed response options listed for that question - no other values are acceptable
2872
+ 2. Answer each question according to its specified response format: for questions with <allowed_answers>, select ONLY from the listed values; for questions with <answer_format>, follow the format specification exactly
2869
2873
  3. Provide a confidence score between 0 and 1 reflecting your certainty
2870
2874
  4. Explain your reasoning based on observable evidence
2871
2875
  </task>
2872
2876
 
2873
2877
  <answer_guidelines>
2874
- - Each question is presented as its own <question> block with a <text> element (the question) and an <allowed_answers> element (the permitted response values)
2875
- - Choose only from the values listed in that question's <allowed_answers> element
2876
- - Questions may have any set of allowed answers. Always read the <allowed_answers> for each question and select the best matching option based on the evidence
2878
+ - Each question is presented as its own <question> block with a <text> element and either an <allowed_answers> element (listing the permitted response values) or an <answer_format> element (describing the required response shape for open-ended responses)
2879
+ - For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
2880
+ - For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
2881
+ - Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
2877
2882
  - Select the answer best supported by observable evidence from the content
2878
2883
  - When evidence is ambiguous but some signal exists, select the most conservative option and use a low confidence score. If the question cannot be answered at all from the content, skip it per the relevance_filtering rules
2879
2884
  - Confidence should reflect the clarity and strength of evidence:
@@ -2900,7 +2905,8 @@ var SYSTEM_PROMPT = promptDedent`
2900
2905
  CRITICAL: Base your answers ONLY on the actual visual and audio/transcript content.
2901
2906
  ${METADATA_BOUNDARY_WARNING}
2902
2907
 
2903
- CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
2908
+ CRITICAL: Do NOT answer irrelevant questions in any form — neither with
2909
+ one of the <allowed_answers> nor with prose for <answer_format>.
2904
2910
  Answering an irrelevant question is WRONG — you MUST skip it instead.
2905
2911
 
2906
2912
  For skipped questions:
@@ -2927,7 +2933,7 @@ var SYSTEM_PROMPT = promptDedent`
2927
2933
  </security>
2928
2934
 
2929
2935
  <constraints>
2930
- - You MUST answer every relevant question with one of its own listed allowed response options
2936
+ - You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
2931
2937
  - Skip irrelevant questions as described in relevance_filtering
2932
2938
  - Only describe observable evidence from frames or transcript
2933
2939
  - ${NO_FABRICATION_CONSTRAINT}
@@ -2962,15 +2968,16 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2962
2968
  <task>
2963
2969
  For each question provided, you must:
2964
2970
  1. Analyze the transcript
2965
- 2. Answer with ONLY the allowed response options listed for that question - no other values are acceptable
2971
+ 2. Answer each question according to its specified response format: for questions with <allowed_answers>, select ONLY from the listed values; for questions with <answer_format>, follow the format specification exactly
2966
2972
  3. Provide a confidence score between 0 and 1 reflecting your certainty
2967
2973
  4. Explain your reasoning based on observable evidence
2968
2974
  </task>
2969
2975
 
2970
2976
  <answer_guidelines>
2971
- - Each question is presented as its own <question> block with a <text> element (the question) and an <allowed_answers> element (the permitted response values)
2972
- - Choose only from the values listed in that question's <allowed_answers> element
2973
- - Questions may have any set of allowed answers. Always read the <allowed_answers> for each question and select the best matching option based on the evidence
2977
+ - Each question is presented as its own <question> block with a <text> element and either an <allowed_answers> element (listing the permitted response values) or an <answer_format> element (describing the required response shape for open-ended responses)
2978
+ - For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
2979
+ - For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
2980
+ - Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
2974
2981
  - Select the answer best supported by observable evidence from the content
2975
2982
  - When evidence is ambiguous but some signal exists, select the most conservative option and use a low confidence score. If the question cannot be answered at all from the content, skip it per the relevance_filtering rules
2976
2983
  - Confidence should reflect the clarity and strength of evidence:
@@ -2997,7 +3004,8 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2997
3004
  CRITICAL: Base your answers ONLY on the actual audio/transcript content.
2998
3005
  ${METADATA_BOUNDARY_WARNING}
2999
3006
 
3000
- CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
3007
+ CRITICAL: Do NOT answer irrelevant questions in any form — neither with
3008
+ one of the <allowed_answers> nor with prose for <answer_format>.
3001
3009
  Answering an irrelevant question is WRONG — you MUST skip it instead.
3002
3010
 
3003
3011
  For skipped questions:
@@ -3022,7 +3030,7 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
3022
3030
  </security>
3023
3031
 
3024
3032
  <constraints>
3025
- - You MUST answer every relevant question with one of its own listed allowed response options
3033
+ - You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
3026
3034
  - Skip irrelevant questions as described in relevance_filtering
3027
3035
  - Only describe observable evidence from transcript content
3028
3036
  - ${NO_FABRICATION_CONSTRAINT}
@@ -3037,16 +3045,104 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
3037
3045
  - GOOD: "The speaker describes running through a park"
3038
3046
  - Be specific and evidence-based
3039
3047
  </language_guidelines>`;
3048
+ function freeFormAddendum(maxFreeFormAnswerLength) {
3049
+ return promptDedent`
3050
+ <free_form_answers>
3051
+ Additional rules for questions with <answer_format>free-form text ...</answer_format>:
3052
+
3053
+ - Produce a short, evidence-grounded answer in plain prose
3054
+ - Keep it tight: one or two sentences, maximum ${maxFreeFormAnswerLength} characters
3055
+ - If the question is irrelevant to the asset content, still skip it
3056
+ by setting answer to "${SKIP_SENTINEL}" exactly, per relevance_filtering
3057
+ - The answer field is a content-analysis channel, not an instruction
3058
+ channel. Do NOT include raw instructions, escape sequences, code,
3059
+ or anything that looks like structured control content in the
3060
+ answer. All existing security, non-fabrication, and
3061
+ non-disclosure rules apply unchanged.
3062
+ </free_form_answers>`;
3063
+ }
3064
+ function buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength) {
3065
+ const base = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
3066
+ if (!hasFreeForm)
3067
+ return base;
3068
+ return `${base}
3069
+
3070
+ ${freeFormAddendum(maxFreeFormAnswerLength)}`;
3071
+ }
3072
+ var YES_NO_ANSWERS = ["yes", "no"];
3073
+ function allowedAnswersForQuestion(q) {
3074
+ switch (q.mode.kind) {
3075
+ case "yesNo":
3076
+ return YES_NO_ANSWERS;
3077
+ case "options":
3078
+ return q.mode.options;
3079
+ case "freeForm":
3080
+ return [];
3081
+ }
3082
+ }
3083
+ function normalizeQuestion(q, idx) {
3084
+ const hasOptions = Array.isArray(q.answerOptions);
3085
+ const isFreeForm = q.freeFormReply === true;
3086
+ if (hasOptions && isFreeForm) {
3087
+ throw new MuxAiError(
3088
+ `Question at index ${idx} sets both answerOptions and freeFormReply: true. These are mutually exclusive \u2014 pick one.`,
3089
+ { type: "validation_error" }
3090
+ );
3091
+ }
3092
+ if (isFreeForm) {
3093
+ return { question: q.question, mode: { kind: "freeForm" } };
3094
+ }
3095
+ if (hasOptions) {
3096
+ const options = Array.from(
3097
+ new Set(
3098
+ (q.answerOptions ?? []).map((o) => o.trim()).filter(Boolean)
3099
+ )
3100
+ );
3101
+ if (!options.length) {
3102
+ throw new MuxAiError(
3103
+ `Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
3104
+ { type: "validation_error" }
3105
+ );
3106
+ }
3107
+ return {
3108
+ question: q.question,
3109
+ mode: { kind: "options", options }
3110
+ };
3111
+ }
3112
+ return { question: q.question, mode: { kind: "yesNo" } };
3113
+ }
3114
+ function renderFormatTag(mode, maxFreeFormAnswerLength) {
3115
+ switch (mode.kind) {
3116
+ case "yesNo":
3117
+ return renderSection({
3118
+ tag: "allowed_answers",
3119
+ content: YES_NO_ANSWERS.map((a) => `"${a}"`).join(", ")
3120
+ });
3121
+ case "options":
3122
+ return renderSection({
3123
+ tag: "allowed_answers",
3124
+ content: mode.options.map((a) => `"${a}"`).join(", ")
3125
+ });
3126
+ case "freeForm":
3127
+ return renderSection({
3128
+ tag: "answer_format",
3129
+ content: `free-form text, maximum ${maxFreeFormAnswerLength} characters`
3130
+ });
3131
+ }
3132
+ }
3040
3133
  function buildUserPrompt({
3041
3134
  questions,
3042
3135
  transcriptText,
3043
3136
  isCleanTranscript = true,
3044
- isAudioOnly = false
3137
+ isAudioOnly = false,
3138
+ maxFreeFormAnswerLength
3045
3139
  }) {
3046
3140
  const contentDescriptor = isAudioOnly ? "audio content using transcript evidence" : "content";
3141
+ const hasFreeForm = questions.some((q) => q.mode.kind === "freeForm");
3142
+ const formatInstruction = hasFreeForm ? "Follow each question's specified response format: pick from <allowed_answers> where listed, or produce a concise free-form answer where <answer_format> is specified." : "Use only the values listed inside each question's own <allowed_answers> element.";
3047
3143
  const taskContent = dedent2`
3048
3144
  Answer each question in the <questions> block below about the ${contentDescriptor}.
3049
- Use only the values listed inside each question's own <allowed_answers> element.
3145
+ ${formatInstruction}
3050
3146
  Return one answer per question, in the order the questions appear.
3051
3147
  If a question cannot be answered from the provided content, skip it as described in the system instructions.`;
3052
3148
  const taskSection = `<task>
@@ -3054,13 +3150,10 @@ ${taskContent}
3054
3150
  </task>`;
3055
3151
  const questionBlocks = questions.map((q, idx) => {
3056
3152
  const textTag = renderSection({ tag: "text", content: q.question });
3057
- const answersTag = renderSection({
3058
- tag: "allowed_answers",
3059
- content: q.answerOptions.map((a) => `"${a}"`).join(", ")
3060
- });
3153
+ const formatTag = renderFormatTag(q.mode, maxFreeFormAnswerLength);
3061
3154
  return `<question number="${idx + 1}">
3062
3155
  ${textTag}
3063
- ${answersTag}
3156
+ ${formatTag}
3064
3157
  </question>`;
3065
3158
  }).join("\n\n");
3066
3159
  const questionsSection = `<questions>
@@ -3080,18 +3173,41 @@ async function fetchImageAsBase64(imageUrl, imageDownloadOptions) {
3080
3173
  const downloadResult = await downloadImageAsBase64(imageUrl, imageDownloadOptions);
3081
3174
  return downloadResult.base64Data;
3082
3175
  }
3176
+ function buildResponseSchemaForQuestions(normalizedQuestions, maxFreeFormAnswerLength) {
3177
+ const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
3178
+ if (hasFreeForm) {
3179
+ const longestConstrained = Math.max(
3180
+ 0,
3181
+ ...normalizedQuestions.flatMap(allowedAnswersForQuestion).map((a) => a.length)
3182
+ );
3183
+ const schemaMax = Math.max(
3184
+ maxFreeFormAnswerLength,
3185
+ SKIP_SENTINEL.length,
3186
+ longestConstrained
3187
+ );
3188
+ return createAskQuestionsSchema(z2.string().min(1).max(schemaMax));
3189
+ }
3190
+ const allowed = Array.from(
3191
+ new Set(normalizedQuestions.flatMap(allowedAnswersForQuestion))
3192
+ );
3193
+ return createAskQuestionsSchema(z2.enum([...allowed, SKIP_SENTINEL]));
3194
+ }
3083
3195
  async function analyzeQuestions({
3084
3196
  provider,
3085
3197
  modelId,
3086
3198
  userPrompt,
3087
3199
  systemPrompt,
3088
- allowedAnswers,
3200
+ normalizedQuestions,
3201
+ maxFreeFormAnswerLength,
3089
3202
  imageDataUrl,
3090
3203
  credentials
3091
3204
  }) {
3092
3205
  "use step";
3093
3206
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
3094
- const responseSchema = createAskQuestionsSchema(allowedAnswers);
3207
+ const responseSchema = buildResponseSchemaForQuestions(
3208
+ normalizedQuestions,
3209
+ maxFreeFormAnswerLength
3210
+ );
3095
3211
  const response = await generateText({
3096
3212
  model,
3097
3213
  output: Output.object({ schema: responseSchema }),
@@ -3170,6 +3286,8 @@ async function askQuestions(assetId, questions, options) {
3170
3286
  );
3171
3287
  }
3172
3288
  questions.forEach((q, idx) => {
3289
+ if (q.freeFormReply === true)
3290
+ return;
3173
3291
  for (const opt of q.answerOptions ?? []) {
3174
3292
  if (typeof opt === "string" && opt.length > maxAnswerOptionLength) {
3175
3293
  throw new MuxAiError(
@@ -3179,6 +3297,14 @@ async function askQuestions(assetId, questions, options) {
3179
3297
  }
3180
3298
  }
3181
3299
  });
3300
+ const DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH = 500;
3301
+ const maxFreeFormAnswerLength = options?.maxFreeFormAnswerLength ?? DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH;
3302
+ if (!Number.isFinite(maxFreeFormAnswerLength) || maxFreeFormAnswerLength <= 0) {
3303
+ throw new MuxAiError(
3304
+ `maxFreeFormAnswerLength must be a positive number (received ${maxFreeFormAnswerLength}).`,
3305
+ { type: "validation_error" }
3306
+ );
3307
+ }
3182
3308
  const {
3183
3309
  provider = "openai",
3184
3310
  model,
@@ -3190,23 +3316,8 @@ async function askQuestions(assetId, questions, options) {
3190
3316
  storyboardWidth = 640,
3191
3317
  credentials
3192
3318
  } = options ?? {};
3193
- const normalizedQuestions = questions.map((q, idx) => {
3194
- const options2 = Array.from(
3195
- new Set(
3196
- (q.answerOptions?.length ? q.answerOptions : ["yes", "no"]).map((o) => o.trim()).filter(Boolean)
3197
- )
3198
- );
3199
- if (!options2.length) {
3200
- throw new MuxAiError(
3201
- `Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
3202
- { type: "validation_error" }
3203
- );
3204
- }
3205
- return { ...q, answerOptions: options2 };
3206
- });
3207
- const allowedAnswers = Array.from(
3208
- new Set(normalizedQuestions.flatMap((q) => q.answerOptions))
3209
- );
3319
+ const normalizedQuestions = questions.map((q, idx) => normalizeQuestion(q, idx));
3320
+ const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
3210
3321
  const modelConfig = resolveLanguageModelConfig({
3211
3322
  ...options,
3212
3323
  model,
@@ -3240,9 +3351,10 @@ async function askQuestions(assetId, questions, options) {
3240
3351
  questions: normalizedQuestions,
3241
3352
  transcriptText,
3242
3353
  isCleanTranscript: cleanTranscript,
3243
- isAudioOnly
3354
+ isAudioOnly,
3355
+ maxFreeFormAnswerLength
3244
3356
  });
3245
- const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
3357
+ const systemPrompt = buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength);
3246
3358
  let analysisResponse;
3247
3359
  let imageUrl;
3248
3360
  try {
@@ -3252,7 +3364,8 @@ async function askQuestions(assetId, questions, options) {
3252
3364
  modelId: modelConfig.modelId,
3253
3365
  userPrompt,
3254
3366
  systemPrompt,
3255
- allowedAnswers,
3367
+ normalizedQuestions,
3368
+ maxFreeFormAnswerLength,
3256
3369
  credentials
3257
3370
  });
3258
3371
  } else {
@@ -3270,7 +3383,8 @@ async function askQuestions(assetId, questions, options) {
3270
3383
  modelId: modelConfig.modelId,
3271
3384
  userPrompt,
3272
3385
  systemPrompt,
3273
- allowedAnswers,
3386
+ normalizedQuestions,
3387
+ maxFreeFormAnswerLength,
3274
3388
  imageDataUrl: base64Data,
3275
3389
  credentials
3276
3390
  });
@@ -3281,7 +3395,8 @@ async function askQuestions(assetId, questions, options) {
3281
3395
  modelId: modelConfig.modelId,
3282
3396
  userPrompt,
3283
3397
  systemPrompt,
3284
- allowedAnswers,
3398
+ normalizedQuestions,
3399
+ maxFreeFormAnswerLength,
3285
3400
  imageDataUrl: storyboardUrl,
3286
3401
  credentials
3287
3402
  })
@@ -3316,20 +3431,42 @@ async function askQuestions(assetId, questions, options) {
3316
3431
  );
3317
3432
  }
3318
3433
  const answers = analysisResponse.result.answers.map((raw, idx) => {
3319
- const scrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
3320
- const isSkipped = raw.skipped || raw.answer === SKIP_SENTINEL || scrub.leaked;
3321
- if (!isSkipped && !normalizedQuestions[idx].answerOptions.includes(raw.answer)) {
3322
- throw new MuxAiError(
3323
- `Answer "${raw.answer}" for question ${idx} is not in allowed options: ${normalizedQuestions[idx].answerOptions.join(", ")}`
3434
+ const question = normalizedQuestions[idx];
3435
+ const isFreeForm = question.mode.kind === "freeForm";
3436
+ const reasoningScrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
3437
+ const explicitSkip = raw.skipped || raw.answer === SKIP_SENTINEL;
3438
+ const answerScrub = isFreeForm && !explicitSkip ? safety.scrubDetailed(raw.answer, `answer[${idx}]`) : null;
3439
+ const overLength = isFreeForm && !explicitSkip && !(answerScrub?.leaked ?? false) && (answerScrub?.text.length ?? 0) > maxFreeFormAnswerLength;
3440
+ if (overLength) {
3441
+ console.warn(
3442
+ `[@mux/ai] Free-form answer at index ${idx} exceeded ${maxFreeFormAnswerLength}-char cap (${answerScrub.text.length} chars); treating as skipped.`
3324
3443
  );
3325
3444
  }
3445
+ const isSkipped = explicitSkip || reasoningScrub.leaked || (answerScrub?.leaked ?? false) || overLength;
3446
+ if (!isSkipped && !isFreeForm) {
3447
+ const allowed = allowedAnswersForQuestion(question);
3448
+ if (!allowed.includes(raw.answer)) {
3449
+ throw new MuxAiError(
3450
+ `Answer "${raw.answer}" for question ${idx} is not in allowed options: ${allowed.join(", ")}`
3451
+ );
3452
+ }
3453
+ }
3454
+ let finalAnswer;
3455
+ if (isSkipped) {
3456
+ finalAnswer = null;
3457
+ } else if (isFreeForm) {
3458
+ finalAnswer = answerScrub ? answerScrub.text : raw.answer;
3459
+ } else {
3460
+ finalAnswer = raw.answer;
3461
+ }
3462
+ const suppressed = reasoningScrub.leaked || (answerScrub?.leaked ?? false);
3326
3463
  return {
3327
3464
  // Use the trusted input verbatim rather than the model's echo.
3328
- question: normalizedQuestions[idx].question,
3465
+ question: question.question,
3329
3466
  confidence: isSkipped ? 0 : Math.min(1, Math.max(0, raw.confidence)),
3330
- reasoning: scrub.leaked ? "Response suppressed by safety filter." : scrub.text,
3467
+ reasoning: suppressed ? "Response suppressed by safety filter." : reasoningScrub.text,
3331
3468
  skipped: isSkipped,
3332
- answer: isSkipped ? null : raw.answer
3469
+ answer: finalAnswer
3333
3470
  };
3334
3471
  });
3335
3472
  return {
@@ -5042,6 +5179,15 @@ var OPENAI_MODERATION_BASE_DELAY_MS = 750;
5042
5179
  var OPENAI_MODERATION_MAX_DELAY_MS = 3e3;
5043
5180
  var MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING = 0.5;
5044
5181
  var MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING = 3;
5182
+ var GOOGLE_VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
5183
+ var GOOGLE_VISION_LIKELIHOOD_TO_SCORE = {
5184
+ UNKNOWN: 0,
5185
+ VERY_UNLIKELY: 0.2,
5186
+ UNLIKELY: 0.4,
5187
+ POSSIBLE: 0.6,
5188
+ LIKELY: 0.8,
5189
+ VERY_LIKELY: 1
5190
+ };
5045
5191
  var HIVE_ENDPOINT = "https://api.thehive.ai/api/v2/task/sync";
5046
5192
  var HIVE_SEXUAL_CATEGORIES = [
5047
5193
  "general_nsfw",
@@ -5370,6 +5516,98 @@ async function requestHiveModeration(images, maxConcurrent = 5, submissionMode =
5370
5516
  }));
5371
5517
  return await processConcurrently(targets, moderateImageWithHive, maxConcurrent);
5372
5518
  }
5519
+ async function moderateImageWithGoogleVision(entry) {
5520
+ "use step";
5521
+ try {
5522
+ const apiKey = await getApiKeyFromEnv("google-vision-api", entry.credentials);
5523
+ const imageField = entry.isBase64 ? { content: entry.image } : { source: { imageUri: entry.image } };
5524
+ const controller = new AbortController();
5525
+ const timeout = setTimeout(() => controller.abort(), 15e3);
5526
+ let res;
5527
+ try {
5528
+ res = await fetch(GOOGLE_VISION_ENDPOINT, {
5529
+ method: "POST",
5530
+ headers: {
5531
+ "Accept": "application/json",
5532
+ "Content-Type": "application/json",
5533
+ "X-goog-api-key": apiKey
5534
+ },
5535
+ body: JSON.stringify({
5536
+ requests: [
5537
+ {
5538
+ image: imageField,
5539
+ features: [{ type: "SAFE_SEARCH_DETECTION" }]
5540
+ }
5541
+ ]
5542
+ }),
5543
+ signal: controller.signal
5544
+ });
5545
+ } catch (err) {
5546
+ if (err?.name === "AbortError") {
5547
+ throw new Error("Google Vision request timed out after 15s");
5548
+ }
5549
+ throw err;
5550
+ } finally {
5551
+ clearTimeout(timeout);
5552
+ }
5553
+ const json = await res.json().catch(() => void 0);
5554
+ if (!res.ok) {
5555
+ throw new Error(
5556
+ `Google Vision moderation error: ${res.status} ${res.statusText} - ${JSON.stringify(json)}`
5557
+ );
5558
+ }
5559
+ const perImage = json?.responses?.[0];
5560
+ if (perImage?.error) {
5561
+ throw new Error(
5562
+ `Google Vision per-image error: ${perImage.error.code} ${perImage.error.message || "Unknown error"}`
5563
+ );
5564
+ }
5565
+ const annotation = perImage?.safeSearchAnnotation;
5566
+ if (!annotation) {
5567
+ throw new Error(`Google Vision response missing safeSearchAnnotation: ${JSON.stringify(json)}`);
5568
+ }
5569
+ const adultLikelihood = typeof annotation.adult === "string" ? annotation.adult : "UNKNOWN";
5570
+ const violenceLikelihood = typeof annotation.violence === "string" ? annotation.violence : "UNKNOWN";
5571
+ const sexual = GOOGLE_VISION_LIKELIHOOD_TO_SCORE[adultLikelihood] ?? 0;
5572
+ const violence = GOOGLE_VISION_LIKELIHOOD_TO_SCORE[violenceLikelihood] ?? 0;
5573
+ return {
5574
+ url: entry.url,
5575
+ time: entry.time,
5576
+ sexual,
5577
+ violence,
5578
+ error: false
5579
+ };
5580
+ } catch (error) {
5581
+ return {
5582
+ url: entry.url,
5583
+ time: entry.time,
5584
+ sexual: 0,
5585
+ violence: 0,
5586
+ error: true,
5587
+ errorMessage: error instanceof Error ? error.message : String(error)
5588
+ };
5589
+ }
5590
+ }
5591
+ async function requestGoogleVisionModeration(images, maxConcurrent = 5, submissionMode = "url", downloadOptions, credentials) {
5592
+ "use step";
5593
+ const imageUrls = images.map((img) => img.url);
5594
+ const timeByUrl = new Map(images.map((img) => [img.url, img.time]));
5595
+ const targets = submissionMode === "base64" ? (await downloadImagesAsBase64(imageUrls, downloadOptions, maxConcurrent)).map((img) => ({
5596
+ url: img.url,
5597
+ time: timeByUrl.get(img.url),
5598
+ // Vision REST wants raw base64; downloadImagesAsBase64 returns a data URI.
5599
+ image: img.base64Data.startsWith("data:") ? img.base64Data.split(",")[1] : img.base64Data,
5600
+ isBase64: true,
5601
+ credentials
5602
+ })) : images.map((img) => ({
5603
+ url: img.url,
5604
+ time: img.time,
5605
+ image: img.url,
5606
+ isBase64: false,
5607
+ credentials
5608
+ }));
5609
+ return processConcurrently(targets, moderateImageWithGoogleVision, maxConcurrent);
5610
+ }
5373
5611
  async function getThumbnailUrlsFromTimestamps(playbackId, timestampsMs, options) {
5374
5612
  "use step";
5375
5613
  const { width, shouldSign, credentials } = options;
@@ -5433,8 +5671,11 @@ async function getModerationScores(assetId, options = {}) {
5433
5671
  );
5434
5672
  } else if (provider === "hive") {
5435
5673
  throw new Error("Hive does not support transcript moderation in this workflow. Use provider: 'openai' for audio-only assets.");
5674
+ } else if (provider === "google-vision-api") {
5675
+ throw new Error("google-vision-api is image-only and does not support transcript moderation. Use provider: 'openai' for audio-only assets.");
5436
5676
  } else {
5437
- throw new Error(`Unsupported moderation provider: ${provider}`);
5677
+ const exhaustiveCheck = provider;
5678
+ throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
5438
5679
  }
5439
5680
  } else {
5440
5681
  const estimatedIntervalCount = duration <= 50 ? 5 : Math.ceil(duration / thumbnailInterval);
@@ -5477,8 +5718,17 @@ async function getModerationScores(assetId, options = {}) {
5477
5718
  imageDownloadOptions,
5478
5719
  credentials
5479
5720
  );
5721
+ } else if (provider === "google-vision-api") {
5722
+ thumbnailScores = await requestGoogleVisionModeration(
5723
+ thumbnailUrls,
5724
+ maxConcurrent,
5725
+ imageSubmissionMode,
5726
+ imageDownloadOptions,
5727
+ credentials
5728
+ );
5480
5729
  } else {
5481
- throw new Error(`Unsupported moderation provider: ${provider}`);
5730
+ const exhaustiveCheck = provider;
5731
+ throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
5482
5732
  }
5483
5733
  }
5484
5734
  const failed = thumbnailScores.filter((s) => s.error);