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,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