@focus-reactive/payload-plugin-translator 0.11.4 → 0.12.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.
package/README.md CHANGED
@@ -340,7 +340,7 @@ Pass **either** an `apiKey` **or** a ready-made `client` — never both; the typ
340
340
  | -------------- | ------------------------- | ------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------- |
341
341
  | `apiKey` | `string` | Unless `client` | — | OpenAI API key. The `openai` package is loaded on first translation, not at config load. _Since v0.11.0: optional when `client` is given._ |
342
342
  | `client` | `OpenAIClientShape` | Unless `apiKey` | — | Your own client — Azure OpenAI, a corporate proxy, OpenRouter, anything with a matching `chat.completions.create`. On this path the `openai` package is never loaded. _Since v0.11.0._ |
343
- | `model` | `string` | No | `'gpt-4o'` | Model used for translation. **The default may change in a minor release** — pin it if you need reproducible output and cost. |
343
+ | `model` | `string` | No | `'gpt-5.4-mini'` | Model used for translation. **The default may change in a minor release** — pin it if you need reproducible output and cost. |
344
344
  | `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom system-prompt builder. |
345
345
  | `dryRun` | `boolean \| DryRunConfig` | No | `false` | **Deprecated** — see the note below. Simulates translations without API calls, but still writes, publishes and records provenance. |
346
346
  | `sampling` | `OpenAISamplingParams` | No | not sent | `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`. Omitted entirely unless set — several models reject them. **Before v0.11.0 this package always sent `temperature: 0, top_p: 1, frequency_penalty: 0, presence_penalty: 0`**, so translations were deterministic; they now follow the model's defaults. Set `{ temperature: 0 }` to restore that. _Since v0.11.0._ |
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Payload's job queue keeps only the message: it rethrows a handler failure as
3
+ * `new TaskError({ message: err.message })` and persists `{ name, cancelled, message, stack }`,
4
+ * so the error's class, `code` and `cause` are gone before anything is stored (Payload 3.x).
5
+ * That is why a reason travels inside the string rather than as an error subclass or a field.
6
+ */
7
+ /** A failure cause safe to name to an end user. Every member obliges admin-facing copy for it. */
8
+ export type UserFacingFailureReason = "model-unavailable";
9
+ /** `detail` is for logs and the job record; it is never shown to a user. */
10
+ export declare function markFailureReason(reason: UserFacingFailureReason, detail: string): string;
11
+ /** Anchored: a marked message quoted inside another error's text is not that error's own reason. */
12
+ export declare function readFailureReason(message: string): UserFacingFailureReason | null;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Payload's job queue keeps only the message: it rethrows a handler failure as
3
+ * `new TaskError({ message: err.message })` and persists `{ name, cancelled, message, stack }`,
4
+ * so the error's class, `code` and `cause` are gone before anything is stored (Payload 3.x).
5
+ * That is why a reason travels inside the string rather than as an error subclass or a field.
6
+ */ /** A failure cause safe to name to an end user. Every member obliges admin-facing copy for it. */ const MARKER_PREFIX = "translator:";
7
+ const MARKER = new RegExp(`^\\[${MARKER_PREFIX}([a-z-]+)\\] `, "u");
8
+ const REASONS = new Set([
9
+ "model-unavailable"
10
+ ]);
11
+ const isUserFacingFailureReason = (value)=>REASONS.has(value);
12
+ /** `detail` is for logs and the job record; it is never shown to a user. */ export function markFailureReason(reason, detail) {
13
+ return `[${MARKER_PREFIX}${reason}] ${detail}`;
14
+ }
15
+ /** Anchored: a marked message quoted inside another error's text is not that error's own reason. */ export function readFailureReason(message) {
16
+ const match = MARKER.exec(message);
17
+ const reason = match?.[1];
18
+ return reason !== undefined && isUserFacingFailureReason(reason) ? reason : null;
19
+ }
20
+
21
+ //# sourceMappingURL=failureReason.js.map
@@ -116,8 +116,11 @@ const asObject = (value)=>isObject(value) ? value : {};
116
116
  target: this.targetData,
117
117
  path: []
118
118
  }, walker);
119
+ // A detached copy, because write-back mutates an object-valued leaf (a rich-text tree) in
120
+ // place: seating the caller's own object here would translate their source document. Scalars
121
+ // are immutable, so they are seated as they are.
119
122
  for (const { dataRef, key, sourceValue } of selected){
120
- dataRef[key] = sourceValue;
123
+ dataRef[key] = isObject(sourceValue) ? structuredClone(sourceValue) : sourceValue;
121
124
  }
122
125
  return chunks;
123
126
  }
@@ -25,9 +25,12 @@ export declare class ProvenanceService {
25
25
  private readonly schemaMap;
26
26
  constructor(payload: Payload, store: ProvenanceStore, schemaMap: CollectionSchemaMap);
27
27
  /**
28
- * Hash the PRISTINE source. The caller MUST pass source fetched **before** the translation pipeline
29
- * runs — the pipeline mutates object-valued leaves (e.g. richText nodes) in place, so hashing after
30
- * it would capture the target translation and make every fresh translation look instantly stale.
28
+ * Hash the source the translation was made from the baseline staleness is later measured against.
29
+ *
30
+ * Ordering used to matter: the pipeline wrote into object-valued leaves it shared with the caller's
31
+ * source, so hashing afterwards captured the translation and reported every fresh translation as
32
+ * stale. It now detaches those leaves, so this may be called on either side of the pipeline.
33
+ *
31
34
  * Returns `null` on any failure (no schema, hashing error) so provenance is skipped, not the translation.
32
35
  */
33
36
  captureFingerprint(collection: CollectionSlug, sourceData: Record<string, unknown>): string | null;
@@ -19,9 +19,12 @@ import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
19
19
  this.schemaMap = schemaMap;
20
20
  }
21
21
  /**
22
- * Hash the PRISTINE source. The caller MUST pass source fetched **before** the translation pipeline
23
- * runs — the pipeline mutates object-valued leaves (e.g. richText nodes) in place, so hashing after
24
- * it would capture the target translation and make every fresh translation look instantly stale.
22
+ * Hash the source the translation was made from the baseline staleness is later measured against.
23
+ *
24
+ * Ordering used to matter: the pipeline wrote into object-valued leaves it shared with the caller's
25
+ * source, so hashing afterwards captured the translation and reported every fresh translation as
26
+ * stale. It now detaches those leaves, so this may be called on either side of the pipeline.
27
+ *
25
28
  * Returns `null` on any failure (no schema, hashing error) so provenance is skipped, not the translation.
26
29
  */ captureFingerprint(collection, sourceData) {
27
30
  const schema = this.schemaMap.get(collection);
@@ -1,4 +1,4 @@
1
1
  export { ServerResponse } from "./ServerResponse";
2
2
  export { withErrorHandler } from "./withErrorHandler";
3
3
  export { withAccessCheck } from "./withAccessCheck";
4
- export { toClientErrorMessage, GENERIC_TRANSLATION_ERROR } from "./toClientErrorMessage";
4
+ export { toClientErrorMessage, failureReasonText, GENERIC_TRANSLATION_ERROR, } from "./toClientErrorMessage";
@@ -1,6 +1,6 @@
1
1
  export { ServerResponse } from "./ServerResponse";
2
2
  export { withErrorHandler } from "./withErrorHandler";
3
3
  export { withAccessCheck } from "./withAccessCheck";
4
- export { toClientErrorMessage, GENERIC_TRANSLATION_ERROR } from "./toClientErrorMessage";
4
+ export { toClientErrorMessage, failureReasonText, GENERIC_TRANSLATION_ERROR } from "./toClientErrorMessage";
5
5
 
6
6
  //# sourceMappingURL=index.js.map
@@ -1,3 +1,5 @@
1
+ /** Call this on any message before it reaches a user — see {@link REASON_TEXT}. */
2
+ export declare function failureReasonText(message?: string): string | null;
1
3
  /** Shown to the browser instead of a raw provider/runtime error outside development. */
2
4
  export declare const GENERIC_TRANSLATION_ERROR = "Translation failed. See the server logs for details.";
3
5
  /**
@@ -5,11 +7,12 @@ export declare const GENERIC_TRANSLATION_ERROR = "Translation failed. See the se
5
7
  *
6
8
  * Provider/runtime errors (e.g. `401 Incorrect API key provided: sk-proj-…`) leak implementation
7
9
  * detail — and sometimes secrets — so the client must not see them in production. The full error
8
- * still lives in the job record and server logs for debugging; only the client-facing copy is
9
- * collapsed. Fail-safe: the raw message passes through solely in `development`/`test`; every other
10
- * environment (including an unset `NODE_ENV`) returns {@link GENERIC_TRANSLATION_ERROR}.
10
+ * still lives in the job record and server logs for debugging. Fail-safe: outside
11
+ * `development`/`test` (including an unset `NODE_ENV`) the raw message never leaves this function.
12
+ * A message carrying a failure reason is answered from this file's own catalogue instead, in every
13
+ * environment, and its tail is never shown.
11
14
  *
12
15
  * @param message - The raw server-side error message, if any.
13
- * @returns The raw message in a debug environment, otherwise the generic text.
16
+ * @returns The raw message in a debug environment, otherwise the generic text or a reason's copy.
14
17
  */
15
18
  export declare function toClientErrorMessage(message?: string): string;
@@ -1,3 +1,11 @@
1
+ import { readFailureReason } from "../../../core/domain/translation-providers/failureReason";
2
+ /** Re-authored here, never taken from the error: only text this file owns is free of vendor detail. */ const REASON_TEXT = {
3
+ "model-unavailable": "The configured translation model is not available to this API key. Set `model` in the provider configuration to one your key can use."
4
+ };
5
+ /** Call this on any message before it reaches a user — see {@link REASON_TEXT}. */ export function failureReasonText(message) {
6
+ const reason = message === undefined ? null : readFailureReason(message);
7
+ return reason === null ? null : REASON_TEXT[reason];
8
+ }
1
9
  /** Shown to the browser instead of a raw provider/runtime error outside development. */ export const GENERIC_TRANSLATION_ERROR = "Translation failed. See the server logs for details.";
2
10
  // Only these environments get the raw message as a debug aid. Anything else — production, an unset
3
11
  // or misconfigured NODE_ENV — is treated as "not debug", so the default is the safe, generic text.
@@ -10,13 +18,16 @@ const DEBUG_ENVS = new Set([
10
18
  *
11
19
  * Provider/runtime errors (e.g. `401 Incorrect API key provided: sk-proj-…`) leak implementation
12
20
  * detail — and sometimes secrets — so the client must not see them in production. The full error
13
- * still lives in the job record and server logs for debugging; only the client-facing copy is
14
- * collapsed. Fail-safe: the raw message passes through solely in `development`/`test`; every other
15
- * environment (including an unset `NODE_ENV`) returns {@link GENERIC_TRANSLATION_ERROR}.
21
+ * still lives in the job record and server logs for debugging. Fail-safe: outside
22
+ * `development`/`test` (including an unset `NODE_ENV`) the raw message never leaves this function.
23
+ * A message carrying a failure reason is answered from this file's own catalogue instead, in every
24
+ * environment, and its tail is never shown.
16
25
  *
17
26
  * @param message - The raw server-side error message, if any.
18
- * @returns The raw message in a debug environment, otherwise the generic text.
27
+ * @returns The raw message in a debug environment, otherwise the generic text or a reason's copy.
19
28
  */ export function toClientErrorMessage(message) {
29
+ const reasonText = failureReasonText(message);
30
+ if (reasonText !== null) return reasonText;
20
31
  if (DEBUG_ENVS.has(process.env.NODE_ENV ?? "")) {
21
32
  return message?.trim() || GENERIC_TRANSLATION_ERROR;
22
33
  }
@@ -1,5 +1,6 @@
1
1
  import { APIError } from "payload";
2
2
  import { ServerResponse } from "./ServerResponse";
3
+ import { failureReasonText } from "./toClientErrorMessage";
3
4
  /**
4
5
  * Wraps async handler with error handling.
5
6
  * Catches APIError and generic Error, returns appropriate HTTP response.
@@ -13,7 +14,7 @@ import { ServerResponse } from "./ServerResponse";
13
14
  return ServerResponse.custom(e.message, e.status);
14
15
  }
15
16
  if (e instanceof Error) {
16
- return ServerResponse.internalServerError(e.message);
17
+ return ServerResponse.internalServerError(failureReasonText(e.message) ?? e.message);
17
18
  }
18
19
  return ServerResponse.internalServerError();
19
20
  }
@@ -6,7 +6,7 @@ type OpenAIProviderBase = {
6
6
  /**
7
7
  * Model used for translation.
8
8
  *
9
- * @default 'gpt-4o' — may move in a minor release; pin it if you need reproducibility.
9
+ * @default 'gpt-5.4-mini' — may move in a minor release; pin it if you need reproducibility.
10
10
  */
11
11
  model?: string;
12
12
  /**
@@ -1,7 +1,7 @@
1
1
  import { createTranslationProvider } from "../shared";
2
2
  import { loadOpenAIClient } from "./loadOpenAIClient";
3
3
  import { openAIComplete } from "./openAIComplete";
4
- const DEFAULT_MODEL = "gpt-4o";
4
+ const DEFAULT_MODEL = "gpt-5.4-mini";
5
5
  /** The SDK's own default is ten minutes — far too long when a translation blocks a live editor request. */ const DEFAULT_TIMEOUT_MS = 60_000;
6
6
  /**
7
7
  * Creates an OpenAI translation provider.
@@ -1,3 +1,5 @@
1
+ import { markFailureReason } from "../../core/domain/translation-providers/failureReason";
2
+ import { isObject } from "../../core/kernel/utils/isObject";
1
3
  import { errorMessageLower, NoContentError, ProviderConfigurationError } from "../shared";
2
4
  const SCHEMA_NAME = "translation";
3
5
  /**
@@ -16,6 +18,17 @@ const SCHEMA_NAME = "translation";
16
18
  if (text.includes("invalid schema")) return "schema-rejected";
17
19
  return null;
18
20
  }
21
+ const MODEL_ACCESS_CODE = "model_not_found";
22
+ /**
23
+ * Only for a gateway that forwards the text and no `code` — Azure, OpenRouter, a corporate proxy.
24
+ * A full phrase rather than the word `model`, which such a gateway also echoes in rate limits.
25
+ * Wording as observed 2026-09; the code above is what carries this when OpenAI rewords it.
26
+ */ const MODEL_ACCESS_PHRASE = "does not exist or you do not have access";
27
+ function isModelAccessFailure(cause) {
28
+ if (isObject(cause) && cause.code === MODEL_ACCESS_CODE) return true;
29
+ const text = errorMessageLower(cause);
30
+ return text !== null && text.includes(MODEL_ACCESS_PHRASE);
31
+ }
19
32
  /**
20
33
  * The vendor boundary: the only place OpenAI's response shape is read.
21
34
  *
@@ -60,6 +73,11 @@ const SCHEMA_NAME = "translation";
60
73
  signal
61
74
  });
62
75
  } catch (cause) {
76
+ if (isModelAccessFailure(cause)) {
77
+ throw new ProviderConfigurationError(markFailureReason("model-unavailable", `The model "${model}" is not available to this API key. Pass \`model\` in the provider configuration to pin one your key can use.`), {
78
+ cause
79
+ });
80
+ }
63
81
  const rejection = structuredOutput === "json_schema" ? classifySchemaRejection(cause) : null;
64
82
  if (rejection === "model-does-not-support") {
65
83
  throw new ProviderConfigurationError(`The model "${model}" does not support the json_schema response format. Pass structuredOutput: "json_object", or choose a model that supports structured outputs.`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",