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
135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|