import { OpenRouterAsrProvider, OpenRouterExtractionProvider, } from './openrouter.provider'; import { VoiceProviderError } from './voice.providers'; const CONFIG = { apiKey: 'test-key', baseUrl: 'https://openrouter.test/api/v1', model: 'm', }; const CATALOG = { treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }], prosthesisTypes: [ { code: 'pfm_crown', label: 'روکش پی‌اف‌ام', category: 'crown', subcategory: '', chartRegion: 'crown', stackGroup: 'restoration', }, ], prosthesisCategories: [{ code: 'crown', label: 'روکش‌ها' }], prosthesisSubcategories: [], labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }], }; type ChatBody = { temperature: number; provider: { require_parameters: boolean }; response_format: { type: string; json_schema: { strict: boolean } }; messages: { role: string; content: string }[]; }; function parseBody(spy: jest.Mock): ChatBody { const init = (spy.mock.calls[0] as [string, RequestInit])[1]; return JSON.parse(init.body as string) as ChatBody; } function mockFetch(response: { ok: boolean; status?: number; body?: unknown; text?: string; }) { const spy = jest.fn().mockResolvedValue({ ok: response.ok, status: response.status ?? (response.ok ? 200 : 500), json: () => Promise.resolve(response.body), text: () => Promise.resolve(response.text ?? ''), }); global.fetch = spy; return spy; } afterEach(() => { jest.restoreAllMocks(); }); describe('OpenRouterAsrProvider', () => { it('posts base64 JSON with the language hint, per the documented STT contract', async () => { const spy = mockFetch({ ok: true, body: { text: ' سلام ', usage: { seconds: 12, cost: 0.002 } }, }); const result = await new OpenRouterAsrProvider(CONFIG).transcribe( { data: 'BASE64', format: 'webm' }, 'fa', ); const [url, init] = spy.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://openrouter.test/api/v1/audio/transcriptions'); expect(init.headers).toMatchObject({ Authorization: 'Bearer test-key' }); expect(JSON.parse(init.body as string)).toEqual({ model: 'm', input_audio: { data: 'BASE64', format: 'webm' }, language: 'fa', }); expect(result.text).toBe('سلام'); expect(result.usage).toEqual({ seconds: 12, costUsd: 0.002 }); }); it('reports missing usage as null rather than zero', async () => { mockFetch({ ok: true, body: { text: 'x' } }); const result = await new OpenRouterAsrProvider(CONFIG).transcribe( { data: 'B', format: 'webm' }, 'en', ); expect(result.usage).toEqual({ seconds: null, costUsd: null }); }); it('raises a staged error without leaking the vendor body', async () => { // A 4xx can echo the request back, transcript included. mockFetch({ ok: false, status: 400, text: 'transcript: patient name here', }); const provider = new OpenRouterAsrProvider(CONFIG); await expect( provider.transcribe({ data: 'B', format: 'webm' }, 'fa'), ).rejects.toMatchObject({ name: 'VoiceProviderError', stage: 'asr', status: 400, }); await expect( provider.transcribe({ data: 'B', format: 'webm' }, 'fa'), ).rejects.not.toThrow(/patient name/); }); }); describe('OpenRouterExtractionProvider', () => { const wireContent = JSON.stringify({ treatmentType: 'prosthesis', teeth: [ { spoken: 'یک چهار', fdi: '14', arch: null, side: null, position: null }, ], connectedSpans: [], comment: null, prosthesis: [ { targets: [ { spoken: 'یک چهار', fdi: '14', arch: null, side: null, position: null, }, ], types: ['pfm_crown'], spoken: 'روکش پی‌اف‌ام روی ۱۴', }, ], labId: 'lab-1', labMatchExact: true, due: { kind: 'none', weekday: null, which: null, unit: null, amount: null, jy: null, jm: null, jd: null, y: null, m: null, d: null, }, }); it('constrains output with a strict JSON schema and a schema-honouring provider', async () => { const spy = mockFetch({ ok: true, body: { choices: [{ message: { content: wireContent } }], usage: { cost: 0.0008 }, }, }); const result = await new OpenRouterExtractionProvider(CONFIG).extract( 'روی دندان ۱۴ روکش', CATALOG, 'fa', ); const body = parseBody(spy); expect(body.response_format.type).toBe('json_schema'); expect(body.response_format.json_schema.strict).toBe(true); // Without require_parameters OpenRouter may route to a provider that treats the // schema as a hint and returns prose, failing parsing intermittently. expect(body.provider).toEqual({ require_parameters: true }); expect(body.temperature).toBe(0); expect(result.intent.treatmentType).toBe('prosthesis'); expect(result.intent.teeth[0]).toEqual({ kind: 'explicit', fdi: '14', spoken: 'یک چهار', }); expect(result.intent.due).toBeNull(); expect(result.costUsd).toBe(0.0008); }); it('sends the catalog codes and lab ids the model is allowed to choose from', async () => { const spy = mockFetch({ ok: true, body: { choices: [{ message: { content: wireContent } }] }, }); await new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'); const body = parseBody(spy); const system = body.messages[0].content; expect(system).toContain('prosthesis'); expect(system).toContain('pfm_crown'); expect(system).toContain('lab-1'); expect(body.messages[1]).toEqual({ role: 'user', content: 'x' }); }); it('fails loudly on unparseable content rather than passing rubbish downstream', async () => { mockFetch({ ok: true, body: { choices: [{ message: { content: 'I think tooth 14?' } }] }, }); await expect( new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'), ).rejects.toBeInstanceOf(VoiceProviderError); }); it('fails when the model returns no content at all', async () => { mockFetch({ ok: true, body: { choices: [] } }); await expect( new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'), ).rejects.toMatchObject({ stage: 'extraction' }); }); });