import { describe, expect, it } from 'vitest'; import { MAX_RETRY_WAIT_MS, OPENROUTER_URL, SLIP_PROMPT, stripCodeFences, transcribeImage, type TranscribeDeps, } from './transcribeImage'; const KEY = 'test-key-not-real-Zq7'; const IMAGE = Buffer.from('fake-image-bytes'); const MODEL_TEXT = '6379 100 MODELTEXT'; const ERROR_BODY = 'BODY-MAY-QUOTE-THE-INPUT'; type Reply = () => Response; const json = (body: unknown, status = 200, headers: Record = {}): Reply => () => new Response(JSON.stringify(body), { status, headers }); const answer = (content: unknown, finish: unknown = 'stop'): Reply => json({ choices: [{ finish_reason: finish, message: { content } }] }); const fail = (status: number, headers: Record = {}, error: unknown = { message: ERROR_BODY }): Reply => json({ error }, status, headers); const networkError: Reply = () => { throw new TypeError('fetch failed'); }; const htmlPage = (status: number): Reply => () => new Response('not json', { status }); function setup(...replies: Reply[]) { const calls: { url: string; init: RequestInit }[] = []; const waits: number[] = []; const logs: Record[] = []; let attempts = 0; const deps: TranscribeDeps = { apiKey: KEY, model: 'test/model:free', timeoutSec: 60, fetchImpl: (async (url: string, init: RequestInit) => { calls.push({ url, init }); const reply = replies[calls.length - 1]; if (!reply) throw new Error('more calls than expected'); return reply(); }) as unknown as typeof fetch, log: (e) => logs.push(e), sleep: async (ms) => { waits.push(ms); }, beforeAttempt: async () => { attempts++; }, }; return { deps, calls, waits, logs, attempts: () => attempts }; } const run = (s: ReturnType) => transcribeImage(IMAGE, 'image/jpeg', s.deps); describe('the request', () => { it('sends the prompt first, reasoning off, a data URL and the key in the header', async () => { const s = setup(answer(MODEL_TEXT)); await run(s); expect(s.calls).toHaveLength(1); const { url, init } = s.calls[0]!; expect(url).toBe(OPENROUTER_URL); expect(init.method).toBe('POST'); expect(init.redirect).toBe('error'); expect((init.headers as Record).authorization).toBe(`Bearer ${KEY}`); const body = JSON.parse(init.body as string); expect(body.model).toBe('test/model:free'); expect(body.temperature).toBe(0); expect(body.max_tokens).toBe(1500); expect(body.reasoning).toEqual({ effort: 'none' }); expect(body.messages[0].content).toEqual([ { type: 'text', text: SLIP_PROMPT }, { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${IMAGE.toString('base64')}` } }, ]); expect(init.body).not.toContain(KEY); }); }); describe('a good answer', () => { it('returns the text', async () => { expect(await run(setup(answer('6379 100\n9001 10')))).toEqual({ ok: true, text: '6379 100\n9001 10' }); }); it('strips a code fence but keeps commentary', async () => { expect(await run(setup(answer('```text\n6379 100\n```')))).toEqual({ ok: true, text: '6379 100' }); expect(await run(setup(answer('Here it is: 6379 100')))).toEqual({ ok: true, text: 'Here it is: 6379 100' }); }); }); describe('failures that are retried once', () => { const retried: [string, Reply][] = [ ['per-minute 429', fail(429, { 'x-ratelimit-limit': '20' })], ['a 429 with no limit header', fail(429)], ["a provider's own 429", fail(429, { 'x-ratelimit-limit': '1000' }, { metadata: { provider_code: 'x' } })], ['408', fail(408)], ['502', fail(502)], ['503', fail(503)], ['a 503 error page that is not JSON', htmlPage(503)], ['a network error', networkError], ['a warm-up reply with no choices', json({ choices: [] })], ['a warm-up reply with no finish reason', answer('', null)], ['a 200 that is not JSON', htmlPage(200)], ['503 inside a 200', json({ error: { code: 503, message: ERROR_BODY } })], ]; it.each(retried)('%s: the second try succeeds', async (_name, reply) => { const s = setup(reply, answer(MODEL_TEXT)); expect(await run(s)).toEqual({ ok: true, text: MODEL_TEXT }); expect(s.calls).toHaveLength(2); }); it.each(retried)('%s: fails twice, no third try', async (_name, reply) => { const s = setup(reply, reply); expect(await run(s)).toEqual({ ok: false, reason: 'unavailable' }); expect(s.calls).toHaveLength(2); }); it('asks the limiter before every attempt, retries included', async () => { const s = setup(fail(503), answer(MODEL_TEXT)); await run(s); expect(s.attempts()).toBe(2); }); it('waits for Retry-After, capped, and 5 s without a usable one', async () => { const given = setup(fail(503, { 'retry-after': '7' }), answer(MODEL_TEXT)); await run(given); expect(given.waits).toEqual([7000]); const capped = setup(fail(503, { 'retry-after': '500' }), answer(MODEL_TEXT)); await run(capped); expect(capped.waits).toEqual([MAX_RETRY_WAIT_MS]); const none = setup(fail(503), answer(MODEL_TEXT)); await run(none); expect(none.waits).toEqual([5000]); const date = setup(fail(503, { 'retry-after': 'Wed, 04 Nov 2026 10:00:00 GMT' }), answer(MODEL_TEXT)); await run(date); expect(date.waits).toEqual([5000]); }); }); describe('failures that are not retried', () => { const once: [string, Reply, string][] = [ ['daily 429 at the free limit', fail(429, { 'x-ratelimit-limit': '50' }), 'daily_limit'], ['daily 429 at the higher limit', fail(429, { 'x-ratelimit-limit': '1000' }), 'daily_limit'], ['daily 429 inside a 200', json({ error: { code: 429 } }, 200, { 'x-ratelimit-limit': '50' }), 'daily_limit'], ['400', fail(400), 'unreadable'], ['401', fail(401), 'unreadable'], ['402', fail(402), 'unreadable'], ['403', fail(403), 'unreadable'], ['422', fail(422), 'unreadable'], ['401 inside a 200', json({ error: { code: 401 } }), 'unreadable'], ['ran out of budget', answer('', 'length'), 'unreadable'], ['another finish reason, even with text', answer('6379 100', 'content_filter'), 'unreadable'], ['content that is not text', answer([{ type: 'text' }]), 'unreadable'], ['empty text', answer(''), 'no_writing'], ['null text', answer(null), 'no_writing'], ['only a fence', answer('```\n```'), 'no_writing'], ]; it.each(once)('%s', async (_name, reply, reason) => { const s = setup(reply); expect(await run(s)).toEqual({ ok: false, reason }); expect(s.calls).toHaveLength(1); expect(s.waits).toEqual([]); }); }); describe('logs', () => { it('never hold the key, the image, the model text or an error body', async () => { const replies: Reply[] = [ answer(MODEL_TEXT), fail(401), json({ error: { code: 401, message: ERROR_BODY } }), answer(MODEL_TEXT, 'length'), networkError, ]; for (const reply of replies) { const s = setup(reply, reply); await run(s); expect(s.logs.length).toBeGreaterThan(0); const logged = JSON.stringify(s.logs); for (const secret of [KEY, MODEL_TEXT, ERROR_BODY, IMAGE.toString('base64'), 'fetch failed']) { expect(logged).not.toContain(secret); } } }); it('record status, finish reason, error type and duration', async () => { const s = setup(answer(MODEL_TEXT)); await run(s); expect(s.logs[0]).toMatchObject({ status: 200, finish_reason: 'stop' }); expect(s.logs[0]).toHaveProperty('duration_ms'); const bad = setup(fail(401)); await run(bad); expect(bad.logs[0]).toMatchObject({ status: 401, error_type: 'http_error', level: 'warn' }); }); }); describe('stripCodeFences', () => { it('leaves plain text alone and handles an unclosed fence', () => { expect(stripCodeFences(' 6379 100 ')).toBe('6379 100'); expect(stripCodeFences('```\n6379 100')).toBe('6379 100'); }); });