/** * 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 { 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 { 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); }