@purposeinplay/payload-ai-translate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
package/README.md ADDED
@@ -0,0 +1,714 @@
1
+ # AI Translate Plugin
2
+
3
+ Automatic LLM translation of localized Payload CMS 3.x content. Translation can be triggered on save, on publish, manually via the admin UI, or programmatically via the Node.js API.
4
+
5
+ This is the reference doc — every config knob, type, endpoint, and provider. New to the plugin? Start with [INTEGRATION.md](./INTEGRATION.md). Want to understand how it works under the hood? See [ARCHITECTURE.md](./ARCHITECTURE.md). Looking for copy-pasteable solutions? See [USAGE.md](./USAGE.md).
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pnpm add @purposeinplay/payload-ai-translate
13
+ ```
14
+
15
+ The `providers` entrypoint hard-imports all four AI SDK adapters at module load. Install all of them as peer deps:
16
+
17
+ ```bash
18
+ pnpm add @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai-compatible ai
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ // payload.config.ts
27
+ import { buildConfig } from 'payload'
28
+ import { aiTranslatePlugin } from '@purposeinplay/payload-ai-translate'
29
+ import { createAnthropicProvider } from '@purposeinplay/payload-ai-translate/providers'
30
+
31
+ export default buildConfig({
32
+ localization: {
33
+ locales: ['en', 'de', 'es', 'fr'],
34
+ defaultLocale: 'en',
35
+ fallback: true,
36
+ },
37
+ plugins: [
38
+ aiTranslatePlugin({
39
+ collections: ['posts'],
40
+ sourceLocale: 'en',
41
+ targetLocales: ['de', 'es', 'fr'],
42
+ provider: createAnthropicProvider({
43
+ apiKey: process.env.ANTHROPIC_API_KEY!,
44
+ }),
45
+ costLimits: {
46
+ perCallCharLimit: 10_000,
47
+ perDocCharCeiling: 50_000,
48
+ bulkConfirmUsdThreshold: 1.0,
49
+ },
50
+ }),
51
+ ],
52
+ })
53
+ ```
54
+
55
+ This wires:
56
+ - A "Translate…" button in the sidebar of every `posts` document
57
+ - `POST /api/posts/ai-translate` and `POST /api/posts/ai-translate/estimate` endpoints
58
+ - A live progress stream at `GET /api/posts/ai-translate/progress`
59
+
60
+ For automation, audit-log integration, and cost tracking, see [INTEGRATION.md](./INTEGRATION.md).
61
+
62
+ ---
63
+
64
+ ## Configuration reference
65
+
66
+ ### `AITranslatePluginConfig`
67
+
68
+ | Field | Type | Required | Default | Description |
69
+ |---|---|---|---|---|
70
+ | `collections` | `string[]` | No | `[]` | Collection slugs to enable translation on. |
71
+ | `globals` | `string[]` | No | `[]` | Global slugs to enable translation on. |
72
+ | `sourceLocale` | `string` | Yes | — | The locale to translate from. Must match a locale in `localization.locales`. |
73
+ | `targetLocales` | `string[]` | Yes | — | Locales to translate into. `sourceLocale` is silently filtered if it appears here. |
74
+ | `provider` | `TranslationProvider` | Yes | — | Default provider. See [Providers](#providers). |
75
+ | `providers` | `Record<string, TranslationProvider>` | No | — | Optional named providers for runtime switching. See [Runtime provider switching](#runtime-provider-switching). |
76
+ | `costLimits` | `CostLimits` | Yes | — | Hard limits that guard against runaway spend. |
77
+ | `excludeFields` | `string[]` | No | `[]` | Field paths or globs to skip. E.g. `['slug', 'meta.*', '**.internalNote']`. |
78
+ | `excludeUrlFields` | `boolean` | No | `true` | When `true`, appends `**.url` to `excludeFields` so URL strings never reach the LLM. |
79
+ | `concurrency` | `Partial<ConcurrencyLimits>` | No | see below | Parallelism controls. |
80
+ | `retry` | `Partial<RetryConfig>` | No | see below | Retry behavior on provider errors. |
81
+ | `access` | `{ translate?: AccessFn }` | No | always allowed | Guard the translate endpoints. |
82
+ | `onEvent` | `(event: TranslationEvent) => void \| Promise<void>` | No | — | Receive translation lifecycle events. |
83
+ | `onAlert` | `(alert: TranslationAlert) => void \| Promise<void>` | No | — | Receive operational alerts. |
84
+ | `lexicalNodes` | `LexicalNodeRegistration[]` | No | `[]` | Register custom Lexical node types for extraction. |
85
+ | `enabled` | `boolean` | No | `true` | Disable the plugin without removing it from config. |
86
+ | `automation` | `AutomationConfig` | No | — | Hook-based automatic translation. See [Automation](#automation). |
87
+ | `perFieldButton` | `boolean` | No | `false` | Inject a translate button next to each localized field. |
88
+ | `quality` | `QualityConfig` | No | — | Sampling, canary, and output validation. |
89
+ | `usageTracking` | `UsageTrackingConfig` | No | `{ enabled: false }` | Persist a row per translation job. See [Usage tracking](#usage-tracking). |
90
+ | `settings` | `TranslationSettingsConfig` | No | — | Override defaults for the auto-registered `translation-settings` global (only meaningful with `providers`). |
91
+
92
+ ### `CostLimits`
93
+
94
+ All three fields are required.
95
+
96
+ | Field | Type | Description |
97
+ |---|---|---|
98
+ | `perCallCharLimit` | `number` | Maximum characters in a single provider call. Requests over this throw `CostGuardError` with code `'PER_CALL_LIMIT'`. |
99
+ | `perDocCharCeiling` | `number` | Maximum total characters extracted from one document. Document is aborted with a `'translation.cost-guard-abort'` alert. |
100
+ | `bulkConfirmUsdThreshold` | `number` | USD threshold above which the admin UI requires explicit confirmation before starting bulk translation. |
101
+
102
+ ### `ConcurrencyLimits`
103
+
104
+ | Field | Type | Default | Description |
105
+ |---|---|---|---|
106
+ | `perDocument` | `number` | `3` | Maximum target locales translated in parallel for a single doc. |
107
+ | `perProvider` | `number` | `5` | Requests-per-minute cap (token-bucket) across all in-flight translations. |
108
+
109
+ ### `RetryConfig`
110
+
111
+ | Field | Type | Default | Description |
112
+ |---|---|---|---|
113
+ | `attempts` | `number` | `2` | Retry attempts after the initial failure. `2` = up to 3 total attempts. |
114
+ | `backoffMs` | `number` | `1000` | Base delay in ms. Doubles per attempt. |
115
+
116
+ ### `AutomationConfig`
117
+
118
+ | Field | Type | Default | Description |
119
+ |---|---|---|---|
120
+ | `trigger` | `'on-change' \| 'on-publish'` | `'on-change'` | When to fire automatic translation. Use `'on-publish'` for collections with drafts. |
121
+ | `mode` | `'async' \| 'inline'` | `'async'` | `async` queues with coalescing; `inline` fire-and-forgets. Save returns immediately in both. |
122
+ | `targetPolicy` | `TargetPolicy` | `'mirror'` | `'mirror'` overwrites existing translations; `'preserve'` skips fields with non-empty target content. |
123
+ | `diffOnly` | `boolean` | `true` | Only retranslate fields whose content hash changed since the previous version. |
124
+ | `coalescingWindowMs` | `number` | `12000` | `async` mode only — saves within this window collapse into one job. |
125
+
126
+ ### `QualityConfig`
127
+
128
+ | Field | Type | Description |
129
+ |---|---|---|
130
+ | `validation` | `ValidationConfig` | Tune length bounds and refusal/injection patterns. |
131
+ | `sampling` | `SamplingConfig` | Sample a percentage of translations for review. |
132
+ | `canaryLocale` | `string` | Translate this locale on every job but don't write the result. Emitted via `onEvent` with `canary: true`. |
133
+
134
+ ### `ValidationConfig`
135
+
136
+ | Field | Type | Default | Description |
137
+ |---|---|---|---|
138
+ | `minLengthRatio` | `number` | `0.3` | Minimum output/input length ratio. CJK locales (ja/ko/zh) typically need `0.15`. |
139
+ | `maxLengthRatio` | `number` | `3.0` | Maximum output/input length ratio. |
140
+ | `minSourceLength` | `number` | `10` | Source strings shorter than this bypass length validation. |
141
+ | `extraRefusalPatterns` | `RegExp[]` | `[]` | Additional patterns to detect LLM refusals in output. |
142
+ | `extraInjectionPatterns` | `RegExp[]` | `[]` | Additional patterns to detect prompt injection in output. |
143
+
144
+ ### `SamplingConfig`
145
+
146
+ | Field | Type | Description |
147
+ |---|---|---|
148
+ | `rate` | `number` | `0`–`1`. `0.1` samples 10% of translations. |
149
+ | `onSample` | `(sample: TranslationSample) => void \| Promise<void>` | Callback per sampled translation. |
150
+
151
+ ### `UsageTrackingConfig`
152
+
153
+ | Field | Type | Default | Description |
154
+ |---|---|---|---|
155
+ | `enabled` | `boolean` | `false` | Master switch. When `true`, the plugin auto-registers a collection that persists one row per translation job. |
156
+ | `collectionSlug` | `string` | `'translation-usage'` | Override the auto-registered collection slug. |
157
+ | `access` | `{ read?: Access }` | admin-only | Override the default admin-gated read access. |
158
+
159
+ When enabled, run `pnpm payload migrate:create` to generate the schema migration. Commit and deploy. See the [Auto-registered surfaces](#auto-registered-surfaces) section for the full schema.
160
+
161
+ ---
162
+
163
+ ## Automation
164
+
165
+ Automation wires translation into Payload's `afterChange` hook so content is translated on every qualifying save without manual action.
166
+
167
+ ### Triggers
168
+
169
+ - **`on-change`** — fires after every save. If the collection has drafts, every autosave fires translation. The plugin emits a startup warning when this combination is detected.
170
+ - **`on-publish`** — fires only when `_status` transitions to `'published'`. Requires `versions.drafts: true`.
171
+
172
+ ### Modes
173
+
174
+ | | `async` (default) | `inline` |
175
+ |---|---|---|
176
+ | Save response time | Not blocked | Not blocked |
177
+ | Coalescing | Yes (12s window default) | No |
178
+ | Use case | High-frequency edits, autosaves | One-translation-per-publish, low autosave noise |
179
+ | Error visibility | `onEvent` / `onAlert` | `onEvent` / `onAlert` (errors during inline never reach the save response — fire-and-forget) |
180
+
181
+ ### `diffOnly`
182
+
183
+ When `true` (default), the hook passes `previousDoc` to `translateDocument`. Fields whose content hash matches the previous version are not retranslated. The diff is per field path, including paths inside arrays/blocks, so a typo fix in one block doesn't retranslate an entire page.
184
+
185
+ The first publish of a new doc has identical `previousDoc`/`doc` (autosave already mirrored) — the plugin detects this and force-translates everything. You don't need to handle the empty-diff edge case.
186
+
187
+ ### Target policy
188
+
189
+ - **`'mirror'`** (default) — overwrite whatever's in the target locale. Source publishes are authoritative.
190
+ - **`'preserve'`** — skip fields whose target value is non-empty. Useful when editors hand-edit translations and you want manual edits left alone.
191
+
192
+ ---
193
+
194
+ ## Programmatic API
195
+
196
+ All exports are from `@purposeinplay/payload-ai-translate`.
197
+
198
+ ### `translateDocument(payload, options)`
199
+
200
+ Reads the source document, extracts translatable content, fans out to the LLM per locale, and writes results back.
201
+
202
+ ```ts
203
+ import { translateDocument } from '@purposeinplay/payload-ai-translate'
204
+
205
+ const result = await translateDocument(payload, {
206
+ collection: 'posts',
207
+ id: 'abc123',
208
+ })
209
+ ```
210
+
211
+ **`TranslateDocumentOptions`**
212
+
213
+ | Field | Type | Required | Description |
214
+ |---|---|---|---|
215
+ | `collection` | `string` | Yes | Collection slug. |
216
+ | `id` | `string \| number` | Yes | Document ID. |
217
+ | `targetLocales` | `string[]` | No | Override plugin config. |
218
+ | `targetPolicy` | `TargetPolicy` | No | Override `automation.targetPolicy` or default `'mirror'`. |
219
+ | `fields` | `string[]` | No | Restrict translation to specific paths. |
220
+ | `previousDoc` | `Record<string, unknown>` | No | When provided, only fields that differ are translated. |
221
+ | `signal` | `AbortSignal` | No | Abort in-flight translation. |
222
+ | `writeMode` | `'full' \| 'minimal'` | No (`'full'`) | `'full'` writes all top-level localized fields together (passes Payload's required-field validation on a fresh target row); `'minimal'` writes only translated fields (caller accepts validation risk). |
223
+ | `jobId` | `string` | No | Reuse an existing progress-store job (used internally by hooks). |
224
+ | `req` | `PayloadRequest` | No | Originating request — used for audit-log attribution. The plugin always uses a fresh transactionID per locale write regardless. |
225
+ | `draft` | `boolean` | No (`true`) | Read latest draft when versioning is enabled. |
226
+
227
+ **`TranslateDocumentResult`**
228
+
229
+ ```ts
230
+ {
231
+ jobId?: string
232
+ documentId: string | number
233
+ collection: string
234
+ sourceLocale: string
235
+ succeeded: FieldLocaleResult[]
236
+ failed: FieldLocaleResult[]
237
+ usage: TranslationUsage
238
+ }
239
+ ```
240
+
241
+ `FieldLocaleResult.status`: `'success' | 'failed' | 'skipped'`. **`'skipped'`** means verbatim-echo (the LLM returned the source unchanged — typical for brand names, code identifiers, URLs); the target field is left untouched.
242
+
243
+ Throws when:
244
+ - Plugin not configured
245
+ - Document not found
246
+ - Collection not in Payload config
247
+ - `perDocCharCeiling` exceeded
248
+
249
+ ### `translateGlobal(payload, options)`
250
+
251
+ Globals counterpart of `translateDocument`. Same shape minus `id`.
252
+
253
+ ```ts
254
+ import { translateGlobal } from '@purposeinplay/payload-ai-translate'
255
+
256
+ await translateGlobal(payload, {
257
+ global: 'site-config',
258
+ targetLocales: ['de', 'fr'],
259
+ })
260
+ ```
261
+
262
+ **`TranslateGlobalOptions`**
263
+
264
+ | Field | Type | Required | Description |
265
+ |---|---|---|---|
266
+ | `global` | `string` | Yes | Global slug. |
267
+ | `targetLocales` | `string[]` | No | Override plugin config. |
268
+ | `targetPolicy` | `TargetPolicy` | No | Override default `'mirror'`. |
269
+ | `fields` | `string[]` | No | Restrict to paths. |
270
+ | `previousDoc` | `Record<string, unknown>` | No | Diff-only equivalent for globals. |
271
+ | `signal` | `AbortSignal` | No | Abort. |
272
+ | `writeMode` | `'full' \| 'minimal'` | No (`'full'`) | See `translateDocument`. |
273
+ | `jobId` | `string` | No | Reuse a progress-store job. |
274
+ | `req` | `PayloadRequest` | No | For audit-log attribution. |
275
+
276
+ ### Exported utilities
277
+
278
+ ```ts
279
+ import { isFieldEmpty, diffFields } from '@purposeinplay/payload-ai-translate'
280
+
281
+ isFieldEmpty('', 'text') // true
282
+ isFieldEmpty('Hi', 'text') // false
283
+ isFieldEmpty([], 'array') // true
284
+ isFieldEmpty(null, 'richText') // true
285
+
286
+ diffFields(prevDoc, currDoc, ['title', 'body']) // ['title'] (paths that changed)
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Providers
292
+
293
+ All built-in providers live at `@purposeinplay/payload-ai-translate/providers`. They share an internal AI-SDK-based adapter, so option shapes are uniform: `apiKey` (required), `model` (with provider-specific default), `temperature`, `maxTokens`, `baseURL`.
294
+
295
+ ### Anthropic
296
+
297
+ ```ts
298
+ import { createAnthropicProvider } from '@purposeinplay/payload-ai-translate/providers'
299
+
300
+ createAnthropicProvider({
301
+ apiKey: process.env.ANTHROPIC_API_KEY!,
302
+ model: 'claude-sonnet-4-6', // default
303
+ temperature: 0.3, // default
304
+ maxTokens: 4096, // default
305
+ })
306
+ ```
307
+
308
+ Cost estimates available for `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5`.
309
+
310
+ ### OpenAI
311
+
312
+ ```ts
313
+ import { createOpenAIProvider } from '@purposeinplay/payload-ai-translate/providers'
314
+
315
+ createOpenAIProvider({
316
+ apiKey: process.env.OPENAI_API_KEY!,
317
+ model: 'gpt-4o', // default
318
+ temperature: 0.3,
319
+ baseURL: undefined, // override for Azure OpenAI
320
+ })
321
+ ```
322
+
323
+ Cost estimates available for `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`.
324
+
325
+ ### Google Gemini
326
+
327
+ ```ts
328
+ import { createGeminiProvider } from '@purposeinplay/payload-ai-translate/providers'
329
+
330
+ createGeminiProvider({
331
+ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY!,
332
+ model: 'gemini-2.5-flash', // default
333
+ temperature: 0.3,
334
+ })
335
+ ```
336
+
337
+ Cost estimates available for `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash`.
338
+
339
+ ### Custom (OpenAI-compatible API)
340
+
341
+ For Grok, Kimi, Together, self-hosted, or any provider that implements the OpenAI chat completions API:
342
+
343
+ ```ts
344
+ import { createCustomProvider } from '@purposeinplay/payload-ai-translate/providers'
345
+
346
+ createCustomProvider({
347
+ apiKey: process.env.XAI_API_KEY!,
348
+ baseURL: 'https://api.x.ai/v1',
349
+ model: 'grok-3-mini',
350
+ pricing: { input: 0.3 / 1_000_000, output: 0.5 / 1_000_000 }, // optional
351
+ })
352
+ ```
353
+
354
+ Without `pricing`, `estimatedCostUsd` is `undefined` (not an error) — the plugin still translates.
355
+
356
+ ### Mock (for testing/dev)
357
+
358
+ ```ts
359
+ import { createMockProvider } from '@purposeinplay/payload-ai-translate/providers'
360
+
361
+ createMockProvider()
362
+ ```
363
+
364
+ Prepends `[{locale}]` to each input string. No network calls, no costs, deterministic. Use in CI and local dev to exercise wiring without spending tokens.
365
+
366
+ ### Runtime provider switching
367
+
368
+ When you pass a `providers` map, the plugin auto-registers a `translation-settings` global so admins can switch providers without redeploying:
369
+
370
+ ```ts
371
+ aiTranslatePlugin({
372
+ // …
373
+ provider: createMockProvider(), // fallback when no admin choice exists
374
+ providers: {
375
+ 'anthropic-sonnet': createAnthropicProvider({ apiKey, model: 'claude-sonnet-4-6' }),
376
+ 'anthropic-haiku': createAnthropicProvider({ apiKey, model: 'claude-haiku-4-5' }),
377
+ 'openai-mini': createOpenAIProvider({ apiKey: oaKey, model: 'gpt-4o-mini' }),
378
+ },
379
+ })
380
+ ```
381
+
382
+ Each map key becomes an option in the admin's "Active provider" dropdown. The default `provider` is always available as a fallback.
383
+
384
+ ### Implementing your own provider
385
+
386
+ ```ts
387
+ import type { TranslationProvider } from '@purposeinplay/payload-ai-translate'
388
+
389
+ const myProvider: TranslationProvider = {
390
+ async translate(request) {
391
+ // request.items: text units to translate
392
+ // request.sourceLocale, request.targetLocale, request.context
393
+ // return: { items, usage, model, latencyMs }
394
+ },
395
+ // estimate is optional — without it, cost previews are unavailable
396
+ async estimate(request) {
397
+ return { inputTokens: 0, estimatedCostUsd: 0 }
398
+ },
399
+ }
400
+ ```
401
+
402
+ The `items` array in the response must preserve the same `id` values as the request — the plugin matches translated text back to field positions by id.
403
+
404
+ ---
405
+
406
+ ## Auto-registered surfaces
407
+
408
+ ### `translation-usage` (collection)
409
+
410
+ Registered when `usageTracking.enabled: true`.
411
+
412
+ | Field | Type | Description |
413
+ |---|---|---|
414
+ | `kind` | `'collection' \| 'global'` | What was translated. |
415
+ | `jobId` | `string` | Matches the SSE jobId. |
416
+ | `slug` | `string` | Collection or global slug. |
417
+ | `documentId` | `string \| null` | Null for globals. |
418
+ | `status` | `'succeeded' \| 'failed'` | Job-level outcome. Skipped fields don't make a job fail. |
419
+ | `sourceLocale` | `string` | |
420
+ | `succeededCount` | `number` | Per-locale successes. |
421
+ | `failedCount` | `number` | Per-locale real failures (excludes skipped). |
422
+ | `inputTokens` / `outputTokens` | `number` | Provider-reported. |
423
+ | `estimatedCostUsd` | `number \| null` | Null for providers without pricing. |
424
+ | `model` | `string` | Provider model id. |
425
+ | `durationMs` | `number` | Wall-clock total. |
426
+ | `error` | `string \| null` | Short error text on failure. |
427
+ | `targetLocales` | `string[]` | List of locales the job covered. |
428
+ | `createdAt` / `updatedAt` | `Date` | |
429
+
430
+ Reads are admin-gated by default. Override with `usageTracking.access.read`.
431
+
432
+ **Migration**: run `pnpm payload migrate:create` after first enabling. The plugin doesn't ship a migration — your schema management owns it.
433
+
434
+ ### `translation-settings` (global)
435
+
436
+ Registered when `providers` (the map) is passed. Exposes an "Active provider" select sourced from the map's keys. The admin's choice is read on every job — admin updates take effect on the next translation, not mid-job.
437
+
438
+ ---
439
+
440
+ ## REST endpoints
441
+
442
+ All endpoints are auto-registered on every configured collection and global at `/api/<slug>`.
443
+
444
+ ### `POST /ai-translate`
445
+
446
+ Translates the doc (or global). Request body fields are all optional; defaults come from plugin config.
447
+
448
+ ```json
449
+ {
450
+ "id": "doc-id", // collections only — required for collections
451
+ "targetLocales": ["fr", "de"], // optional
452
+ "fields": ["title", "body"], // optional
453
+ "writeMode": "full" // optional ('full' | 'minimal')
454
+ }
455
+ ```
456
+
457
+ Response:
458
+
459
+ ```json
460
+ {
461
+ "jobId": "j_abc123",
462
+ "documentId": "doc-id",
463
+ "collection": "posts",
464
+ "sourceLocale": "en",
465
+ "succeeded": [
466
+ { "fieldPath": "title", "locale": "fr", "status": "success", "characterCount": 42, "durationMs": 310 }
467
+ ],
468
+ "failed": [],
469
+ "usage": { "inputTokens": 120, "outputTokens": 135, "estimatedCostUsd": 0.00034, "model": "claude-sonnet-4-6" }
470
+ }
471
+ ```
472
+
473
+ ### `POST /ai-translate/estimate`
474
+
475
+ Token + cost preview without making a translation call. Same body as `/ai-translate`.
476
+
477
+ ```json
478
+ {
479
+ "inputTokens": 120,
480
+ "estimatedCostUsd": 0.00034,
481
+ "billableCharacters": 480,
482
+ "skippedCharacters": 0
483
+ }
484
+ ```
485
+
486
+ `billableCharacters` / `skippedCharacters` lets you see how much will actually be translated vs. how much is excluded (URLs, exact-match exclude paths, empty fields).
487
+
488
+ ### `GET /ai-translate/progress` (SSE)
489
+
490
+ Live progress stream for a translation job. Subscribed via Server-Sent Events.
491
+
492
+ Two modes:
493
+
494
+ - `?jobId=X` — follow a specific job.
495
+ - `?docId=X` — follow any active job for this doc, or wait until one starts. The in-edit progress bar uses this mode so the bar appears instantly when a job begins.
496
+
497
+ Each event has shape:
498
+
499
+ ```json
500
+ {
501
+ "jobId": "j_abc123",
502
+ "completed": ["de", "es"],
503
+ "failed": [{ "locale": "fr", "reason": "1 field(s) failed" }],
504
+ "total": 9,
505
+ "status": "running"
506
+ }
507
+ ```
508
+
509
+ `status` is one of `'queued' | 'running' | 'succeeded' | 'failed'`.
510
+
511
+ ### Access control
512
+
513
+ Pass `access.translate` to gate the translate and estimate endpoints:
514
+
515
+ ```ts
516
+ aiTranslatePlugin({
517
+ // …
518
+ access: {
519
+ translate: ({ req }) => req.user?.role === 'editor' || req.user?.role === 'admin',
520
+ },
521
+ })
522
+ ```
523
+
524
+ The progress endpoint inherits the same gate.
525
+
526
+ ---
527
+
528
+ ## Admin UI
529
+
530
+ ### Sidebar Translate button
531
+
532
+ Auto-injected on every configured collection (and global) at the edit view. Triggers a full-doc translation against the configured `targetLocales`. The component is at `@purposeinplay/payload-ai-translate/client#TranslateButton`.
533
+
534
+ ### Per-field button
535
+
536
+ Set `perFieldButton: true` to inject a button next to each localized `text`, `textarea`, or `richText` input. Clicking it re-translates that field only. Recurses into `group`, `array`, `row`, `collapsible`, `tabs`, and `blocks`.
537
+
538
+ ### In-edit progress bar
539
+
540
+ Mounts beneath the action bar and connects to the SSE doc-stream the moment the edit view loads. When a translation job for that doc starts, the bar renders within ~50ms — no polling delay. Showed every per-locale tick as it lands.
541
+
542
+ ### Translation Settings global (when `providers` map is configured)
543
+
544
+ Admin chooses an active provider from a dropdown sourced from the `providers` map keys. Changes apply on the next translation.
545
+
546
+ ### Translation Usage collection (when `usageTracking.enabled`)
547
+
548
+ Standard Payload collection list view, admin-gated. Filterable by status, model, slug, date.
549
+
550
+ ---
551
+
552
+ ## Events and alerts
553
+
554
+ ### `onEvent`
555
+
556
+ | Type | When | Additional fields |
557
+ |---|---|---|
558
+ | `'translation.started'` | Before LLM calls | `targetLocales` |
559
+ | `'translation.field'` | After each individual field translation | `fields` (single entry) |
560
+ | `'translation.succeeded'` | All locales completed with no real failures (skipped is OK) | `fields`, `usage` |
561
+ | `'translation.failed'` | At least one locale had a real failure | `fields`, `usage`, `error` |
562
+
563
+ All events include `documentId`, `collection`, `sourceLocale`, `targetLocales`, `timestamp`. Set `canary: true` when emitted from the canary locale.
564
+
565
+ ### `onAlert`
566
+
567
+ | Type | When |
568
+ |---|---|
569
+ | `'translation.persistent-failure'` | A field has failed after all retry attempts. |
570
+ | `'translation.cost-guard-abort'` | A document exceeded `perDocCharCeiling`. |
571
+ | `'translation.provider-outage'` | Provider unreachable for multiple consecutive requests. |
572
+
573
+ ---
574
+
575
+ ## Quality and monitoring
576
+
577
+ ### Sampling
578
+
579
+ ```ts
580
+ quality: {
581
+ sampling: {
582
+ rate: 0.05,
583
+ onSample: async (sample) => {
584
+ // sample: { documentId, collection, fieldPath, sourceLocale, targetLocale,
585
+ // sourceText, translatedText, model, timestamp }
586
+ await db.translationSamples.insert(sample)
587
+ },
588
+ },
589
+ }
590
+ ```
591
+
592
+ ### Canary locale
593
+
594
+ The canary locale is translated on every job but the result is **not written back**. Instead, the result fires via `onEvent` with `canary: true`. Use it to run automated quality scoring against a synthetic locale without affecting production content.
595
+
596
+ ```ts
597
+ quality: { canaryLocale: 'en-qa' }
598
+ ```
599
+
600
+ The synthetic locale must exist in `localization.locales`.
601
+
602
+ ### Output validation
603
+
604
+ Every translation is validated before write:
605
+
606
+ - Length ratio within `[minLengthRatio, maxLengthRatio]`
607
+ - Doesn't match a known refusal pattern ("I cannot translate…")
608
+ - Doesn't match an injection pattern (attempted prompt break-out)
609
+ - Not a verbatim echo of the source (special case: marked `'skipped'` instead of failed; target field left untouched)
610
+
611
+ Failed validations produce `status: 'failed'` and the source field is left unchanged in the target.
612
+
613
+ ---
614
+
615
+ ## Cost controls
616
+
617
+ ### Character limits
618
+
619
+ | Limit | Config | Scope | Behavior |
620
+ |---|---|---|---|
621
+ | Per-call | `perCallCharLimit` | Single LLM request | `CostGuardError` with code `'PER_CALL_LIMIT'` |
622
+ | Per-document | `perDocCharCeiling` | All fields in one doc | Document aborted; `'translation.cost-guard-abort'` alert |
623
+
624
+ Both count raw characters (not tokens). The character-to-token ratio used for estimates is approximately 3.5 chars/token.
625
+
626
+ ### Rate limiting
627
+
628
+ Internal token-bucket rate limiter enforces `concurrency.perProvider` requests per minute. Excess requests queue internally — none dropped.
629
+
630
+ ### Cost estimation
631
+
632
+ Providers that implement `estimate` return token and cost estimates. The `/estimate` endpoint and the admin "Estimate" preview both use this. When cost exceeds `bulkConfirmUsdThreshold`, the UI requires explicit confirmation.
633
+
634
+ For custom providers without `pricing`, or unknown model IDs, `estimatedCostUsd` is `undefined` — not an error.
635
+
636
+ ---
637
+
638
+ ## Lexical rich text
639
+
640
+ Lexical fields are translated at the node level, not as serialized JSON. Text nodes are extracted, sent to the LLM as discrete items, and patched back into the tree. Formatting, inline marks, and nested block structure are preserved.
641
+
642
+ Inline nodes (links, inline code) are passed to the model as opaque `<ph id="...">` placeholders — the LLM sees only translatable text and is instructed to keep placeholders in order.
643
+
644
+ ### Custom Lexical node registration
645
+
646
+ ```ts
647
+ aiTranslatePlugin({
648
+ lexicalNodes: [
649
+ {
650
+ type: 'callout',
651
+ translatable: true,
652
+ translatableAttributes: ['title', 'body'],
653
+ },
654
+ ],
655
+ })
656
+ ```
657
+
658
+ | Field | Type | Description |
659
+ |---|---|---|
660
+ | `type` | `string` | Lexical node `type` value. |
661
+ | `translatable` | `boolean` | Whether the node contains translatable text. |
662
+ | `translatableAttributes` | `string[]` | Attribute names containing translatable text. Defaults to `['text']`. |
663
+
664
+ ---
665
+
666
+ ## Security
667
+
668
+ ### API keys
669
+
670
+ Read from `process.env` at runtime. The plugin never serializes them to the client config bundle.
671
+
672
+ ### Anti-injection system prompt
673
+
674
+ All providers include a system prompt instructing the model to translate only and ignore embedded instructions. The output validator additionally scans translated output for injection patterns before writing.
675
+
676
+ ### `fallbackLocale: null` on source reads
677
+
678
+ The plugin reads source documents with `fallbackLocale: null` so it gets only the explicit source-locale value. This prevents fields that fall back to another locale from being double-translated or miscounted.
679
+
680
+ ---
681
+
682
+ ## Escape hatches
683
+
684
+ ### `context.skipAutoTranslate`
685
+
686
+ Set on the Payload request context to suppress automation hooks. Use during bulk imports / seed scripts.
687
+
688
+ ```ts
689
+ await payload.create({
690
+ collection: 'posts',
691
+ data: { title: 'Seed' },
692
+ context: { skipAutoTranslate: true },
693
+ })
694
+ ```
695
+
696
+ ### `context.aiTranslateInternal`
697
+
698
+ Set automatically by the plugin on its own writes to prevent re-trigger loops. Don't propagate this flag from one operation to another — if you copy `req.context` into another write, you'll suppress translation.
699
+
700
+ ### `excludeUrlFields: false`
701
+
702
+ Default is `true`, which auto-excludes `**.url` paths. Override to `false` if you genuinely need locale-specific URLs (rare — usually localized deep links).
703
+
704
+ ### `writeMode: 'minimal'`
705
+
706
+ Per-call override on `translateDocument` / `translateGlobal`. Writes only translated fields, not the full localized field set. Caller accepts the risk that Payload may reject the write if other required localized fields are empty in the target locale.
707
+
708
+ ---
709
+
710
+ ## Cross-references
711
+
712
+ - [Integration walkthrough](./INTEGRATION.md) — adding the plugin to a project from scratch.
713
+ - [Architecture](./ARCHITECTURE.md) — how the components fit together.
714
+ - [Recipes](./USAGE.md) — copy-pasteable solutions.