Skip to content

Commit be56f38

Browse files
committed
feat(i18n): prefetch translations through OpenRouter batch endpoints
Add a --batch mode to the translate script. A silent dry run collects every request the real run would send, those go to the model's :batch endpoint at half the live price, and the real run consumes each answer once before falling back to the live model for validation retries, truncation, and anything a batch did not answer within TRANSLATE_BATCH_WAIT_MINUTES. Batch-side failures never fail the run; they only cost the live price. The translate workflow now runs in batch mode.
1 parent fd2c28a commit be56f38

6 files changed

Lines changed: 445 additions & 16 deletions

File tree

‎.github/workflows/translate_docs.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,11 @@ jobs:
171171
if [ "${{ inputs.force }}" = "true" ]; then
172172
FLAGS="--force"
173173
fi
174-
npx tsx scripts/translate.ts $FLAGS
174+
# --batch sends every uncached block through the model's half-price
175+
# `:batch` endpoint first and runs only the leftovers live. It waits up to
176+
# TRANSLATE_BATCH_WAIT_MINUTES (default 180) so the live tail and the
177+
# commit still fit in the job's 6-hour limit.
178+
npx tsx scripts/translate.ts --batch $FLAGS
175179
176180
# `!cancelled()` so a translate step that failed part-way still commits the
177181
# pages it did finish; gated on the prepare step so a failure in checkout or

‎lib/i18n/batch.test.ts‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { createPrefetchedModel, prefetchTranslations, requestKey, type BatchAnswer } from './batch'
6+
import {
7+
buildTranslateRequest,
8+
TruncatedOutputError,
9+
type TranslateModel,
10+
type TranslateRequest,
11+
} from './engine'
12+
import { runTranslation } from './run'
13+
14+
const echoSource = (prompt: string) => prompt.split(/Translate the following[^\n]*:\n/).pop() ?? ''
15+
16+
function countingModel(): TranslateModel & { calls: number } {
17+
const m = {
18+
calls: 0,
19+
generate: async ({ prompt }: TranslateRequest) => {
20+
m.calls++
21+
return echoSource(prompt)
22+
},
23+
}
24+
return m
25+
}
26+
27+
const req = (text: string): TranslateRequest =>
28+
buildTranslateRequest({ text, locale: 'de', kind: 'block' })
29+
30+
function json(body: unknown, status = 200): Response {
31+
return new Response(JSON.stringify(body), { status })
32+
}
33+
34+
const baseOpts = {
35+
apiKey: 'k',
36+
model: 'anthropic/claude-haiku-5.5',
37+
chunkSize: 1000,
38+
waitMs: 60_000,
39+
pollMs: 1000,
40+
log: () => {},
41+
sleep: async () => {},
42+
}
43+
44+
describe('createPrefetchedModel', () => {
45+
it('serves a prefetched answer once, then falls back to the live model', async () => {
46+
const live = countingModel()
47+
const r = req('Hello')
48+
const model = createPrefetchedModel(
49+
new Map([[requestKey(r), { text: 'Hallo', finishReason: 'stop' }]]),
50+
live,
51+
)
52+
expect(await model.generate(r)).toBe('Hallo')
53+
expect(live.calls).toBe(0)
54+
// A validation retry of the same request must reach the live model.
55+
expect(await model.generate(r)).toBe('Hello')
56+
expect(live.calls).toBe(1)
57+
})
58+
59+
it('rejects a truncated batch answer like the live path does', async () => {
60+
const r = req('Hello')
61+
const model = createPrefetchedModel(
62+
new Map([[requestKey(r), { text: 'Hal', finishReason: 'length' }]]),
63+
countingModel(),
64+
)
65+
await expect(model.generate(r)).rejects.toBeInstanceOf(TruncatedOutputError)
66+
})
67+
})
68+
69+
describe('prefetchTranslations', () => {
70+
it('submits distinct requests with `requests` last and harvests completed answers', async () => {
71+
const a = req('One')
72+
const b = req('Two')
73+
const bodies: Record<string, unknown>[] = []
74+
let polls = 0
75+
const fakeFetch = (async (url: string, init?: RequestInit) => {
76+
if (init?.method === 'POST') {
77+
bodies.push(JSON.parse(String(init.body)))
78+
return json({ id: 'b1', status: 'validating' })
79+
}
80+
polls++
81+
if (polls === 1) return json({ id: 'b1', status: 'in_progress', results: null })
82+
return json({
83+
id: 'b1',
84+
status: 'completed',
85+
results: [
86+
{
87+
custom_id: requestKey(a),
88+
response: {
89+
status_code: 200,
90+
body: { choices: [{ message: { content: 'Eins' }, finish_reason: 'stop' }] },
91+
},
92+
error: null,
93+
},
94+
{ custom_id: requestKey(b), response: null, error: { message: 'boom' } },
95+
],
96+
})
97+
}) as typeof fetch
98+
99+
const answers = await prefetchTranslations([a, b, a], { ...baseOpts, fetch: fakeFetch })
100+
101+
expect(bodies).toHaveLength(1)
102+
expect(Object.keys(bodies[0]).at(-1)).toBe('requests')
103+
expect(bodies[0].model).toBe('anthropic/claude-haiku-5.5:batch')
104+
expect((bodies[0].requests as unknown[]).length).toBe(2)
105+
expect(answers.get(requestKey(a))).toEqual({ text: 'Eins', finishReason: 'stop' })
106+
// An errored result has no answer, so that request runs live.
107+
expect(answers.has(requestKey(b))).toBe(false)
108+
})
109+
110+
it('splits requests into chunks', async () => {
111+
let posts = 0
112+
const fakeFetch = (async (_url: string, init?: RequestInit) => {
113+
if (init?.method === 'POST') return json({ id: `b${++posts}`, status: 'validating' })
114+
return json({ id: 'x', status: 'completed', results: [] })
115+
}) as typeof fetch
116+
await prefetchTranslations([req('1'), req('2'), req('3')], {
117+
...baseOpts,
118+
chunkSize: 2,
119+
fetch: fakeFetch,
120+
})
121+
expect(posts).toBe(2)
122+
})
123+
124+
it('stops waiting at the deadline and returns what it has', async () => {
125+
let t = 0
126+
const fakeFetch = (async (_url: string, init?: RequestInit) =>
127+
init?.method === 'POST'
128+
? json({ id: 'b1', status: 'validating' })
129+
: json({ id: 'b1', status: 'in_progress', results: null })) as typeof fetch
130+
const answers = await prefetchTranslations([req('One')], {
131+
...baseOpts,
132+
fetch: fakeFetch,
133+
now: () => t,
134+
sleep: async (ms) => {
135+
t += ms
136+
},
137+
})
138+
expect(answers.size).toBe(0)
139+
expect(t).toBeGreaterThanOrEqual(baseOpts.waitMs)
140+
})
141+
142+
it('does not throw when a submit fails', async () => {
143+
const fakeFetch = (async () => json({ error: 'no :batch endpoint' }, 400)) as typeof fetch
144+
const answers = await prefetchTranslations([req('One')], { ...baseOpts, fetch: fakeFetch })
145+
expect(answers.size).toBe(0)
146+
})
147+
})
148+
149+
describe('batch prefetch with runTranslation', () => {
150+
let content: string
151+
let cache: string
152+
beforeEach(async () => {
153+
const base = await mkdtemp(join(tmpdir(), 'batch-'))
154+
content = join(base, 'docs')
155+
cache = join(base, 'cache')
156+
await mkdir(content, { recursive: true })
157+
await writeFile(
158+
join(content, 'index.mdx'),
159+
`---\ntitle: Hello\n---\n\n# Hello\n\nA paragraph.\n\nA paragraph.\n`,
160+
)
161+
await writeFile(join(content, 'meta.json'), JSON.stringify({ title: 'Docs', pages: ['index'] }))
162+
})
163+
164+
it('answers every request a real run makes, so nothing runs live', async () => {
165+
const opts = { contentDir: content, cacheDir: cache, locales: ['de', 'fr'] }
166+
const requests: TranslateRequest[] = []
167+
await runTranslation({
168+
...opts,
169+
model: countingModel(),
170+
dryRun: true,
171+
onMiss: (input) => requests.push(buildTranslateRequest(input)),
172+
})
173+
expect(requests.length).toBeGreaterThan(0)
174+
175+
const answers = new Map<string, BatchAnswer>()
176+
for (const r of requests)
177+
answers.set(requestKey(r), { text: echoSource(r.prompt), finishReason: 'stop' })
178+
179+
const live = countingModel()
180+
const stats = await runTranslation({ ...opts, model: createPrefetchedModel(answers, live) })
181+
expect(live.calls).toBe(0)
182+
expect(stats.translatedBlocks).toBeGreaterThan(0)
183+
expect(stats.skipped).toEqual([])
184+
})
185+
})

‎lib/i18n/batch.ts‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { createHash } from 'node:crypto'
2+
import {
3+
assertNotTruncated,
4+
TRANSLATE_MODEL_SETTINGS,
5+
type TranslateModel,
6+
type TranslateRequest,
7+
} from './engine'
8+
9+
/**
10+
* Batch prefetch for the translation pipeline. OpenRouter's `:batch` endpoints bill
11+
* at half the live rate but answer asynchronously (up to a 24h window), which does
12+
* not fit the per-block translate/validate/retry loop in run.ts. So the batch only
13+
* answers each request's first attempt: the run then consumes those answers and
14+
* falls back to the live model for everything else (validation retries, truncation,
15+
* requests a batch could not answer before the deadline). The run's caching,
16+
* validation, and give-up logic stay exactly as they are.
17+
*
18+
* https://openrouter.ai/docs/projects/docs/batch-quickstart
19+
*/
20+
21+
const BATCHES_URL = 'https://openrouter.ai/api/v1/batches'
22+
const TERMINAL = new Set(['completed', 'failed', 'expired', 'cancelled'])
23+
24+
export interface BatchAnswer {
25+
text: string
26+
finishReason: string
27+
}
28+
29+
/** Stable id for one request; doubles as the batch `custom_id`. */
30+
export function requestKey(req: Parameters<TranslateModel['generate']>[0]): string {
31+
return createHash('sha256')
32+
.update(JSON.stringify([req.system, req.prompt, req.maxOutputTokens]))
33+
.digest('hex')
34+
.slice(0, 32)
35+
}
36+
37+
/**
38+
* Serves each prefetched answer once, then defers to the live model. Once, so a
39+
* validation retry of the same request reaches the live model instead of being
40+
* handed the same rejected answer again.
41+
*/
42+
export function createPrefetchedModel(
43+
prefetched: Map<string, BatchAnswer>,
44+
live: TranslateModel,
45+
): TranslateModel {
46+
return {
47+
async generate(req) {
48+
const key = requestKey(req)
49+
const hit = prefetched.get(key)
50+
if (!hit) return live.generate(req)
51+
prefetched.delete(key)
52+
// Same contract as the live path: a length-limited answer is not a translation.
53+
assertNotTruncated(hit.finishReason, req.maxOutputTokens)
54+
return hit.text
55+
},
56+
}
57+
}
58+
59+
interface BatchObject {
60+
id: string
61+
status: string
62+
request_counts?: { total?: number; completed?: number; failed?: number }
63+
results?: Array<{
64+
custom_id: string
65+
response?: {
66+
status_code?: number
67+
body?: { choices?: Array<{ message?: { content?: unknown }; finish_reason?: unknown }> }
68+
} | null
69+
error?: unknown
70+
}> | null
71+
}
72+
73+
export interface PrefetchOptions {
74+
apiKey: string
75+
/** Live model slug; `:batch` is appended. */
76+
model: string
77+
/** Requests per submitted batch. */
78+
chunkSize: number
79+
/** Give up waiting after this long; unanswered requests run live. */
80+
waitMs: number
81+
pollMs: number
82+
log: (message: string) => void
83+
fetch?: typeof fetch
84+
sleep?: (ms: number) => Promise<void>
85+
now?: () => number
86+
}
87+
88+
/**
89+
* Submit every distinct request as OpenRouter batches, wait for them up to
90+
* `waitMs`, and return the answers keyed by `requestKey`. Never throws for a
91+
* batch-side failure: a request without an answer simply runs live, so the worst
92+
* case is the old cost, not a failed run.
93+
*/
94+
export async function prefetchTranslations(
95+
requests: TranslateRequest[],
96+
opts: PrefetchOptions,
97+
): Promise<Map<string, BatchAnswer>> {
98+
const doFetch = opts.fetch ?? fetch
99+
const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)))
100+
const now = opts.now ?? Date.now
101+
const headers = {
102+
Authorization: `Bearer ${opts.apiKey}`,
103+
'Content-Type': 'application/json',
104+
'HTTP-Referer': 'https://www.librechat.ai',
105+
'X-Title': 'LibreChat',
106+
}
107+
108+
const unique = new Map<string, TranslateRequest>()
109+
for (const req of requests) unique.set(requestKey(req), req)
110+
const answers = new Map<string, BatchAnswer>()
111+
if (unique.size === 0) return answers
112+
113+
const entries = [...unique]
114+
const pending: string[] = []
115+
for (let i = 0; i < entries.length; i += opts.chunkSize) {
116+
const chunk = entries.slice(i, i + opts.chunkSize)
117+
// Key order matters: OpenRouter rejects a body whose `requests` comes first.
118+
const body = {
119+
endpoint: '/v1/chat/completions',
120+
model: `${opts.model}:batch`,
121+
completion_window: '24h',
122+
requests: chunk.map(([key, req]) => ({
123+
custom_id: key,
124+
body: {
125+
messages: [
126+
{ role: 'system', content: req.system },
127+
{ role: 'user', content: req.prompt },
128+
],
129+
max_tokens: req.maxOutputTokens,
130+
...TRANSLATE_MODEL_SETTINGS,
131+
},
132+
})),
133+
}
134+
try {
135+
const res = await doFetch(BATCHES_URL, {
136+
method: 'POST',
137+
headers,
138+
body: JSON.stringify(body),
139+
})
140+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`)
141+
const created = (await res.json()) as BatchObject
142+
pending.push(created.id)
143+
opts.log(`submitted batch ${created.id} (${chunk.length} request(s))`)
144+
} catch (e) {
145+
opts.log(
146+
`batch submit failed, ${chunk.length} request(s) will run live: ${(e as Error).message}`,
147+
)
148+
}
149+
}
150+
151+
const deadline = now() + opts.waitMs
152+
while (pending.length > 0) {
153+
for (const id of [...pending]) {
154+
let batch: BatchObject
155+
try {
156+
const res = await doFetch(`${BATCHES_URL}/${id}`, { headers })
157+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
158+
batch = (await res.json()) as BatchObject
159+
} catch (e) {
160+
// A failed poll is not a failed batch; try again next round.
161+
opts.log(`batch ${id} poll failed: ${(e as Error).message}`)
162+
continue
163+
}
164+
if (!TERMINAL.has(batch.status)) continue
165+
pending.splice(pending.indexOf(id), 1)
166+
let harvested = 0
167+
for (const r of batch.results ?? []) {
168+
const choice = r.response?.status_code === 200 ? r.response.body?.choices?.[0] : undefined
169+
const text = choice?.message?.content
170+
if (typeof text !== 'string') continue
171+
answers.set(r.custom_id, {
172+
text,
173+
finishReason: typeof choice?.finish_reason === 'string' ? choice.finish_reason : 'stop',
174+
})
175+
harvested++
176+
}
177+
opts.log(`batch ${id} ${batch.status}: ${harvested} answer(s)`)
178+
}
179+
if (pending.length === 0) break
180+
if (now() >= deadline) {
181+
opts.log(
182+
`stopped waiting on ${pending.length} batch(es) after ${Math.round(opts.waitMs / 60000)} min; their requests will run live`,
183+
)
184+
break
185+
}
186+
await sleep(opts.pollMs)
187+
}
188+
return answers
189+
}

0 commit comments

Comments
 (0)