@braze/sanity-plugin-ai-translation 0.2.31 → 0.2.33

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.d.ts CHANGED
@@ -233,7 +233,15 @@ export declare interface PluginConfig {
233
233
  maxReferenceDepth?: number;
234
234
  openai?: {
235
235
  apiKey: string;
236
- model?: "gpt-5.1" | "gpt-5" | "gpt-4o" | "gpt-4-turbo" | "gpt-4";
236
+ model?:
237
+ | "gpt-5.1"
238
+ | "gpt-5"
239
+ | "gpt-5-mini"
240
+ | "gpt-5-nano"
241
+ | "gpt-5-pro"
242
+ | "gpt-4o"
243
+ | "gpt-4-turbo"
244
+ | "gpt-4";
237
245
  reasoningEffort?: "low" | "medium" | "high";
238
246
  enabled?: boolean;
239
247
  defaultMode?: TranslationMode;
package/dist/index.js CHANGED
@@ -1605,7 +1605,109 @@ const fetchTranslationPrompts = async ({
1605
1605
  payload
1606
1606
  });
1607
1607
  }
1608
- }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1608
+ }, DEFAULT_OPTIONS = {
1609
+ maxRetries: 3,
1610
+ initialDelayMs: 1e3,
1611
+ maxDelayMs: 3e4,
1612
+ backoffMultiplier: 2,
1613
+ jitter: !0
1614
+ };
1615
+ function isRetryableError(error) {
1616
+ if (error instanceof Error) {
1617
+ const message = error.message.toLowerCase(), name = error.name.toLowerCase(), nonRetryablePatterns = [
1618
+ "schema not found",
1619
+ "invalid schema",
1620
+ "not found",
1621
+ "unauthorized",
1622
+ "forbidden",
1623
+ "invalid api key",
1624
+ "missing api key",
1625
+ "validation failed",
1626
+ "invalid document",
1627
+ "source language not found",
1628
+ "invalid configuration",
1629
+ "type mismatch"
1630
+ ];
1631
+ for (const pattern of nonRetryablePatterns)
1632
+ if (message.includes(pattern) || name.includes(pattern))
1633
+ return !1;
1634
+ const retryablePatterns = [
1635
+ "failed to fetch",
1636
+ "network",
1637
+ "timeout",
1638
+ "econnreset",
1639
+ "econnrefused",
1640
+ "enotfound",
1641
+ "socket",
1642
+ "rate limit",
1643
+ "too many requests",
1644
+ "429",
1645
+ "500",
1646
+ "502",
1647
+ "503",
1648
+ "504",
1649
+ "internal server error",
1650
+ "bad gateway",
1651
+ "service unavailable",
1652
+ "gateway timeout",
1653
+ "temporarily unavailable",
1654
+ "try again"
1655
+ ];
1656
+ for (const pattern of retryablePatterns)
1657
+ if (message.includes(pattern) || name.includes(pattern))
1658
+ return !0;
1659
+ }
1660
+ if (typeof error == "object" && error !== null) {
1661
+ const statusCode = error.statusCode || error.status;
1662
+ if (statusCode) {
1663
+ if (statusCode === 429 || statusCode >= 500 && statusCode < 600)
1664
+ return !0;
1665
+ if (statusCode >= 400 && statusCode < 500)
1666
+ return !1;
1667
+ }
1668
+ }
1669
+ return !0;
1670
+ }
1671
+ function calculateBackoffDelay(attempt, options) {
1672
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = options;
1673
+ let delay = initialDelayMs * backoffMultiplier ** attempt;
1674
+ if (delay = Math.min(delay, maxDelayMs), jitter) {
1675
+ const jitterFactor = 0.75 + Math.random() * 0.5;
1676
+ delay = Math.round(delay * jitterFactor);
1677
+ }
1678
+ return delay;
1679
+ }
1680
+ function sleep(ms) {
1681
+ return new Promise((resolve) => setTimeout(resolve, ms));
1682
+ }
1683
+ async function withRetry(fn, options = {}) {
1684
+ const opts = { ...DEFAULT_OPTIONS, ...options }, shouldRetry = opts.isRetryable ?? isRetryableError;
1685
+ let lastError, attempts = 0;
1686
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
1687
+ attempts = attempt + 1;
1688
+ try {
1689
+ return { success: !0, result: await fn(), attempts };
1690
+ } catch (error) {
1691
+ if (lastError = error, attempt >= opts.maxRetries) {
1692
+ devLog.debug("Max retries exhausted", { attempt: attempts, error });
1693
+ break;
1694
+ }
1695
+ if (!shouldRetry(error)) {
1696
+ devLog.debug("Error is not retryable", { attempt: attempts, error });
1697
+ break;
1698
+ }
1699
+ const delay = calculateBackoffDelay(attempt, opts);
1700
+ devLog.debug("Scheduling retry", {
1701
+ attempt: attempts,
1702
+ nextAttempt: attempt + 2,
1703
+ delayMs: delay,
1704
+ error: error instanceof Error ? error.message : String(error)
1705
+ }), opts.onRetry && opts.onRetry(attempt + 1, error, delay), await sleep(delay);
1706
+ }
1707
+ }
1708
+ return { success: !1, error: lastError, attempts };
1709
+ }
1710
+ const getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1609
1711
  (translation) => translation._key === targetLanguage
1610
1712
  )?.value._ref || null, getTranslationMetadata = async ({
1611
1713
  client,
@@ -1859,9 +1961,10 @@ class TransformRegistry {
1859
1961
  }), matches[0];
1860
1962
  }
1861
1963
  }
1862
- const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKey, request) => (request.model || "gpt-5") === "gpt-5" ? await callGPT5ResponsesAPI(apiKey, request) : await callChatCompletionsAPI(apiKey, request), callGPT5ResponsesAPI = async (apiKey, request) => {
1964
+ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKey, request) => (request.model || "gpt-5").startsWith("gpt-5") ? await callGPT5ResponsesAPI(apiKey, request) : await callChatCompletionsAPI(apiKey, request), callGPT5ResponsesAPI = async (apiKey, request) => {
1863
1965
  const model = request.model || "gpt-5", requestBody = {
1864
- model: "gpt-5.1",
1966
+ model: model === "gpt-5" ? "gpt-5.1" : model,
1967
+ // Map gpt-5 to gpt-5.1, use others as-is
1865
1968
  input: request.messages
1866
1969
  // Uses 'input' instead of 'messages'
1867
1970
  };
@@ -1951,9 +2054,29 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1951
2054
  }, calculateCost = (tokens, model) => {
1952
2055
  const pricing = {
1953
2056
  "gpt-5": {
1954
- input: 2e-6,
1955
- output: 1e-5,
1956
- reasoning: 5e-6
2057
+ input: 125e-5,
2058
+ output: 0.01,
2059
+ reasoning: 0
2060
+ },
2061
+ "gpt-5.1": {
2062
+ input: 125e-5,
2063
+ output: 0.01,
2064
+ reasoning: 0
2065
+ },
2066
+ "gpt-5-mini": {
2067
+ input: 25e-5,
2068
+ output: 2e-3,
2069
+ reasoning: 0
2070
+ },
2071
+ "gpt-5-nano": {
2072
+ input: 5e-5,
2073
+ output: 4e-4,
2074
+ reasoning: 0
2075
+ },
2076
+ "gpt-5-pro": {
2077
+ input: 0.015,
2078
+ output: 0.12,
2079
+ reasoning: 0
1957
2080
  },
1958
2081
  "gpt-4o": {
1959
2082
  input: 5e-6,
@@ -1987,7 +2110,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1987
2110
  type: "field",
1988
2111
  name: fieldName,
1989
2112
  text: unescapeXml(text2),
1990
- attributes: {},
2113
+ attributes: { type: "string" },
1991
2114
  children: []
1992
2115
  };
1993
2116
  if (stack.length > 0) {
@@ -2060,7 +2183,13 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2060
2183
  parent && parent.children.push(node);
2061
2184
  } else
2062
2185
  root.push(node);
2063
- } else
2186
+ } else if (attributes.type === "string" && content && content.includes(closingTag))
2187
+ if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
2188
+ const parent = stack[stack.length - 1];
2189
+ parent && parent.children.push(node);
2190
+ } else
2191
+ root.push(node);
2192
+ else
2064
2193
  stack.push(node);
2065
2194
  continue;
2066
2195
  }
@@ -2192,7 +2321,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2192
2321
  return;
2193
2322
  }
2194
2323
  if (typeof value == "string") {
2195
- value.length > 0 && lines.push(`${fieldName}: ${escapeXml(value)}`);
2324
+ value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}" type="string">${escapeXml(value)}</field>`);
2196
2325
  return;
2197
2326
  }
2198
2327
  (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
@@ -2229,7 +2358,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2229
2358
  return;
2230
2359
  }
2231
2360
  if (typeof value == "string" && value.length > 0) {
2232
- lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
2361
+ lines.push(`${indentStr}<field name="${fieldName}" type="string">${escapeXml(value)}</field>`);
2233
2362
  return;
2234
2363
  }
2235
2364
  (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
@@ -2371,7 +2500,19 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2371
2500
  `Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
2372
2501
  );
2373
2502
  }
2374
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
2503
+ const translationValidation = validateTranslationCompleteness(sourceDocument, result, languageFieldName);
2504
+ return translationValidation.valid || (devLog.warn("Translation completeness warning", {
2505
+ ...logContext,
2506
+ skippedFields: translationValidation.skippedFields,
2507
+ warning: "GPT-5 may have skipped some important fields"
2508
+ }), devLog.info("Skipped field details", {
2509
+ ...logContext,
2510
+ skippedFields: translationValidation.skippedFields.map((field) => ({
2511
+ path: field.path,
2512
+ originalValue: field.originalValue,
2513
+ translatedValue: field.translatedValue
2514
+ }))
2515
+ })), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
2375
2516
  tokensUsed: response.tokensUsed.total,
2376
2517
  estimatedCost: response.cost
2377
2518
  }), result;
@@ -2412,12 +2553,19 @@ ${sourceText}
2412
2553
 
2413
2554
  ## Instructions
2414
2555
 
2415
- 1. Translate all text content into ${targetLanguage}
2416
- 2. Follow the guidelines and terminology requirements above
2417
- 3. Preserve protected phrases exactly as they appear
2418
- 4. Return the document in THE EXACT SAME FORMAT
2419
- 5. **CRITICAL**: Only modify text content between tags
2420
- 6. Never add, remove, or change any tags, attributes, or structure
2556
+ 1. **TRANSLATE ALL TEXT FIELDS**: Every field marked with type="string" or containing text must be translated
2557
+ 2. **DO NOT SKIP SIMPLE FIELDS**: Pay special attention to simple fields like "title", "name", "description" - these are often the most important
2558
+ 3. Follow the guidelines and terminology requirements above
2559
+ 4. Preserve protected phrases exactly as they appear
2560
+ 5. Return the document in THE EXACT SAME FORMAT
2561
+ 6. **CRITICAL**: Only modify text content between tags
2562
+ 7. Never add, remove, or change any tags, attributes, or structure
2563
+
2564
+ **Common fields that must always be translated:**
2565
+ - title, name, heading, label
2566
+ - description, caption, alt text
2567
+ - All text within <field> tags
2568
+ - All text within <span> tags
2421
2569
 
2422
2570
  If you used significant reasoning to make translation decisions, you may include a brief summary:
2423
2571
  <reasoning>
@@ -2425,6 +2573,43 @@ Brief summary of key translation decisions and reasoning
2425
2573
  </reasoning>
2426
2574
 
2427
2575
  Then return the translated document:`;
2576
+ }, validateTranslationCompleteness = (sourceDoc, translatedDoc, languageFieldName) => {
2577
+ const skippedFields = [], checkField = (source, translated, path = []) => {
2578
+ if (path.length > 0) {
2579
+ const fieldName = path[path.length - 1];
2580
+ if (fieldName.startsWith("_") || fieldName === languageFieldName)
2581
+ return;
2582
+ }
2583
+ if (typeof source == "string" && typeof translated == "string") {
2584
+ if (source.length > 0 && source === translated) {
2585
+ const fieldName = path[path.length - 1] || "unknown";
2586
+ (["title", "name", "heading", "label", "description", "caption", "alt"].includes(
2587
+ fieldName
2588
+ ) || source.length > 10) && skippedFields.push({
2589
+ path: path.join("."),
2590
+ originalValue: source,
2591
+ translatedValue: translated
2592
+ });
2593
+ }
2594
+ return;
2595
+ }
2596
+ if (source && translated && typeof source == "object" && typeof translated == "object") {
2597
+ if (Array.isArray(source) && Array.isArray(translated)) {
2598
+ const maxLength = Math.min(source.length, translated.length);
2599
+ for (let i = 0; i < maxLength; i++)
2600
+ checkField(source[i], translated[i], [...path, String(i)]);
2601
+ } else if (!Array.isArray(source) && !Array.isArray(translated))
2602
+ for (const key of Object.keys(source))
2603
+ !key.startsWith("_") && key !== languageFieldName && checkField(source[key], translated[key], [
2604
+ ...path,
2605
+ key
2606
+ ]);
2607
+ }
2608
+ };
2609
+ return checkField(sourceDoc, translatedDoc), {
2610
+ valid: skippedFields.length === 0,
2611
+ skippedFields
2612
+ };
2428
2613
  }, assembleLanguageSpecs = ({
2429
2614
  sourceDocument,
2430
2615
  languageFieldName,
@@ -2643,23 +2828,50 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2643
2828
  });
2644
2829
  return;
2645
2830
  }
2646
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2647
- dispatch,
2648
- client,
2649
- schemaId,
2650
- sourceDocument,
2651
- targetLanguage,
2652
- existingTranslationId,
2653
- languageFieldName,
2654
- translationPrompts,
2655
- translationMode,
2656
- openaiConfig,
2657
- releaseId
2658
- });
2659
- if (!newDocument) {
2660
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2831
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await withRetry(
2832
+ () => handleTranslation({
2833
+ dispatch,
2834
+ client,
2835
+ schemaId,
2836
+ sourceDocument,
2837
+ targetLanguage,
2838
+ existingTranslationId,
2839
+ languageFieldName,
2840
+ translationPrompts,
2841
+ translationMode,
2842
+ openaiConfig,
2843
+ releaseId
2844
+ }),
2845
+ {
2846
+ maxRetries: 3,
2847
+ initialDelayMs: 1e3,
2848
+ isRetryable: isRetryableError,
2849
+ onRetry: (attempt, error, delayMs) => {
2850
+ devLog.warn("Translation failed, scheduling retry", {
2851
+ ...logContext,
2852
+ attempt,
2853
+ delayMs,
2854
+ error: error instanceof Error ? error.message : String(error)
2855
+ }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "retrying", {
2856
+ retryCount: attempt,
2857
+ errorMessage: error instanceof Error ? error.message : String(error)
2858
+ });
2859
+ }
2860
+ }
2861
+ );
2862
+ if (!translationResult.success || !translationResult.result) {
2863
+ const errorMessage = translationResult.error instanceof Error ? translationResult.error.message : String(translationResult.error || "Translation failed");
2864
+ devLog.error("Translation failed after retries", translationResult.error, {
2865
+ ...logContext,
2866
+ attempts: translationResult.attempts
2867
+ }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
2868
+ errorMessage,
2869
+ retryCount: translationResult.attempts - 1
2870
+ // -1 because attempts includes initial try
2871
+ }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2661
2872
  return;
2662
2873
  }
2874
+ const newDocument = translationResult.result;
2663
2875
  return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "updating_metadata", {
2664
2876
  targetId: newDocument._id
2665
2877
  }), await updateTranslationMetadata({
@@ -3239,12 +3451,17 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3239
3451
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3240
3452
  "Error: ",
3241
3453
  job.errorMessage
3454
+ ] }) }) : null,
3455
+ job.status === "retrying" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3456
+ "Retrying after: ",
3457
+ job.errorMessage
3242
3458
  ] }) }) : null
3243
3459
  ] })
3244
3460
  ] }),
3245
3461
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3246
3462
  job.status === "skipped" ? /* @__PURE__ */ jsx(SkippedBadge, {}) : null,
3247
- job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, {}) : null,
3463
+ job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, { retryCount: job.retryCount }) : null,
3464
+ job.status === "retrying" ? /* @__PURE__ */ jsx(RetryingBadge, { retryCount: job.retryCount }) : null,
3248
3465
  job.targetId ? /* @__PURE__ */ jsx(
3249
3466
  TranslationOpenerButton,
3250
3467
  {
@@ -3324,12 +3541,25 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3324
3541
  content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "There was already a fresh translation for this document and language." }) }),
3325
3542
  children: /* @__PURE__ */ jsx(Badge, { tone: "neutral", children: "Skipped" })
3326
3543
  }
3327
- ), ErrorBadge = () => /* @__PURE__ */ jsx(
3544
+ ), ErrorBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3545
+ Tooltip,
3546
+ {
3547
+ portal: !0,
3548
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: retryCount && retryCount > 0 ? `This translation failed after ${retryCount} retry attempt${retryCount > 1 ? "s" : ""}.` : "This translation failed. Check the error message for details." }) }),
3549
+ children: /* @__PURE__ */ jsxs(Badge, { tone: "critical", children: [
3550
+ "Failed",
3551
+ retryCount && retryCount > 0 ? ` (${retryCount} retries)` : ""
3552
+ ] })
3553
+ }
3554
+ ), RetryingBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3328
3555
  Tooltip,
3329
3556
  {
3330
3557
  portal: !0,
3331
- content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
3332
- children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
3558
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Retrying translation after a temporary failure." }) }),
3559
+ children: /* @__PURE__ */ jsxs(Badge, { tone: "caution", children: [
3560
+ "Retry ",
3561
+ retryCount || 1
3562
+ ] })
3333
3563
  }
3334
3564
  ), getStatusLabel = (status) => ({
3335
3565
  idle: "Idle",
@@ -3339,6 +3569,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3339
3569
  translating_with_openai: "Translating with GPT-5...",
3340
3570
  reviewing_with_openai: "Reviewing translation with GPT-5...",
3341
3571
  updating_metadata: "Saving metadata...",
3572
+ retrying: "Retrying...",
3342
3573
  error: "Error",
3343
3574
  skipped: "Skipped",
3344
3575
  completed: "Completed"