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:
2026-09-20 20:13:28 +02:00
co-authored by Claude Opus 5
parent c430205bf2
commit f68ce1c3a5
5 changed files with 567 additions and 0 deletions
+195
View File
@@ -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 });
});
});