|
| 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 | +}) |
0 commit comments