- 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
199 lines
8.2 KiB
TypeScript
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)
|
|
}
|