Skip to content

Commit ea943db

Browse files
committed
fix: harden OpenAI translation requests
1 parent 40e8653 commit ea943db

4 files changed

Lines changed: 178 additions & 36 deletions

File tree

scripts/i18n/config.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
1+
import type { OpenAI } from 'openai'
2+
13
export interface OutputLocale {
24
code: string
35
name: string
46
guidance?: string
57
}
68

7-
export type ReasoningEffort =
8-
| 'none'
9-
| 'low'
10-
| 'medium'
11-
| 'high'
12-
| 'xhigh'
13-
| 'max'
14-
159
export interface TranslationPipelineConfig {
1610
entry: string
1711
output: string
1812
model: string
19-
reasoningEffort: ReasoningEffort
13+
reasoningEffort: NonNullable<
14+
OpenAI.ChatCompletionCreateParams['reasoning_effort']
15+
>
2016
maxItemsPerRequest: number
2117
maxSourceCharsPerRequest: number
22-
localeConcurrency: number
18+
stateConcurrency: number
2319
requestConcurrency: number
2420
maxTranslationRounds: number
2521
pruneCountFloor: number
@@ -52,7 +48,7 @@ export const translationPipelineConfig: TranslationPipelineConfig = {
5248
reasoningEffort: 'high',
5349
maxItemsPerRequest: 40,
5450
maxSourceCharsPerRequest: 6000,
55-
localeConcurrency: 3,
51+
stateConcurrency: 3,
5652
requestConcurrency: 2,
5753
maxTranslationRounds: 3,
5854
pruneCountFloor: 25,

scripts/i18n/translate.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import OpenAI from 'openai'
22

3-
import type {
4-
OutputLocale,
5-
ReasoningEffort,
6-
TranslationPipelineConfig
7-
} from './config'
3+
import type { OutputLocale, TranslationPipelineConfig } from './config'
84
import { tokenErrors } from './protected-tokens'
95

106
export interface TranslationItem {
@@ -58,6 +54,8 @@ export function chunkItems(
5854
maxItems: number,
5955
maxSourceChars: number
6056
): TranslationItem[][] {
57+
// Character count is an initial batching heuristic; truncated responses are
58+
// recursively split before the translation run fails.
6159
const chunks: TranslationItem[][] = []
6260
let chunk: TranslationItem[] = []
6361
let chunkChars = 0
@@ -103,18 +101,29 @@ function parseBatchResponse(content: string): Record<string, string> {
103101
throw new Error('translation response is not a JSON object')
104102
}
105103
const record: Record<string, string> = {}
104+
const invalidIds: string[] = []
106105
for (const [key, value] of Object.entries(parsed)) {
107-
if (typeof value === 'string') record[key] = value
106+
if (typeof value === 'string') {
107+
record[key] = value
108+
} else {
109+
invalidIds.push(key)
110+
}
111+
}
112+
if (invalidIds.length > 0) {
113+
throw new Error(
114+
`translation response has non-string values for ids: ${invalidIds.join(', ')}`
115+
)
108116
}
109117
return record
110118
}
111119

112120
interface OpenAiTranslatorOptions {
113121
apiKey: string
114122
model: string
115-
reasoningEffort: ReasoningEffort
123+
reasoningEffort: TranslationPipelineConfig['reasoningEffort']
116124
glossary: string
117125
fetchFn?: typeof fetch
126+
onCompletion?: (completion: OpenAI.ChatCompletion) => void
118127
requestTimeoutMs?: number
119128
}
120129

@@ -127,7 +136,11 @@ export function createOpenAiTranslator(
127136
timeout: options.requestTimeoutMs ?? defaultRequestTimeoutMs,
128137
maxRetries: maxNetworkRetries
129138
})
130-
return async (locale, items) => {
139+
140+
async function translateBatch(
141+
locale: OutputLocale,
142+
items: TranslationItem[]
143+
): Promise<Record<string, string>> {
131144
let lastError = new Error('translation request was not attempted')
132145
for (let attempt = 0; attempt <= maxMalformedResponseRetries; attempt++) {
133146
const completion = await client.chat.completions.create({
@@ -142,12 +155,18 @@ export function createOpenAiTranslator(
142155
{ role: 'user', content: JSON.stringify({ items }) }
143156
]
144157
})
158+
options.onCompletion?.(completion)
145159
const choice = completion.choices[0]
146160
if (choice?.finish_reason === 'length') {
147-
lastError = new Error(
148-
'OpenAI response was truncated (finish_reason "length"); lower maxItemsPerRequest or maxSourceCharsPerRequest'
149-
)
150-
continue
161+
if (items.length === 1) {
162+
throw new Error(
163+
'OpenAI response was truncated (finish_reason "length"); lower maxItemsPerRequest or maxSourceCharsPerRequest'
164+
)
165+
}
166+
const splitIndex = Math.ceil(items.length / 2)
167+
const first = await translateBatch(locale, items.slice(0, splitIndex))
168+
const second = await translateBatch(locale, items.slice(splitIndex))
169+
return { ...first, ...second }
151170
}
152171
const content = choice?.message.content
153172
if (typeof content !== 'string') {
@@ -162,6 +181,8 @@ export function createOpenAiTranslator(
162181
}
163182
throw lastError
164183
}
184+
185+
return translateBatch
165186
}
166187

167188
export async function translateLocaleItems(

scripts/i18n/update-locales.test.ts

Lines changed: 94 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* (happy-dom defines window/navigator), and nothing here needs a DOM.
44
* @vitest-environment node
55
*/
6+
import type { OpenAI } from 'openai'
67
import { describe, expect, it, vi } from 'vitest'
78

89
import type { OutputLocale } from './config'
@@ -503,20 +504,39 @@ describe('createOpenAiTranslator', () => {
503504
context: 'main.json: greeting',
504505
source: 'Hello {name}',
505506
preserve: ['{name}']
507+
},
508+
{
509+
id: '2',
510+
context: 'main.json: farewell',
511+
source: 'Goodbye {name}',
512+
preserve: ['{name}']
506513
}
507514
]
508515

509-
const completion = (content: string, finishReason = 'stop') =>
516+
const completion = (
517+
content: string,
518+
finishReason = 'stop',
519+
usage?: OpenAI.CompletionUsage
520+
) =>
510521
new Response(
511522
JSON.stringify({
512-
choices: [{ finish_reason: finishReason, message: { content } }]
523+
choices: [{ finish_reason: finishReason, message: { content } }],
524+
usage
513525
}),
514526
{ status: 200, headers: { 'content-type': 'application/json' } }
515527
)
516528

517-
function translatorFor(responses: Response[]) {
529+
function translatorFor(
530+
responses: Response[],
531+
onCompletion?: (completion: OpenAI.ChatCompletion) => void
532+
) {
518533
let calls = 0
519-
const fetchFn: typeof fetch = async () => {
534+
const requestBodies: string[] = []
535+
const fetchFn: typeof fetch = async (_input, init) => {
536+
if (typeof init?.body !== 'string') {
537+
throw new Error('expected a JSON request body')
538+
}
539+
requestBodies.push(init.body)
520540
calls++
521541
return responses[calls - 1]
522542
}
@@ -525,20 +545,83 @@ describe('createOpenAiTranslator', () => {
525545
model: 'test-model',
526546
reasoningEffort: 'low',
527547
glossary: '',
528-
fetchFn
548+
fetchFn,
549+
onCompletion
529550
})
530-
return { translate, callCount: () => calls }
551+
return { translate, callCount: () => calls, requestBodies }
531552
}
532553

533-
it('retries truncated responses instead of failing on the partial JSON', async () => {
534-
const { translate, callCount } = translatorFor([
554+
it('splits a truncated batch instead of retrying it unchanged', async () => {
555+
const { translate, callCount, requestBodies } = translatorFor([
535556
completion('{"1": "Bonj', 'length'),
536-
completion('{"1": "Bonjour {name}"}')
557+
completion('{"1": "Bonjour {name}"}'),
558+
completion('{"2": "Au revoir {name}"}')
537559
])
538560
await expect(translate(locale, items)).resolves.toEqual({
539-
'1': 'Bonjour {name}'
561+
'1': 'Bonjour {name}',
562+
'2': 'Au revoir {name}'
563+
})
564+
expect(callCount()).toBe(3)
565+
expect(requestBodies[1].length).toBeLessThan(requestBodies[0].length)
566+
expect(requestBodies[2].length).toBeLessThan(requestBodies[0].length)
567+
expect(requestBodies[1]).toContain('main.json: greeting')
568+
expect(requestBodies[1]).not.toContain('main.json: farewell')
569+
expect(requestBodies[2]).not.toContain('main.json: greeting')
570+
expect(requestBodies[2]).toContain('main.json: farewell')
571+
})
572+
573+
it('does not retry a truncated single-item batch', async () => {
574+
const { translate, callCount } = translatorFor([
575+
completion('{"1": "Bonj', 'length')
576+
])
577+
await expect(translate(locale, items.slice(0, 1))).rejects.toThrow(
578+
'OpenAI response was truncated'
579+
)
580+
expect(callCount()).toBe(1)
581+
})
582+
583+
it('reports non-string response values with their ids', async () => {
584+
const malformed = () => completion('{"1": {"text": "Bonjour"}, "2": 42}')
585+
const { translate, callCount } = translatorFor([
586+
malformed(),
587+
malformed(),
588+
malformed(),
589+
malformed()
590+
])
591+
await expect(translate(locale, items)).rejects.toThrow(
592+
'translation response has non-string values for ids: 1, 2'
593+
)
594+
expect(callCount()).toBe(4)
595+
})
596+
597+
it('reports usage for every completed API request', async () => {
598+
const totalTokens: number[] = []
599+
const onCompletion = vi.fn((response: OpenAI.ChatCompletion) => {
600+
if (response.usage) totalTokens.push(response.usage.total_tokens)
540601
})
541-
expect(callCount()).toBe(2)
602+
const { translate } = translatorFor(
603+
[
604+
completion('{"1": "Bonj', 'length', {
605+
completion_tokens: 4,
606+
prompt_tokens: 10,
607+
total_tokens: 14
608+
}),
609+
completion('{"1": "Bonjour {name}"}', 'stop', {
610+
completion_tokens: 5,
611+
prompt_tokens: 7,
612+
total_tokens: 12
613+
}),
614+
completion('{"2": "Au revoir {name}"}', 'stop', {
615+
completion_tokens: 6,
616+
prompt_tokens: 8,
617+
total_tokens: 14
618+
})
619+
],
620+
onCompletion
621+
)
622+
await translate(locale, items)
623+
expect(onCompletion).toHaveBeenCalledTimes(3)
624+
expect(totalTokens).toEqual([14, 12, 14])
542625
})
543626

544627
it('fails immediately on non-retryable statuses', async () => {

scripts/i18n/update-locales.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
} from './protected-tokens'
3939
import type { TranslateBatch, TranslationItem } from './translate'
4040
import {
41+
chunkItems,
4142
createOpenAiTranslator,
4243
mapWithConcurrency,
4344
translateLocaleItems
@@ -468,27 +469,62 @@ async function run(argv: readonly string[]): Promise<void> {
468469
(count, plan) => count + plan.items.length,
469470
0
470471
)
472+
const initialBatchCount = [...translationPlans.values()].reduce(
473+
(count, plan) =>
474+
count +
475+
chunkItems(
476+
plan.items,
477+
config.maxItemsPerRequest,
478+
config.maxSourceCharsPerRequest
479+
).length,
480+
0
481+
)
482+
if (pendingTotal > 0) {
483+
print(
484+
`Translation preflight: ${pendingTotal} strings in ${initialBatchCount} initial batches across ${config.outputLocales.length} locales; retries and truncation splits can add requests.`
485+
)
486+
}
471487

472488
const apiKey = process.env.OPENAI_API_KEY
473489
if (pendingTotal > 0 && !apiKey) {
474490
throw new Error(
475491
`${pendingTotal} strings need translation but OPENAI_API_KEY is not set.`
476492
)
477493
}
494+
const usage = {
495+
completionTokens: 0,
496+
promptTokens: 0,
497+
reasoningTokens: 0,
498+
requests: 0,
499+
totalTokens: 0
500+
}
501+
const countedFetch: typeof fetch = async (input, init) => {
502+
usage.requests++
503+
return fetch(input, init)
504+
}
478505
const translateBatch: TranslateBatch = apiKey
479506
? createOpenAiTranslator({
480507
apiKey,
508+
fetchFn: countedFetch,
481509
model: config.model,
482510
reasoningEffort: config.reasoningEffort,
483-
glossary: config.glossary
511+
glossary: config.glossary,
512+
onCompletion: (completion) => {
513+
if (!completion.usage) return
514+
usage.completionTokens += completion.usage.completion_tokens
515+
usage.promptTokens += completion.usage.prompt_tokens
516+
usage.reasoningTokens +=
517+
completion.usage.completion_tokens_details?.reasoning_tokens ?? 0
518+
usage.totalTokens += completion.usage.total_tokens
519+
}
484520
})
485521
: async () => {
486522
throw new Error('No translator available')
487523
}
488524

489525
const outcomes = await mapWithConcurrency(
490526
states,
491-
config.localeConcurrency,
527+
config.stateConcurrency,
492528
async (
493529
state
494530
): Promise<
@@ -602,6 +638,12 @@ async function run(argv: readonly string[]): Promise<void> {
602638
)
603639
)
604640

641+
if (usage.requests > 0) {
642+
print(
643+
`OpenAI usage: ${usage.requests} HTTP requests; ${usage.promptTokens} input, ${usage.completionTokens} output (${usage.reasoningTokens} reasoning), ${usage.totalTokens} total tokens.`
644+
)
645+
}
646+
605647
if (failuresByFile.size > 0) {
606648
const details = [...failuresByFile.values()].flat()
607649
const persisted =

0 commit comments

Comments
 (0)