doxa-maps-cdn/server/utils/openrouter.ts
corsacca 7dfad0ee07 replace deepl with llm translation via openrouter
- translateText/translateTexts call OpenRouter chat-completions; glossary
  terms are prompt-injected from config/glossaries.ts, generated from the
  translation repo's TSVs by scripts/build-glossaries.ts
- model resolution: per-language override in config/languages.ts →
  translation.model site setting (new admin field) → TRANSLATION_MODEL env
  → anthropic/claude-opus-5, at low reasoning effort
- blank fragments are held back from the model, which drops them and breaks
  the one-translation-per-fragment count the caller maps results back on
- OpenRouter failures map to a typed error with a human-readable message;
  an unrecoverable one (no credits, bad key, rejected model) stops the run
  instead of repeating the same call for every remaining locale
- machine translations record source 'auto-translate'; 'deepl' stays
  readable for snapshots written before the switch
2026-09-07 16:30:48 +01:00

199 lines
8.2 KiB
TypeScript

/**
* OpenRouter Translation Client
*
* Translates text fragments with an LLM via the OpenRouter chat-completions
* API. Each request carries the target language's glossary and every fragment
* of the batch, and the model must return exactly one translation per fragment
* so the caller can map results back into structured content.
*/
import { getLanguage } from '~~/config/languages'
import { GLOSSARIES } from '~~/config/glossaries'
import { getSetting } from './site-settings'
const DEFAULT_TRANSLATION_MODEL = 'anthropic/claude-opus-5'
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
export function isOpenRouterConfigured(): boolean {
return Boolean(useRuntimeConfig().openrouterApiKey)
}
/**
* The OpenRouter model used to translate into a language. A per-language
* override in config/languages.ts wins; then the admin-managed
* `translation.model` site setting, the TRANSLATION_MODEL env var, and a code
* default — so adopting a newly released model is a setting, not a code edit.
*/
export async function getTranslationModel(targetLanguage?: string): Promise<string> {
if (targetLanguage) {
const override = getLanguage(targetLanguage)?.translationModel
if (override) return override
}
const stored = await getSetting('translation.model')
if (stored) return stored
return useRuntimeConfig().translationModel || DEFAULT_TRANSLATION_MODEL
}
/** Language name as used in prompts — the precise variant when the plain name is ambiguous. */
function promptLanguageName(code: string): string {
const lang = getLanguage(code)
return lang?.translationName || lang?.name || code
}
/** Whether a curated terminology glossary is injected for this language. */
export function hasGlossary(code: string): boolean {
return Boolean(GLOSSARIES[code]?.length)
}
function buildSystemPrompt(targetLanguage: string, sourceLanguage: string, fragmentCount: number): string {
const source = promptLanguageName(sourceLanguage)
const target = promptLanguageName(targetLanguage)
const glossary = GLOSSARIES[targetLanguage]
const glossaryBlock = glossary?.length
? `\nGlossary — always use these translations, inflected correctly for the surrounding grammar:\n${glossary.map(([s, t]) => `${s} → ${t}`).join('\n')}\n`
: ''
return `You are a professional translator for DOXA, a Christian mission organization mobilizing prayer for unreached people groups. Translate marketing and informational website copy from ${source} into ${target}.
Rules:
- Use natural, idiomatic ${target} in a warm but professional register suited to a public website.
- The numbered fragments are consecutive pieces of one document; formatting may split a sentence across fragments. Translate each fragment so that the concatenated result reads naturally.
- Return exactly one translation per fragment, in the same order. Never merge, split, reorder, or skip fragments, and add no commentary.
- If a fragment begins or ends with whitespace, preserve that whitespace.
- Preserve any markup, placeholders, or URLs exactly as they appear.
- Render names of people, places, and Scripture references using standard ${target} conventions.
${glossaryBlock}
Respond with a JSON object of the form {"translations": ["...", "..."]} containing exactly ${fragmentCount} strings.`
}
interface ChatCompletionResponse {
choices?: Array<{ message?: { content?: string } }>
error?: { message?: string }
}
/**
* Failure of an OpenRouter request. The message is written for the admin UI,
* and `retryable` says whether a later attempt could plausibly succeed —
* false for anything that needs a human (no credits, bad key, rejected model).
*/
export class OpenRouterError extends Error {
constructor(message: string, readonly status: number | null, readonly retryable: boolean) {
super(message)
this.name = 'OpenRouterError'
}
}
function providerMessage(body: string): string {
try {
const message = JSON.parse(body)?.error?.message
if (typeof message === 'string') return message
} catch {
// error bodies are not always JSON
}
return body.slice(0, 200)
}
function toOpenRouterError(status: number, body: string, model: string): OpenRouterError {
console.error(`[Translate] OpenRouter ${status} for ${model}: ${body.slice(0, 500)}`)
if (status === 402) {
return new OpenRouterError('OpenRouter account has no credits — add credits at https://openrouter.ai/settings/credits', status, false)
}
if (status === 401 || status === 403) {
return new OpenRouterError('OpenRouter rejected the API key — check OPENROUTER_API_KEY', status, false)
}
if (status === 400 || status === 404 || status === 422) {
return new OpenRouterError(`OpenRouter did not accept the request for model "${model}": ${providerMessage(body)}`, status, false)
}
return new OpenRouterError(`OpenRouter is temporarily unavailable (HTTP ${status}) — try again in a moment`, status, true)
}
function parseTranslations(content: string, expectedCount: number): string[] {
// Some models wrap JSON output in a markdown code fence despite json_object mode
const raw = content.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '')
const parsed = JSON.parse(raw)
const translations = parsed?.translations
if (!Array.isArray(translations) || translations.length !== expectedCount || !translations.every((t: unknown) => typeof t === 'string')) {
throw new Error(`Expected ${expectedCount} translated fragments, got ${Array.isArray(translations) ? translations.length : 'invalid output'}`)
}
return translations
}
/**
* Translate an array of text fragments in a single model request.
* Returns translations in the same order as the input.
*/
export async function openrouterTranslateTexts(
texts: string[],
targetLanguage: string,
sourceLanguage: string = 'en'
): Promise<string[]> {
if (texts.length === 0) return []
const config = useRuntimeConfig()
const apiKey = config.openrouterApiKey
if (!apiKey) {
throw new OpenRouterError('OpenRouter is not configured — set OPENROUTER_API_KEY', null, false)
}
const model = await getTranslationModel(targetLanguage)
const body = JSON.stringify({
model,
messages: [
{ role: 'system', content: buildSystemPrompt(targetLanguage, sourceLanguage, texts.length) },
{ role: 'user', content: JSON.stringify({ fragments: texts }) }
],
response_format: { type: 'json_object' },
// Translation is mechanical — the glossary and the output contract are both
// in the system prompt, so deep reasoning adds cost without adding accuracy.
// Low rather than off: with thinking disabled, Claude models can leak
// <thinking> tags into the response, which here would corrupt a fragment.
reasoning: { effort: 'low' }
})
console.log(`[Translate] ${model}: ${texts.length} fragments → ${targetLanguage}`)
// The fragment-count contract occasionally fails on a first attempt; one
// retry recovers those cases before surfacing an error to the caller.
let lastError: Error | undefined
for (let attempt = 1; attempt <= 2; attempt++) {
let response: Response
try {
response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Title': 'DOXA Marketing'
},
body
})
} catch (e: any) {
console.error(`[Translate] could not reach OpenRouter: ${e?.message}`)
throw new OpenRouterError('Could not reach OpenRouter — try again in a moment', null, true)
}
if (!response.ok) {
throw toOpenRouterError(response.status, await response.text(), model)
}
const data = await response.json() as ChatCompletionResponse
const content = data.choices?.[0]?.message?.content
if (!content) {
throw new OpenRouterError(`OpenRouter returned no translation${data.error?.message ? `: ${data.error.message}` : ''}`, response.status, true)
}
try {
return parseTranslations(content, texts.length)
} catch (e: any) {
lastError = e
console.warn(`[Translate] attempt ${attempt} failed for ${targetLanguage}: ${e?.message}`)
}
}
throw new OpenRouterError(`Translation into "${targetLanguage}" failed: ${lastError?.message}`, null, true)
}