Files
vliaudatandClaude Opus 5 f68ce1c3a5 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
2026-09-20 20:13:28 +02:00

135 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}