@purposeinplay/payload-ai-translate
v0.2.4
Published
AI translation plugin for Payload CMS 3 — multi-provider (OpenAI, Anthropic, Gemini, custom), bulk translation, Lexical-aware, with an admin Translation Hub.
Readme
AI Translate Plugin
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.
This is the reference doc — every config knob, type, endpoint, and provider. New to the plugin? Start with INTEGRATION.md. Want to understand how it works under the hood? See ARCHITECTURE.md and the lifecycle diagrams (GitHub-rendered Mermaid + the full Excalidraw system map). Looking for copy-pasteable solutions? See USAGE.md.
Installation
pnpm add @purposeinplay/payload-ai-translateThe providers entrypoint hard-imports all four AI SDK adapters at module load. Install all of them as peer deps:
pnpm add @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai-compatible aiQuick start
// payload.config.ts
import { buildConfig } from 'payload'
import { aiTranslatePlugin } from '@purposeinplay/payload-ai-translate'
import { createAnthropicProvider } from '@purposeinplay/payload-ai-translate/providers'
export default buildConfig({
localization: {
locales: ['en', 'de', 'es', 'fr'],
defaultLocale: 'en',
fallback: true,
},
plugins: [
aiTranslatePlugin({
collections: ['posts'],
sourceLocale: 'en',
targetLocales: ['de', 'es', 'fr'],
provider: createAnthropicProvider({
apiKey: process.env.ANTHROPIC_API_KEY!,
}),
costLimits: {
perCallCharLimit: 10_000,
perDocCharCeiling: 50_000,
bulkConfirmUsdThreshold: 1.0,
},
}),
],
})This wires:
- A "Translate…" button in the sidebar of every
postsdocument POST /api/posts/ai-translateandPOST /api/posts/ai-translate/estimateendpoints- A live progress stream at
GET /api/posts/ai-translate/progress
For automation, audit-log integration, and cost tracking, see INTEGRATION.md.
Configuration reference
AITranslatePluginConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| collections | string[] | No | [] | Collection slugs to enable translation on. |
| globals | string[] | No | [] | Global slugs to enable translation on. |
| sourceLocale | string | Yes | — | The locale to translate from. Must match a locale in localization.locales. |
| targetLocales | string[] | Yes | — | Locales to translate into. sourceLocale is silently filtered if it appears here. |
| provider | TranslationProvider | Yes | — | Default provider. See Providers. |
| providers | Record<string, TranslationProvider> | No | — | Optional named providers for runtime switching. See Runtime provider switching. |
| costLimits | CostLimits | Yes | — | Hard limits that guard against runaway spend. |
| excludeFields | string[] | No | [] | Field paths or globs to skip. E.g. ['slug', 'meta.*', '**.internalNote']. |
| excludeUrlFields | boolean | No | true | When true, appends **.url to excludeFields so URL strings never reach the LLM. |
| concurrency | Partial<ConcurrencyLimits> | No | see below | Parallelism controls. |
| retry | Partial<RetryConfig> | No | see below | Retry behavior on provider errors. |
| access | { translate?: AccessFn } | No | always allowed | Guard the translate endpoints. |
| onEvent | (event: TranslationEvent) => void \| Promise<void> | No | — | Receive translation lifecycle events. |
| onAlert | (alert: TranslationAlert) => void \| Promise<void> | No | — | Receive operational alerts. |
| lexicalNodes | LexicalNodeRegistration[] | No | [] | Register custom Lexical node types for extraction. |
| enabled | boolean | No | true | Disable the plugin without removing it from config. |
| automation | AutomationConfig | No | — | Hook-based automatic translation. See Automation. |
| perFieldButton | boolean | No | false | Inject a translate button next to each localized field. |
| quality | QualityConfig | No | — | Sampling, canary, and output validation. |
| usageTracking | UsageTrackingConfig | No | { enabled: false } | Persist a row per translation job. See Usage tracking. |
| preserveManualEdits | boolean | No | false | Fingerprint every write (auto-registered ai-translate-meta collection) so hand-edited translations are detected and never overwritten ("Protected"). Also enables per-leaf unresolved tracking + the Review Drawer's retry bypass. |
| providerOutage | { threshold?: number; dedupeMs?: number } | No | { threshold: 3, dedupeMs: 900000 } | Emit one deduplicated translation.provider-outage alert after N consecutive transient provider failures; a success resets the streak. |
| settings | TranslationSettingsConfig | No | — | Override defaults for the auto-registered translation-settings global (only meaningful with providers). |
CostLimits
All three fields are required.
| Field | Type | Description |
|---|---|---|
| perCallCharLimit | number | Maximum characters in a single provider call. Requests over this throw CostGuardError with code 'PER_CALL_LIMIT'. |
| perDocCharCeiling | number | Maximum total characters extracted from one document. Document is aborted with a 'translation.cost-guard-abort' alert. |
| bulkConfirmUsdThreshold | number | USD threshold above which the admin UI requires explicit confirmation before starting bulk translation. |
ConcurrencyLimits
| Field | Type | Default | Description |
|---|---|---|---|
| perDocument | number | 3 | Maximum target locales translated in parallel for a single doc. |
| perProvider | number | 5 | Requests-per-minute cap (token-bucket) across all in-flight translations. |
RetryConfig
| Field | Type | Default | Description |
|---|---|---|---|
| attempts | number | 2 | Retry attempts after the initial failure. 2 = up to 3 total attempts. |
| backoffMs | number | 1000 | Base delay in ms. Doubles per attempt. |
AutomationConfig
| Field | Type | Default | Description |
|---|---|---|---|
| trigger | 'on-change' \| 'on-publish' | 'on-change' | When to fire automatic translation. Use 'on-publish' for collections with drafts. |
| mode | 'async' \| 'inline' | 'async' | async queues with coalescing; inline fire-and-forgets. Save returns immediately in both. |
| targetPolicy | TargetPolicy | 'mirror' | 'mirror' overwrites existing translations; 'preserve' skips fields with non-empty target content. |
| diffOnly | boolean | true | Only retranslate fields whose content hash changed since the previous version. |
| coalescingWindowMs | number | 12000 | async mode only — saves within this window collapse into one job. |
QualityConfig
| Field | Type | Description |
|---|---|---|
| validation | ValidationConfig | Tune length bounds and refusal/injection patterns. |
| sampling | SamplingConfig | Sample a percentage of translations for review. |
| canaryLocale | string | Translate this locale on every job but don't write the result. Emitted via onEvent with canary: true. |
ValidationConfig
| Field | Type | Default | Description |
|---|---|---|---|
| minLengthRatio | number | 0.3 | Minimum output/input length ratio. CJK locales (ja/ko/zh) typically need 0.15. |
| maxLengthRatio | number | 3.0 | Maximum output/input length ratio. |
| minSourceLength | number | 10 | Source strings shorter than this bypass length validation. |
| extraRefusalPatterns | RegExp[] | [] | Additional patterns to detect LLM refusals in output. |
| extraInjectionPatterns | RegExp[] | [] | Additional patterns to detect prompt injection in output. |
SamplingConfig
| Field | Type | Description |
|---|---|---|
| rate | number | 0–1. 0.1 samples 10% of translations. |
| onSample | (sample: TranslationSample) => void \| Promise<void> | Callback per sampled translation. |
UsageTrackingConfig
| Field | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | false | Master switch. When true, the plugin auto-registers a collection that persists one row per translation job. |
| collectionSlug | string | 'translation-usage' | Override the auto-registered collection slug. |
| access | { read?: Access } | admin-only | Override the default admin-gated read access. |
When enabled, run pnpm payload migrate:create to generate the schema migration. Commit and deploy. See the Auto-registered surfaces section for the full schema.
Automation
Automation wires translation into Payload's afterChange hook so content is translated on every qualifying save without manual action.
Triggers
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.on-publish— fires only when_statustransitions to'published'. Requiresversions.drafts: true.
Modes
| | async (default) | inline |
|---|---|---|
| Save response time | Not blocked | Not blocked |
| Coalescing | Yes (12s window default) | No |
| Use case | High-frequency edits, autosaves | One-translation-per-publish, low autosave noise |
| Error visibility | onEvent / onAlert | onEvent / onAlert (errors during inline never reach the save response — fire-and-forget) |
diffOnly
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.
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.
Target policy
'mirror'(default) — overwrite whatever's in the target locale. Source publishes are authoritative.'preserve'— skip fields whose target value is non-empty. Useful when editors hand-edit translations and you want manual edits left alone.
Programmatic API
All exports are from @purposeinplay/payload-ai-translate.
translateDocument(payload, options)
Reads the source document, extracts translatable content, fans out to the LLM per locale, and writes results back.
import { translateDocument } from '@purposeinplay/payload-ai-translate'
const result = await translateDocument(payload, {
collection: 'posts',
id: 'abc123',
})TranslateDocumentOptions
| Field | Type | Required | Description |
|---|---|---|---|
| collection | string | Yes | Collection slug. |
| id | string \| number | Yes | Document ID. |
| targetLocales | string[] | No | Override plugin config. |
| targetPolicy | TargetPolicy | No | Override automation.targetPolicy or default 'mirror'. |
| fields | string[] | No | Restrict translation to specific paths. |
| previousDoc | Record<string, unknown> | No | When provided, only fields that differ are translated. |
| scope | { topFields: string[] } | No | Restrict the run to specific localized top-level field groups. Validated against the schema — unknown names are an error. Multi-group docs auto-chunk per group server-side either way. |
| force | boolean | No | Bypass the fingerprint skip AND the manual-edit protect guard — every field re-translates and overwrites, then re-baselines. |
| signal | AbortSignal | No | Abort in-flight translation. |
| 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). |
| jobId | string | No | Reuse an existing progress-store job (used internally by hooks). |
| req | PayloadRequest | No | Originating request — used for audit-log attribution. The plugin always uses a fresh transactionID per locale write regardless. |
| draft | boolean | No (true) | Read latest draft when versioning is enabled. |
TranslateDocumentResult
{
jobId?: string
documentId: string | number
collection: string
sourceLocale: string
succeeded: FieldLocaleResult[]
failed: FieldLocaleResult[]
usage: TranslationUsage
}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.
Throws when:
- Plugin not configured
- Document not found
- Collection not in Payload config
perDocCharCeilingexceeded
translateGlobal(payload, options)
Globals counterpart of translateDocument. Same shape minus id.
import { translateGlobal } from '@purposeinplay/payload-ai-translate'
await translateGlobal(payload, {
global: 'site-config',
targetLocales: ['de', 'fr'],
})TranslateGlobalOptions
| Field | Type | Required | Description |
|---|---|---|---|
| global | string | Yes | Global slug. |
| targetLocales | string[] | No | Override plugin config. |
| targetPolicy | TargetPolicy | No | Override default 'mirror'. |
| fields | string[] | No | Restrict to paths. |
| previousDoc | Record<string, unknown> | No | Diff-only equivalent for globals. |
| signal | AbortSignal | No | Abort. |
| writeMode | 'full' \| 'minimal' | No ('full') | See translateDocument. |
| jobId | string | No | Reuse a progress-store job. |
| req | PayloadRequest | No | For audit-log attribution. |
Exported utilities
import { isFieldEmpty, diffFields } from '@purposeinplay/payload-ai-translate'
isFieldEmpty('', 'text') // true
isFieldEmpty('Hi', 'text') // false
isFieldEmpty([], 'array') // true
isFieldEmpty(null, 'richText') // true
diffFields(prevDoc, currDoc, ['title', 'body']) // ['title'] (paths that changed)Providers
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.
Anthropic
import { createAnthropicProvider } from '@purposeinplay/payload-ai-translate/providers'
createAnthropicProvider({
apiKey: process.env.ANTHROPIC_API_KEY!,
model: 'claude-sonnet-4-6', // default
temperature: 0.3, // default
maxTokens: 4096, // default
})Cost estimates available for claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5.
OpenAI
import { createOpenAIProvider } from '@purposeinplay/payload-ai-translate/providers'
createOpenAIProvider({
apiKey: process.env.OPENAI_API_KEY!,
model: 'gpt-4o', // default
temperature: 0.3,
baseURL: undefined, // override for Azure OpenAI
})Cost estimates available for gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano.
Google Gemini
import { createGeminiProvider } from '@purposeinplay/payload-ai-translate/providers'
createGeminiProvider({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY!,
model: 'gemini-2.5-flash', // default
temperature: 0.3,
})Cost estimates available for gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash.
Custom (OpenAI-compatible API)
For Grok, Kimi, Together, self-hosted, or any provider that implements the OpenAI chat completions API:
import { createCustomProvider } from '@purposeinplay/payload-ai-translate/providers'
createCustomProvider({
apiKey: process.env.XAI_API_KEY!,
baseURL: 'https://api.x.ai/v1',
model: 'grok-3-mini',
pricing: { input: 0.3 / 1_000_000, output: 0.5 / 1_000_000 }, // optional
})Without pricing, estimatedCostUsd is undefined (not an error) — the plugin still translates.
Mock (for testing/dev)
import { createMockProvider } from '@purposeinplay/payload-ai-translate/providers'
createMockProvider()Prepends [{locale}] to each input string. No network calls, no costs, deterministic. Use in CI and local dev to exercise wiring without spending tokens.
Runtime provider switching
When you pass a providers map, the plugin auto-registers a translation-settings global so admins can switch providers without redeploying:
aiTranslatePlugin({
// …
provider: createMockProvider(), // fallback when no admin choice exists
providers: {
'anthropic-sonnet': createAnthropicProvider({ apiKey, model: 'claude-sonnet-4-6' }),
'anthropic-haiku': createAnthropicProvider({ apiKey, model: 'claude-haiku-4-5' }),
'openai-mini': createOpenAIProvider({ apiKey: oaKey, model: 'gpt-4o-mini' }),
},
})Each map key becomes an option in the admin's "Active provider" dropdown. The default provider is always available as a fallback.
Implementing your own provider
import type { TranslationProvider } from '@purposeinplay/payload-ai-translate'
const myProvider: TranslationProvider = {
async translate(request) {
// request.items: text units to translate
// request.sourceLocale, request.targetLocale, request.context
// return: { items, usage, model, latencyMs }
},
// estimate is optional — without it, cost previews are unavailable
async estimate(request) {
return { inputTokens: 0, estimatedCostUsd: 0 }
},
}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.
Auto-registered surfaces
translation-usage (collection)
Registered when usageTracking.enabled: true.
| Field | Type | Description |
|---|---|---|
| kind | 'collection' \| 'global' | What was translated. |
| jobId | string | Matches the SSE jobId. |
| slug | string | Collection or global slug. |
| documentId | string \| null | Null for globals. |
| status | 'succeeded' \| 'failed' \| 'aborted' | Job-level outcome. Skipped fields don't make a job fail; aborted = the cost guard stopped the run before writing anything (a safety stop, not a failure). |
| sourceLocale | string | |
| succeededCount | number | Per-locale successes. |
| failedCount | number | Per-locale real failures (excludes skipped). |
| inputTokens / outputTokens | number | Provider-reported. |
| estimatedCostUsd | number \| null | Null for providers without pricing. |
| model | string | Provider model id. |
| durationMs | number | Wall-clock total. |
| error | string \| null | Short error text on failure. |
| targetLocales | array | Per-locale outcome rows: locale, status, error, errorCode, failedFields[], hashRecordPartial. |
| fieldsTranslated / fieldsHashSkipped / fieldsPreserved / fieldsSoftSkipped / fieldsNonTranslatable | number | The run receipt breakdown — what actually happened per field. |
| softSkippedFields | array | Per-field skip entries: path, locale, reasonCode, sourceValue, acknowledged, acknowledgedNote, acknowledgedBy. Feeds the Review Drawer. |
| preservedFields | array | Fields the manual-edit guard refused to overwrite (path, locale). |
| triggeredByUserId / triggeredByEmail | string | Who caused the run — the "By" column everywhere. |
| createdAt / updatedAt | Date | |
Reads are admin-gated by default. Override with usageTracking.access.read.
Migration: run pnpm payload migrate:create after first enabling. The plugin doesn't ship migrations for its Payload-registered collections — your schema management owns them.
Upgrading? Releases regularly ADD columns to
translation-usage(e.g. the per-field receipt counts,softSkippedFields.acknowledged*,triggeredBy*, theabortedstatus) and register new sidecar collections (ai-translate-meta,ai-translate-unresolved,translation-alerts,ai-translate-jobs, bulk batches/units). After every plugin upgrade, runpnpm payload migrate:createand review the generated migration before deploying — the release notes call out schema-affecting versions. (The bulk job-queue tables self-migrate idempotently at boot; the Payload collections above do not.)
translation-settings (global)
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.
REST endpoints
All endpoints are auto-registered on every configured collection and global at /api/<slug>.
POST /ai-translate
Translates the doc (or global). Request body fields are all optional; defaults come from plugin config.
{
"id": "doc-id", // collections only — required for collections
"targetLocales": ["fr", "de"], // optional
"fields": ["title", "body"], // optional
"writeMode": "full", // optional ('full' | 'minimal')
"async": true, // optional — enqueue and return { jobId } immediately; poll doc-status/progress
"force": false, // optional — bypass skip + protect guards (overwrites hand-edits)
"scope": { "topFields": ["hero"] }, // optional — translate only these top-level groups (validated)
"confirmCost": true // optional — acknowledge a 402 cost-confirmation response and run anyway
}Use async: true behind proxies/load balancers — the synchronous mode can outlive
a gateway timeout on large documents. The admin UI always uses async + polling.
Response:
{
"jobId": "j_abc123",
"documentId": "doc-id",
"collection": "posts",
"sourceLocale": "en",
"succeeded": [
{ "fieldPath": "title", "locale": "fr", "status": "success", "characterCount": 42, "durationMs": 310 }
],
"failed": [],
"usage": { "inputTokens": 120, "outputTokens": 135, "estimatedCostUsd": 0.00034, "model": "claude-sonnet-4-6" }
}POST /ai-translate/estimate
Token + cost preview without making a translation call. Same body as /ai-translate.
{
"inputTokens": 120,
"estimatedCostUsd": 0.00034,
"billableCharacters": 480,
"skippedCharacters": 0
}billableCharacters / skippedCharacters lets you see how much will actually be translated vs. how much is excluded (URLs, exact-match exclude paths, empty fields).
POST /ai-translate/cancel
Requests cancellation of the document's running job ({ "id": "doc-id" } for collections). Honored at the next per-group chunk boundary — languages and groups already written stay saved; the job ends as cancelled with its per-group breakdown intact. Works cross-instance via the shared jobs mirror.
GET /ai-translate/review
The Review Drawer's data source: everything a human still needs to look at for one document, merged from the per-leaf unresolved book (incl. self-heal detections), the latest run's failed fields, and unacknowledged attention-grade soft-skips — deduped per (locale, path) with breadcrumb labels and current source/target text.
Query params: ?id= (collections), ?locale= (scope the read to one language), ?runId= (scope to one run's receipt — the Hub's "This run (N) | Everything (M)" toggle). The response includes targetLocales (the surface's full configured list) so the drawer can render a chip for every language.
GET /ai-translate/doc-status · POST /ai-translate/status-batch
doc-status is the per-document status projection (per-language state, stale groups, active job) — derived through the one canonical resolveLocaleStatus. status-batch (root-level) answers the same question for many documents in ONE request: { docs: [{ slug, documentId }] } → a live verdict per doc. The Hub's "Now" column uses it.
POST /ai-translate/skips/acknowledge
Mark-as-OK: { id?, locale, paths, note? }. Persists the acknowledgment durably (with acknowledgedBy from the session and the optional note) so detection never re-flags a deliberate keep. A later source change re-opens the question by design.
Translation Hub endpoints (root-level)
Bulk translation runs on /api/translation-hub/bulk-translate/*: preflight (cost estimate + checks), enqueue (start, 2FA-gated), status, list, active, :id/cancel, :id/retry-failed, :id/revert (24h window, 2FA), :id/force-reset, :id/failures, and :id/review — the batch-scoped Review Drawer feed (same item anatomy as the per-doc review, each item tagged with its owning document).
GET /ai-translate/progress (SSE)
Live progress stream for a translation job. Subscribed via Server-Sent Events.
Two modes:
?jobId=X— follow a specific job.?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.
Each event has shape:
{
"jobId": "j_abc123",
"completed": ["de", "es"],
"failed": [{ "locale": "fr", "reason": "1 field(s) failed" }],
"total": 9,
"status": "running"
}status is one of 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'.
Note: many proxy/load-balancer chains buffer or kill SSE. The admin UI does NOT rely on this stream — it polls
doc-status(2.5s) instead. Prefer polling for anything that must work behind an arbitrary gateway.
Access control
Pass access.translate to gate the translate and estimate endpoints:
aiTranslatePlugin({
// …
access: {
translate: ({ req }) => req.user?.role === 'editor' || req.user?.role === 'admin',
},
})The progress endpoint inherits the same gate.
Admin UI
Sidebar Translate button
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.
Per-field button
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.
In-edit progress bar
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.
Translation Settings global (when providers map is configured)
Admin chooses an active provider from a dropdown sourced from the providers map keys. Changes apply on the next translation.
Translation Usage collection (when usageTracking.enabled)
Standard Payload collection list view, admin-gated. Filterable by status, model, slug, date.
Events and alerts
onEvent
| Type | When | Additional fields |
|---|---|---|
| 'translation.started' | Before LLM calls | targetLocales |
| 'translation.field' | After each individual field translation | fields (single entry) |
| 'translation.succeeded' | All locales completed with no real failures (skipped is OK) | fields, usage |
| 'translation.failed' | At least one locale had a real failure | fields, usage, error |
All events include documentId, collection, sourceLocale, targetLocales, timestamp. Set canary: true when emitted from the canary locale.
onAlert
| Type | When |
|---|---|
| 'translation.persistent-failure' | A field has failed after all retry attempts. |
| 'translation.cost-guard-abort' | A document exceeded perDocCharCeiling. |
| 'translation.provider-outage' | Provider unreachable for multiple consecutive requests. |
Quality and monitoring
Sampling
quality: {
sampling: {
rate: 0.05,
onSample: async (sample) => {
// sample: { documentId, collection, fieldPath, sourceLocale, targetLocale,
// sourceText, translatedText, model, timestamp }
await db.translationSamples.insert(sample)
},
},
}Canary locale
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.
quality: { canaryLocale: 'en-qa' }The synthetic locale must exist in localization.locales.
Output validation
Every translation is validated before write:
- Length ratio within
[minLengthRatio, maxLengthRatio] - Doesn't match a known refusal pattern ("I cannot translate…")
- Doesn't match an injection pattern (attempted prompt break-out)
- Not a verbatim echo of the source (special case: marked
'skipped'instead of failed; target field left untouched)
Failed validations produce status: 'failed' and the source field is left unchanged in the target.
Cost controls
Character limits
| Limit | Config | Scope | Behavior |
|---|---|---|---|
| Per-call | perCallCharLimit | Single LLM request | CostGuardError with code 'PER_CALL_LIMIT' |
| Per-document | perDocCharCeiling | All fields in one doc | Document aborted; 'translation.cost-guard-abort' alert |
Both count raw characters (not tokens). The character-to-token ratio used for estimates is approximately 3.5 chars/token.
Rate limiting
Internal token-bucket rate limiter enforces concurrency.perProvider requests per minute. Excess requests queue internally — none dropped.
Cost estimation
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.
For custom providers without pricing, or unknown model IDs, estimatedCostUsd is undefined — not an error.
Lexical rich text
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.
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.
Custom Lexical node registration
aiTranslatePlugin({
lexicalNodes: [
{
type: 'callout',
translatable: true,
translatableAttributes: ['title', 'body'],
},
],
})| Field | Type | Description |
|---|---|---|
| type | string | Lexical node type value. |
| translatable | boolean | Whether the node contains translatable text. |
| translatableAttributes | string[] | Attribute names containing translatable text. Defaults to ['text']. |
Security
API keys
Read from process.env at runtime. The plugin never serializes them to the client config bundle.
Anti-injection system prompt
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.
fallbackLocale: null on source reads
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.
Escape hatches
context.skipAutoTranslate
Set on the Payload request context to suppress automation hooks. Use during bulk imports / seed scripts.
await payload.create({
collection: 'posts',
data: { title: 'Seed' },
context: { skipAutoTranslate: true },
})context.aiTranslateInternal
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.
excludeUrlFields: false
Default is true, which auto-excludes **.url paths. Override to false if you genuinely need locale-specific URLs (rare — usually localized deep links).
writeMode: 'minimal'
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.
Cross-references
- Integration walkthrough — adding the plugin to a project from scratch.
- Architecture — how the components fit together.
- Lifecycle diagrams — GitHub-rendered Mermaid flowcharts (lifecycle, status states, review loop, runId correlation) + the full Excalidraw system map.
- Recipes — copy-pasteable solutions.
