Files
dyolink/backend/src/modules/voice/openrouter.provider.spec.ts
Amin Mousavi c07f550e00 feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.

The two halves share a wrapper and nothing else. Add keeps its exact behaviour.

The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.

Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.

Fixes from review of this commit:

- mountedRef was set false on unmount and never re-armed, so under StrictMode
  the hook was permanently "unmounted" in dev and recording silently never
  started.
- onStart guarded only on `phase`, which does not change until getUserMedia
  resolves; a second click during the permission prompt orphaned the first
  MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
  Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
  a bare weekday carries no qualifier, and rejecting it discarded a real
  deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
  now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
  Safari's mp4/aac clips are not mislabelled.

Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30

198 lines
5.9 KiB
TypeScript

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: 'روکش پی‌اف‌ام' }],
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,
prosthesisDefaultType: 'pfm_crown',
prosthesisOverrides: [],
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' });
});
});