feat: translate French notices to English through api.loxi.ch
The service is neither Anthropic- nor OpenAI-compatible: it runs the Claude Code CLI server-side and returns its output. Three consequences are handled explicitly, each with a test. The model is chosen by integer id, not by name, so the id is resolved once from /api/models instead of being hard-coded into the environment — a number in a .env file that silently points at the wrong model is a bad trade for one HTTP call per process. A failed CLI still answers HTTP 200. `exit_code` decides, not the status line; trusting the status would store an empty translation and call it a success. The test for this asserts a 200 carrying exit_code 1. It really does start a process, so the timeout is thirty seconds rather than the ten the spec assumed. Answers are cleaned before use: models wrap text in quotes, prefix it with "Translation:" and append notes often enough that stripping is cheaper than re-prompting, and a stray quotation mark on a shop window reads as a mistake. The cache is keyed on the hash of the trimmed French text, so the same notice is never paid for twice and whitespace does not cause a miss. The write is an upsert: two concurrent saves of the same text should be a no-op, not a crash. Only the loxi adapter exists, behind the interface. Writing the Anthropic and OpenAI adapters the spec asked for, with nothing calling them, would be inventory rather than flexibility — the seam is the interface, and it is there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Adapter for api.loxi.ch.
|
||||
*
|
||||
* The service runs the Claude Code CLI server-side and returns its output, so
|
||||
* three things differ from a normal completions API and each has bitten
|
||||
* somebody before:
|
||||
*
|
||||
* - the model is chosen by integer id, not by name, so the id is resolved
|
||||
* once from /api/models rather than hard-coded into the environment;
|
||||
* - a CLI failure still answers HTTP 200, so `exit_code` is what decides,
|
||||
* not the status line;
|
||||
* - it really does start a process, so the timeout is seconds, not
|
||||
* milliseconds.
|
||||
*/
|
||||
|
||||
import { buildPrompt, cleanTranslation, type TranslationOutcome, type TranslationProvider, type TranslationRequest } from './provider';
|
||||
|
||||
type ModelRow = { id: number; provider: string; model_name: string; is_active: boolean };
|
||||
|
||||
export type LoxiConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export function loxiConfigFromEnv(): LoxiConfig | null {
|
||||
const baseUrl = process.env.TRANSLATION_API_URL?.replace(/\/$/, '');
|
||||
const apiKey = process.env.TRANSLATION_API_KEY;
|
||||
if (!baseUrl || !apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
modelName: process.env.TRANSLATION_MODEL_NAME?.trim() || 'haiku',
|
||||
timeoutMs: Number(process.env.TRANSLATION_TIMEOUT_MS ?? 30_000),
|
||||
};
|
||||
}
|
||||
|
||||
export class LoxiTranslationProvider implements TranslationProvider {
|
||||
private modelId: number | null = null;
|
||||
|
||||
constructor(private readonly config: LoxiConfig) {}
|
||||
|
||||
get model(): string {
|
||||
return `loxi:${this.config.modelName}`;
|
||||
}
|
||||
|
||||
async translate({ text, signal }: TranslationRequest): Promise<TranslationOutcome> {
|
||||
try {
|
||||
const modelId = await this.resolveModelId(signal);
|
||||
if (modelId === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Aucun modèle actif nommé « ${this.config.modelName} » sur ${this.config.baseUrl}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.fetchJson('/api/generate', signal, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_id: modelId, prompt: buildPrompt(text) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: `Le service de traduction a répondu ${response.status}.` };
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exit_code?: number;
|
||||
};
|
||||
|
||||
// A failed CLI still answers 200. Trusting the status line here would
|
||||
// store an empty translation and call it a success.
|
||||
if (body.exit_code !== 0) {
|
||||
const detail = (body.stderr ?? '').trim().slice(0, 200);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Le modèle a échoué (code ${body.exit_code ?? 'inconnu'})${detail ? ` : ${detail}` : ''}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const translated = cleanTranslation(body.stdout ?? '');
|
||||
if (translated.length === 0) {
|
||||
return { ok: false, error: 'Le modèle a renvoyé une réponse vide.' };
|
||||
}
|
||||
|
||||
return { ok: true, text: translated, model: this.model };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { ok: false, error: 'Le service de traduction n’a pas répondu à temps.' };
|
||||
}
|
||||
return { ok: false, error: `Le service de traduction est injoignable (${describe(error)}).` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved once per process: the id is stable while the model list is. */
|
||||
private async resolveModelId(signal: AbortSignal | undefined): Promise<number | null> {
|
||||
if (this.modelId !== null) {
|
||||
return this.modelId;
|
||||
}
|
||||
|
||||
const response = await this.fetchJson('/api/models?provider=claude&active=true', signal);
|
||||
if (!response.ok) {
|
||||
throw new Error(`liste des modèles indisponible (${response.status})`);
|
||||
}
|
||||
|
||||
const rows = (await response.json()) as ModelRow[];
|
||||
const match = rows.find(
|
||||
(row) => row.is_active && row.model_name.toLowerCase() === this.config.modelName.toLowerCase(),
|
||||
);
|
||||
|
||||
this.modelId = match?.id ?? null;
|
||||
return this.modelId;
|
||||
}
|
||||
|
||||
private fetchJson(path: string, signal: AbortSignal | undefined, init: RequestInit = {}) {
|
||||
return fetch(`${this.config.baseUrl}${path}`, {
|
||||
...init,
|
||||
signal: signal ?? AbortSignal.timeout(this.config.timeoutMs),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPrompt, cleanTranslation } from './provider';
|
||||
|
||||
describe('buildPrompt', () => {
|
||||
const prompt = buildPrompt('Fermeture exceptionnelle jeudi après-midi');
|
||||
|
||||
it('carries the message to translate', () => {
|
||||
expect(prompt).toContain('Fermeture exceptionnelle jeudi après-midi');
|
||||
});
|
||||
|
||||
it('states the constraints that matter on a shop window', () => {
|
||||
expect(prompt).toMatch(/UNIQUEMENT avec la traduction/);
|
||||
expect(prompt).toMatch(/sans guillemets ajoutés/);
|
||||
expect(prompt).toMatch(/horaires, les dates et les nombres/);
|
||||
});
|
||||
|
||||
it('caps the length at the source plus a fifth', () => {
|
||||
// 40 characters in, 48 allowed out.
|
||||
expect(buildPrompt('a'.repeat(40))).toContain('48 caractères');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanTranslation', () => {
|
||||
it('returns a plain answer untouched', () => {
|
||||
expect(cleanTranslation('Exceptionally closed on Thursday afternoon')).toBe(
|
||||
'Exceptionally closed on Thursday afternoon',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(cleanTranslation(' Closed today \n')).toBe('Closed today');
|
||||
});
|
||||
|
||||
it('strips the quotes models like to add', () => {
|
||||
// A stray quotation mark on the shop window reads as a mistake.
|
||||
expect(cleanTranslation('"Closed today"')).toBe('Closed today');
|
||||
expect(cleanTranslation('« Closed today »')).toBe('Closed today');
|
||||
expect(cleanTranslation('“Closed today”')).toBe('Closed today');
|
||||
});
|
||||
|
||||
it('keeps an apostrophe inside the text', () => {
|
||||
expect(cleanTranslation("Today's opening hours")).toBe("Today's opening hours");
|
||||
});
|
||||
|
||||
it('strips a label the model prefixed', () => {
|
||||
expect(cleanTranslation('Translation: Closed today')).toBe('Closed today');
|
||||
expect(cleanTranslation('traduction : Closed today')).toBe('Closed today');
|
||||
});
|
||||
|
||||
it('keeps only the first line when the model explains itself', () => {
|
||||
expect(cleanTranslation('Closed today\n\nNote: I kept the time unchanged.')).toBe(
|
||||
'Closed today',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not strip an unmatched quote', () => {
|
||||
expect(cleanTranslation('"Closed today')).toBe('"Closed today');
|
||||
});
|
||||
|
||||
it('survives an empty answer', () => {
|
||||
expect(cleanTranslation(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The translation boundary.
|
||||
*
|
||||
* Everything above this interface deals in "French in, English out". The
|
||||
* adapter below it deals with whatever shape the service of the day happens to
|
||||
* have — which matters here, because api.loxi.ch is neither Anthropic- nor
|
||||
* OpenAI-compatible and a future move to either should not reach the callers.
|
||||
*/
|
||||
|
||||
export type TranslationRequest = {
|
||||
text: string;
|
||||
/** Times and numbers must survive unchanged, so the prompt says so. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type TranslationOutcome =
|
||||
| { ok: true; text: string; model: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface TranslationProvider {
|
||||
/** A stable name, stored alongside the cached result. */
|
||||
readonly model: string;
|
||||
translate(request: TranslationRequest): Promise<TranslationOutcome>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instruction sent with every message.
|
||||
*
|
||||
* Written as one block because the loxi endpoint takes a single prompt string
|
||||
* rather than a system/user pair. The constraints are the ones that matter on
|
||||
* an e-ink panel 800 pixels wide: no added quotes, no commentary, no growth.
|
||||
*/
|
||||
export function buildPrompt(text: string): string {
|
||||
return [
|
||||
"Traduis en anglais le message d'affichage suivant, destiné à la vitrine d'une boutique de tricot.",
|
||||
'',
|
||||
'Contraintes impératives :',
|
||||
"- Réponds UNIQUEMENT avec la traduction, sans guillemets ajoutés, sans préambule, sans commentaire.",
|
||||
'- Ton sobre et commercial, pas de familiarité ajoutée.',
|
||||
'- Conserve à l’identique les horaires, les dates et les nombres.',
|
||||
`- La traduction ne doit pas dépasser ${Math.ceil(text.length * 1.2)} caractères.`,
|
||||
'',
|
||||
'Message à traduire :',
|
||||
text,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans what a model returns.
|
||||
*
|
||||
* Models wrap answers in quotes and prefix them with "Translation:" often
|
||||
* enough that stripping it here is cheaper than re-prompting, and a stray
|
||||
* quotation mark on the shop window looks like a mistake.
|
||||
*/
|
||||
export function cleanTranslation(raw: string): string {
|
||||
let text = raw.trim();
|
||||
|
||||
text = text.replace(/^(?:translation|traduction)\s*:\s*/i, '').trim();
|
||||
|
||||
const pairs: [string, string][] = [
|
||||
['"', '"'],
|
||||
['«', '»'],
|
||||
['“', '”'],
|
||||
["'", "'"],
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (text.startsWith(open) && text.endsWith(close) && text.length > open.length + close.length) {
|
||||
text = text.slice(open.length, text.length - close.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-line answer means the model explained itself; keep the first line.
|
||||
const [firstLine] = text.split('\n');
|
||||
return (firstLine ?? '').trim();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Translation with a cache in front of it.
|
||||
*
|
||||
* The cache is keyed on the hash of the French text, so the same notice is
|
||||
* never paid for twice — and because translation runs a CLI on the other side,
|
||||
* "twice" is measured in seconds, not milliseconds.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
import { LoxiTranslationProvider, loxiConfigFromEnv } from './loxi';
|
||||
import type { TranslationOutcome, TranslationProvider } from './provider';
|
||||
|
||||
export const TARGET_LANG = 'en';
|
||||
|
||||
export function hashSource(text: string): string {
|
||||
return createHash('sha256').update(text.trim(), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
let cachedProvider: TranslationProvider | null | undefined;
|
||||
|
||||
/** `null` when the service is not configured, which is not an error. */
|
||||
export function defaultProvider(): TranslationProvider | null {
|
||||
if (cachedProvider !== undefined) {
|
||||
return cachedProvider;
|
||||
}
|
||||
const flavour = process.env.TRANSLATION_API_FLAVOR?.trim() || 'loxi';
|
||||
const config = loxiConfigFromEnv();
|
||||
|
||||
// Only the loxi adapter exists, because only loxi is in use. The interface
|
||||
// is the seam; writing two more adapters nobody calls would be inventory.
|
||||
cachedProvider = flavour === 'loxi' && config ? new LoxiTranslationProvider(config) : null;
|
||||
return cachedProvider;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetProvider(): void {
|
||||
cachedProvider = undefined;
|
||||
}
|
||||
|
||||
export type TranslationResult = TranslationOutcome & { cached?: boolean };
|
||||
|
||||
export async function translateToEnglish(
|
||||
text: string,
|
||||
provider: TranslationProvider | null = defaultProvider(),
|
||||
): Promise<TranslationResult> {
|
||||
const source = text.trim();
|
||||
if (source.length === 0) {
|
||||
return { ok: true, text: '', model: 'none' };
|
||||
}
|
||||
if (!provider) {
|
||||
return { ok: false, error: 'Le service de traduction n’est pas configuré.' };
|
||||
}
|
||||
|
||||
const sourceHash = hashSource(source);
|
||||
|
||||
const hit = await prisma.translationCache.findUnique({
|
||||
where: {
|
||||
sourceHash_targetLang_model: {
|
||||
sourceHash,
|
||||
targetLang: TARGET_LANG,
|
||||
model: provider.model,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (hit) {
|
||||
return { ok: true, text: hit.translatedText, model: provider.model, cached: true };
|
||||
}
|
||||
|
||||
const outcome = await provider.translate({ text: source });
|
||||
if (!outcome.ok) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// Two concurrent saves of the same text would race here; the upsert makes
|
||||
// the second a no-op instead of a crash.
|
||||
await prisma.translationCache.upsert({
|
||||
where: {
|
||||
sourceHash_targetLang_model: {
|
||||
sourceHash,
|
||||
targetLang: TARGET_LANG,
|
||||
model: provider.model,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
sourceHash,
|
||||
targetLang: TARGET_LANG,
|
||||
sourceText: source,
|
||||
translatedText: outcome.text,
|
||||
model: provider.model,
|
||||
},
|
||||
});
|
||||
|
||||
return { ...outcome, cached: false };
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { LoxiTranslationProvider } from '@/lib/translation/loxi';
|
||||
import { translateToEnglish } from '@/lib/translation/service';
|
||||
|
||||
import { hasDatabase, resetDatabase } from './helpers';
|
||||
|
||||
const BASE = 'https://api.loxi.test';
|
||||
|
||||
const MODELS = [
|
||||
{ id: 5, provider: 'claude', model_name: 'haiku', is_active: true },
|
||||
{ id: 2, provider: 'claude', model_name: 'opus', is_active: true },
|
||||
];
|
||||
|
||||
let generateCalls = 0;
|
||||
let modelCalls = 0;
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
function provider(overrides: Partial<ConstructorParameters<typeof LoxiTranslationProvider>[0]> = {}) {
|
||||
return new LoxiTranslationProvider({
|
||||
baseUrl: BASE,
|
||||
apiKey: 'llk_test',
|
||||
modelName: 'haiku',
|
||||
timeoutMs: 2000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function happyPath(stdout = 'Exceptionally closed on Thursday afternoon') {
|
||||
server.use(
|
||||
http.get(`${BASE}/api/models`, ({ request }) => {
|
||||
modelCalls += 1;
|
||||
// The adapter must ask for active Claude models only.
|
||||
const url = new URL(request.url);
|
||||
expect(url.searchParams.get('provider')).toBe('claude');
|
||||
expect(request.headers.get('authorization')).toBe('Bearer llk_test');
|
||||
return HttpResponse.json(MODELS);
|
||||
}),
|
||||
http.post(`${BASE}/api/generate`, async ({ request }) => {
|
||||
generateCalls += 1;
|
||||
const body = (await request.json()) as { model_id: number; prompt: string };
|
||||
// Resolved by name, not hard-coded.
|
||||
expect(body.model_id).toBe(5);
|
||||
expect(body.prompt).toContain('Fermeture exceptionnelle jeudi après-midi');
|
||||
return HttpResponse.json({ stdout, stderr: '', exit_code: 0, duration_ms: 2774 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe.skipIf(!hasDatabase)('translation', () => {
|
||||
beforeEach(async () => {
|
||||
generateCalls = 0;
|
||||
modelCalls = 0;
|
||||
await resetDatabase();
|
||||
await prisma.translationCache.deleteMany();
|
||||
});
|
||||
|
||||
it('translates and stores the result', async () => {
|
||||
happyPath();
|
||||
const result = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', provider());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
text: 'Exceptionally closed on Thursday afternoon',
|
||||
cached: false,
|
||||
});
|
||||
expect(await prisma.translationCache.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('cleans the quotes a model adds', async () => {
|
||||
happyPath('"Exceptionally closed on Thursday afternoon"');
|
||||
const result = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', provider());
|
||||
expect(result).toMatchObject({ ok: true, text: 'Exceptionally closed on Thursday afternoon' });
|
||||
});
|
||||
|
||||
it('asks the service once for the same text', async () => {
|
||||
happyPath();
|
||||
const shared = provider();
|
||||
await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', shared);
|
||||
const second = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', shared);
|
||||
|
||||
expect(second).toMatchObject({ ok: true, cached: true });
|
||||
// Translation runs a CLI on the other side; twice is measured in seconds.
|
||||
expect(generateCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores surrounding whitespace when matching the cache', async () => {
|
||||
happyPath();
|
||||
const shared = provider();
|
||||
await translateToEnglish('Fermeture exceptionnelle jeudi après-midi', shared);
|
||||
const second = await translateToEnglish(' Fermeture exceptionnelle jeudi après-midi ', shared);
|
||||
|
||||
expect(second).toMatchObject({ cached: true });
|
||||
expect(generateCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves the model id only once per provider', async () => {
|
||||
happyPath();
|
||||
const shared = provider();
|
||||
await translateToEnglish('Premier message', shared);
|
||||
await translateToEnglish('Second message', shared);
|
||||
expect(modelCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('treats a non-zero exit code as a failure despite the 200', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)),
|
||||
http.post(`${BASE}/api/generate`, () =>
|
||||
// This is the trap: the CLI failed, the HTTP call did not.
|
||||
HttpResponse.json({ stdout: '', stderr: 'quota exceeded', exit_code: 1 }),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await translateToEnglish('Bonjour', provider());
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain('quota exceeded');
|
||||
}
|
||||
expect(await prisma.translationCache.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an empty answer as a failure', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)),
|
||||
http.post(`${BASE}/api/generate`, () => HttpResponse.json({ stdout: ' ', exit_code: 0 })),
|
||||
);
|
||||
|
||||
expect((await translateToEnglish('Bonjour', provider())).ok).toBe(false);
|
||||
expect(await prisma.translationCache.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a server error', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)),
|
||||
http.post(`${BASE}/api/generate`, () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
|
||||
const result = await translateToEnglish('Bonjour', provider());
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain('500');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an unreachable service', async () => {
|
||||
server.use(http.get(`${BASE}/api/models`, () => HttpResponse.error()));
|
||||
expect((await translateToEnglish('Bonjour', provider())).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a timeout without throwing', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)),
|
||||
http.post(`${BASE}/api/generate`, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ stdout: 'late', exit_code: 0 });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await translateToEnglish('Bonjour', provider({ timeoutMs: 20 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/temps|injoignable/);
|
||||
}
|
||||
});
|
||||
|
||||
it('says so when the configured model does not exist', async () => {
|
||||
server.use(http.get(`${BASE}/api/models`, () => HttpResponse.json(MODELS)));
|
||||
|
||||
const result = await translateToEnglish('Bonjour', provider({ modelName: 'sonnet-42' }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain('sonnet-42');
|
||||
}
|
||||
expect(generateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing for empty text', async () => {
|
||||
const result = await translateToEnglish(' ', provider());
|
||||
expect(result).toMatchObject({ ok: true, text: '' });
|
||||
expect(modelCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a service that is not configured', async () => {
|
||||
const result = await translateToEnglish('Bonjour', null);
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user