@justanarthur/payload-plugin-translator 3.0.3 → 3.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.
@@ -0,0 +1,243 @@
1
+ import {
2
+ chunkArray
3
+ } from "./chunk-e6c0qzkt.js";
4
+
5
+ // src/resolvers/openAI.ts
6
+ var LOCALE_DISPLAY_NAME = {
7
+ en: "English",
8
+ sk: "Slovak",
9
+ cs: "Czech",
10
+ de: "German",
11
+ uk: "Ukrainian",
12
+ ua: "Ukrainian",
13
+ pl: "Polish",
14
+ hu: "Hungarian",
15
+ fr: "French",
16
+ es: "Spanish",
17
+ it: "Italian",
18
+ pt: "Portuguese",
19
+ nl: "Dutch",
20
+ ro: "Romanian"
21
+ };
22
+ var RETRY_DELAYS_MS = [500, 1000, 2000];
23
+ var defaultPrompt = ({ localeFrom, localeTo, texts }) => {
24
+ const from = LOCALE_DISPLAY_NAME[localeFrom] ?? localeFrom;
25
+ const to = LOCALE_DISPLAY_NAME[localeTo] ?? localeTo;
26
+ const input = Object.fromEntries(texts.map((text, index) => [String(index), text]));
27
+ return `You are a machine-translation engine for website copy. Translate every value in the input JSON object from ${from} (${localeFrom}) to ${to} (${localeTo}).
28
+
29
+ Rules:
30
+ 1. Output a JSON object with exactly the same keys as the input. One key, one translated value. Never merge, split, drop or add keys.
31
+ 2. Keep placeholders in curly braces (for example {address} or {count, plural, ...}) exactly as they are; translate only the words around them.
32
+ 3. URLs, email addresses, product and brand names, code, hex strings and other opaque identifiers: keep as-is.
33
+ 4. Keep leading and trailing whitespace of each value.
34
+ 5. Apply locale-specific formatting for dates, currency and decimal separators in human-readable text.
35
+ 6. Return only the JSON object. No markdown fences, no prose.
36
+
37
+ INPUT:
38
+ ${JSON.stringify(input)}`;
39
+ };
40
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
+ var isRetryableStatus = (status) => status === 429 || status >= 500;
42
+ var isGpt5Family = (model) => /^gpt-5/.test(model);
43
+ var isGpt54Plus = (model) => /^gpt-5\.[1-9]/.test(model);
44
+ var usesMaxCompletionTokens = (model) => isGpt5Family(model) || /^o[1-9]/.test(model);
45
+ var deriveMaxTokens = (chunkLength, model) => {
46
+ const base = Math.max(chunkLength * 100, 4000);
47
+ if (model && isGpt54Plus(model))
48
+ return Math.max(base * 4, 16000);
49
+ return base;
50
+ };
51
+ var parseContent = (raw, expected) => {
52
+ const trimmed = raw.trim();
53
+ const m = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/);
54
+ const candidate = m ? m[1].trim() : trimmed;
55
+ const fenceStripped = m !== null;
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(candidate);
59
+ } catch (e) {
60
+ return {
61
+ error: e instanceof Error ? e.message : String(e),
62
+ fenceStripped,
63
+ ok: false
64
+ };
65
+ }
66
+ const values = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" ? Array.from({ length: expected }, (_, index) => parsed[String(index)]) : null;
67
+ if (!values)
68
+ return { error: "parsed value is neither an object nor an array", fenceStripped, ok: false };
69
+ if (values.length !== expected) {
70
+ return { error: `expected ${expected} value(s), got ${values.length}`, fenceStripped, ok: false };
71
+ }
72
+ if (!values.every((v) => typeof v === "string")) {
73
+ return { error: "missing key or non-string value", fenceStripped, ok: false };
74
+ }
75
+ return { fenceStripped, ok: true, translated: values };
76
+ };
77
+ var mapLimit = async (items, limit, task) => {
78
+ const results = new Array(items.length);
79
+ let next = 0;
80
+ const worker = async () => {
81
+ while (next < items.length) {
82
+ const index = next++;
83
+ results[index] = await task(items[index]);
84
+ }
85
+ };
86
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
87
+ return results;
88
+ };
89
+ var openAIResolver2 = ({
90
+ apiKey,
91
+ baseUrl,
92
+ chunkLength = 100,
93
+ concurrency = 3,
94
+ model = "gpt-4o-mini",
95
+ prompt = defaultPrompt
96
+ }) => {
97
+ return {
98
+ key: "openai",
99
+ resolve: async ({ localeFrom, localeTo, req, texts }) => {
100
+ const apiUrl = `${baseUrl || "https://api.openai.com"}/v1/chat/completions`;
101
+ const maxTokens = deriveMaxTokens(chunkLength, model);
102
+ const maxTokensKey = usesMaxCompletionTokens(model) ? "max_completion_tokens" : "max_tokens";
103
+ const supportsCustomTemperature = !isGpt5Family(model);
104
+ const reasoningEffort = isGpt54Plus(model) ? "low" : undefined;
105
+ const logger = req.payload.logger;
106
+ const requestChunk = async (chunk) => {
107
+ for (let attempt = 0;attempt <= RETRY_DELAYS_MS.length; attempt++) {
108
+ let shouldRetry = false;
109
+ let httpStatus = 0;
110
+ try {
111
+ const res = await fetch(apiUrl, {
112
+ body: JSON.stringify({
113
+ messages: [
114
+ {
115
+ content: prompt({ localeFrom, localeTo, texts: chunk }),
116
+ role: "user"
117
+ }
118
+ ],
119
+ model,
120
+ response_format: { type: "json_object" },
121
+ ...supportsCustomTemperature ? { temperature: 0 } : {},
122
+ ...reasoningEffort ? { reasoning_effort: reasoningEffort } : {},
123
+ [maxTokensKey]: maxTokens
124
+ }),
125
+ headers: {
126
+ Authorization: `Bearer ${apiKey}`,
127
+ "Content-Type": "application/json"
128
+ },
129
+ method: "post"
130
+ });
131
+ httpStatus = res.status;
132
+ const data = await res.json();
133
+ if (res.ok) {
134
+ const choice = data?.choices?.[0];
135
+ if (choice?.finish_reason === "length") {
136
+ logger.warn({
137
+ code: "OPENAI_TRUNCATED",
138
+ message: `OpenAI output hit the token limit for ${chunk.length} value(s)`
139
+ });
140
+ return { kind: "truncated" };
141
+ }
142
+ const content = choice?.message?.content;
143
+ if (!content) {
144
+ logger.error({
145
+ code: "OPENAI_BAD_JSON",
146
+ message: "OpenAI response missing content",
147
+ openAIResponse: data
148
+ });
149
+ shouldRetry = true;
150
+ } else {
151
+ const result = parseContent(content, chunk.length);
152
+ if (result.ok) {
153
+ if (result.fenceStripped) {
154
+ logger.info({
155
+ code: "OPENAI_FENCE_STRIPPED",
156
+ message: "OpenAI returned fenced JSON despite json_object mode"
157
+ });
158
+ }
159
+ return { kind: "ok", translated: result.translated };
160
+ }
161
+ logger.error({
162
+ code: "OPENAI_BAD_JSON",
163
+ error: result.error,
164
+ fenceStripped: result.fenceStripped,
165
+ message: "Failed to parse OpenAI response"
166
+ });
167
+ shouldRetry = true;
168
+ }
169
+ } else {
170
+ logger.error({
171
+ code: "OPENAI_HTTP_ERROR",
172
+ message: "OpenAI returned non-2xx status",
173
+ openAIResponse: data,
174
+ status: httpStatus
175
+ });
176
+ if (isRetryableStatus(httpStatus))
177
+ shouldRetry = true;
178
+ }
179
+ } catch (e) {
180
+ logger.error({
181
+ code: "OPENAI_NETWORK_ERROR",
182
+ message: "OpenAI request threw",
183
+ originalErr: e instanceof Error ? e.message : String(e)
184
+ });
185
+ shouldRetry = true;
186
+ }
187
+ if (attempt < RETRY_DELAYS_MS.length && shouldRetry) {
188
+ logger.info({
189
+ attempt: attempt + 1,
190
+ code: "OPENAI_RETRY",
191
+ message: "Retrying OpenAI request after backoff",
192
+ nextBackoffMs: RETRY_DELAYS_MS[attempt],
193
+ status: httpStatus
194
+ });
195
+ await sleep(RETRY_DELAYS_MS[attempt]);
196
+ continue;
197
+ }
198
+ break;
199
+ }
200
+ logger.error({
201
+ code: "OPENAI_GIVE_UP",
202
+ message: "OpenAI chunk failed after retries"
203
+ });
204
+ return { kind: "failed" };
205
+ };
206
+ const translateChunk = async (chunk) => {
207
+ const result = await requestChunk(chunk);
208
+ if (result.kind === "ok")
209
+ return result.translated;
210
+ if (result.kind === "failed" || chunk.length === 1)
211
+ return null;
212
+ const half = Math.ceil(chunk.length / 2);
213
+ const left = await translateChunk(chunk.slice(0, half));
214
+ if (!left)
215
+ return null;
216
+ const right = await translateChunk(chunk.slice(half));
217
+ return right ? [...left, ...right] : null;
218
+ };
219
+ try {
220
+ const response = await mapLimit(chunkArray(texts, chunkLength), concurrency, translateChunk);
221
+ const translated = [];
222
+ for (const result of response) {
223
+ if (!result)
224
+ return { success: false };
225
+ translated.push(...result);
226
+ }
227
+ return {
228
+ success: true,
229
+ translatedTexts: translated
230
+ };
231
+ } catch (e) {
232
+ logger.error({
233
+ code: "OPENAI_UNEXPECTED",
234
+ message: "OpenAI resolve threw an unexpected error",
235
+ originalErr: e instanceof Error ? e.message : String(e)
236
+ });
237
+ return { success: false };
238
+ }
239
+ }
240
+ };
241
+ };
242
+
243
+ export { openAIResolver2 };