diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt
index e8a0412c75..3c17403a49 100644
--- a/.cspell-wordlist.txt
+++ b/.cspell-wordlist.txt
@@ -448,3 +448,4 @@ Premul
eurollm
doclayout
netp
+Uyghur
diff --git a/apps/nlp/app/_layout.tsx b/apps/nlp/app/_layout.tsx
index 7d637faa15..69520828e7 100644
--- a/apps/nlp/app/_layout.tsx
+++ b/apps/nlp/app/_layout.tsx
@@ -48,6 +48,13 @@ export default function Layout() {
title: 'Privacy Filter',
}}
/>
+
router.navigate('privacy-filter/')}>
Privacy Filter
+ router.navigate('translation/')}>
+ Translation
+
router.navigate('llm/')}>
LLM
diff --git a/apps/nlp/app/translation/index.tsx b/apps/nlp/app/translation/index.tsx
new file mode 100644
index 0000000000..e45b0ffc8c
--- /dev/null
+++ b/apps/nlp/app/translation/index.tsx
@@ -0,0 +1,303 @@
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ ScrollView,
+ StyleSheet,
+ TouchableOpacity,
+ KeyboardAvoidingView,
+ Platform,
+} from 'react-native';
+import { useTranslator, models, type nlp, type TranslatorModel } from 'react-native-executorch';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import { ModelStatus } from '../../components/ModelStatus';
+import { Button } from '../../components/Button';
+import { theme } from '../../theme';
+
+/* cspell:disable */
+const SAMPLES: Record = {
+ en: 'The weather is beautiful today. Shall we go for a walk in the park?\nI will bring something to drink.',
+ de: 'Das Wetter ist heute wunderschön. Sollen wir im Park spazieren gehen?\nIch bringe etwas zu trinken mit.',
+ es: 'Hoy hace un tiempo precioso. ¿Vamos a dar un paseo por el parque?\nYo llevaré algo de beber.',
+ fr: 'Il fait très beau aujourd’hui. On va se promener dans le parc ?\nJ’apporterai quelque chose à boire.',
+ pl: 'Dzisiaj jest piękna pogoda. Może pójdziemy na spacer do parku?\nPrzyniosę coś do picia.',
+ zh: '今天天气真好。我们去公园散步好吗?\n我会带点喝的。',
+ ja: '今日はとても良い天気です。公園に散歩に行きませんか?\n飲み物を持っていきます。',
+};
+/* cspell:enable */
+
+type Pair = { label: string; languages: nlp.TranslationLanguages };
+const pair = (sourceLanguage: string, targetLanguage: string): Pair => ({
+ label: `${sourceLanguage.toUpperCase()} → ${targetLanguage.toUpperCase()}`,
+ languages: { sourceLanguage, targetLanguage },
+});
+
+// Opus-MT ships one model per pair; EuroLLM and Hy-MT2 translate every pair with one.
+const OPUS_MT: { pair: Pair; model: TranslatorModel }[] = [
+ { pair: pair('en', 'de'), model: models.translation.OPUS_MT_EN_DE.DEFAULT },
+ { pair: pair('de', 'en'), model: models.translation.OPUS_MT_DE_EN.DEFAULT },
+ { pair: pair('en', 'es'), model: models.translation.OPUS_MT_EN_ES.DEFAULT },
+ { pair: pair('es', 'en'), model: models.translation.OPUS_MT_ES_EN.DEFAULT },
+ { pair: pair('en', 'fr'), model: models.translation.OPUS_MT_EN_FR.DEFAULT },
+ { pair: pair('fr', 'en'), model: models.translation.OPUS_MT_FR_EN.DEFAULT },
+];
+const EUROLLM_PAIRS = [...OPUS_MT.map((m) => m.pair), pair('en', 'pl'), pair('pl', 'en')];
+const HY_MT2_PAIRS = [
+ ...EUROLLM_PAIRS,
+ pair('en', 'zh'),
+ pair('zh', 'en'),
+ pair('en', 'ja'),
+ pair('ja', 'en'),
+];
+
+const FAMILIES = [
+ {
+ label: 'Opus-MT',
+ description:
+ 'One small model per language pair. Translates a sentence at a time and streams the result as it is decoded.',
+ pairs: OPUS_MT.map((m) => m.pair),
+ modelFor: (i: number) => OPUS_MT[i]!.model,
+ },
+ {
+ label: 'EuroLLM',
+ description:
+ 'One 1.7 GB model for 35 languages, so switching the pair does not reload it. Translates a line at a time.',
+ pairs: EUROLLM_PAIRS,
+ modelFor: () => models.translation.EUROLLM_1_7B.DEFAULT as TranslatorModel,
+ },
+ {
+ label: 'Hy-MT2',
+ description:
+ 'One 1.2 GB model for 38 languages and dialects, strongest on Chinese. Translates a line at a time.',
+ pairs: HY_MT2_PAIRS,
+ modelFor: () => models.translation.HY_MT2_1_8B.DEFAULT as TranslatorModel,
+ },
+];
+
+function TranslationContent() {
+ const [family, setFamily] = useState(0);
+ const [selected, setSelected] = useState(0);
+ const { pairs, modelFor, description } = FAMILIES[family]!;
+ const active = pairs[selected]!;
+ const { isReady, downloadProgress, error, translate, translateStop } = useTranslator(
+ modelFor(selected)
+ );
+
+ const [text, setText] = useState(SAMPLES[active.languages.sourceLanguage] ?? '');
+ const [output, setOutput] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [runError, setRunError] = useState(null);
+ const [elapsedMs, setElapsedMs] = useState(null);
+
+ const select = (nextFamily: number, nextPair: number) => {
+ if (nextFamily === family && nextPair === selected) return;
+ translateStop?.();
+ setFamily(nextFamily);
+ setSelected(nextPair);
+ const source = FAMILIES[nextFamily]!.pairs[nextPair]!.languages.sourceLanguage;
+ setText(SAMPLES[source] ?? '');
+ setOutput('');
+ setRunError(null);
+ setElapsedMs(null);
+ };
+
+ const run = async () => {
+ if (!translate || !text.trim()) return;
+ setBusy(true);
+ setRunError(null);
+ setOutput('');
+ setElapsedMs(null);
+ try {
+ const startedAt = Date.now();
+ const result = await translate(text, active.languages, (piece) =>
+ setOutput((prev) => prev + piece)
+ );
+ setElapsedMs(Date.now() - startedAt);
+ setOutput(result);
+ } catch (e: any) {
+ const msg = e?.message ?? String(e);
+ if (!/disposed/i.test(msg)) setRunError(msg);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const chips = (labels: string[], current: number, onPress: (i: number) => void) => (
+
+ {labels.map((label, i) => (
+ onPress(i)}
+ >
+ {label}
+
+ ))}
+
+ );
+
+ return (
+
+
+
+ Translation
+ {description}
+
+ Model
+ {chips(
+ FAMILIES.map((f) => f.label),
+ family,
+ (i) => select(i, 0)
+ )}
+
+ Language pair
+ {chips(
+ pairs.map((p) => p.label),
+ selected,
+ (i) => select(family, i)
+ )}
+
+
+
+
+ {runError && (
+
+ {runError}
+
+ )}
+
+
+ Text to translate
+
+
+
+
+ {elapsedMs !== null && Time: {elapsedMs} ms}
+
+
+ {output !== '' && (
+
+ Translation
+ {output}
+
+ )}
+
+
+ );
+}
+
+export default function TranslationScreen() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+ container: { flex: 1, backgroundColor: theme.colors.background },
+ content: { padding: theme.spacing.large, paddingBottom: 40 },
+ card: {
+ backgroundColor: theme.colors.cardBackground,
+ borderRadius: theme.radius.large,
+ padding: 20,
+ marginBottom: 20,
+ borderWidth: 1,
+ borderColor: theme.colors.lightBorder,
+ },
+ cardTitle: {
+ fontSize: theme.typography.title.fontSize,
+ fontWeight: theme.typography.title.fontWeight,
+ color: theme.colors.strongPrimary,
+ marginBottom: 8,
+ },
+ cardDescription: {
+ fontSize: 14,
+ color: theme.colors.textMuted,
+ lineHeight: 20,
+ marginBottom: 16,
+ },
+ fieldLabel: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: theme.colors.textPlaceholder,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ marginBottom: 8,
+ },
+ chipRow: { gap: 8, paddingBottom: 4, marginBottom: 12 },
+ chip: {
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ borderRadius: 20,
+ backgroundColor: '#f1f3f5',
+ borderWidth: 1,
+ borderColor: theme.colors.lightBorder,
+ },
+ chipActive: {
+ backgroundColor: theme.colors.strongPrimary,
+ borderColor: theme.colors.strongPrimary,
+ },
+ chipText: { fontSize: 13, fontWeight: '600', color: theme.colors.textMuted },
+ chipTextActive: { color: '#fff' },
+ sectionTitle: { fontSize: 16, fontWeight: '700', color: '#212529', marginBottom: 12 },
+ input: {
+ backgroundColor: '#f1f3f5',
+ borderRadius: theme.radius.small,
+ padding: 12,
+ fontSize: 14,
+ color: '#212529',
+ marginBottom: 16,
+ minHeight: 140,
+ textAlignVertical: 'top',
+ borderWidth: 1,
+ borderColor: theme.colors.lightBorder,
+ },
+ buttonRow: { flexDirection: 'row', gap: theme.spacing.small },
+ statsText: {
+ fontSize: 13,
+ color: theme.colors.textPlaceholder,
+ marginTop: 12,
+ textAlign: 'center',
+ },
+ errorContainer: {
+ backgroundColor: theme.colors.errorBackground,
+ padding: 12,
+ borderRadius: theme.radius.small,
+ marginBottom: 20,
+ },
+ errorText: { color: theme.colors.errorText, fontSize: 14, textAlign: 'center' },
+ outputText: { fontSize: 15, lineHeight: 22, color: '#212529' },
+});
diff --git a/docs/docs/02-extensions/natural-language/07-translation.md b/docs/docs/02-extensions/natural-language/07-translation.md
new file mode 100644
index 0000000000..154e91c9d2
--- /dev/null
+++ b/docs/docs/02-extensions/natural-language/07-translation.md
@@ -0,0 +1,147 @@
+---
+title: Translation
+slug: /extensions/translation
+description: 'Translate text between languages fully on-device in React Native with Opus-MT, EuroLLM and Hy-MT2 models.'
+keywords:
+ [
+ react native,
+ translation,
+ machine translation,
+ opus-mt,
+ marian,
+ eurollm,
+ hy-mt2,
+ mobile ml,
+ on-device ai,
+ ]
+---
+
+# Translation
+
+Translation models turn text in one language into another without sending it off the device. Two kinds are available behind the same API:
+
+- [Opus-MT](https://github.com/Helsinki-NLP/Opus-MT): small (~110 MB) and fast, one model per language pair, such as English to German.
+- [EuroLLM-1.7B-Instruct](https://huggingface.co/utter-project/EuroLLM-1.7B-Instruct): one 1.7 GB model that translates between any two of its 35 languages.
+- [Hy-MT2-1.8B](https://huggingface.co/tencent/Hy-MT2-1.8B): one 1.2 GB model that translates between any two of its 38 languages and dialects, and is stronger than EuroLLM on Chinese.
+
+## Quick Start
+
+The [`useTranslator`](../../06-api-reference/functions/useTranslator.md) hook downloads the `.pte` model and tokenizer, loads the pipeline, and manages its lifecycle. Every call names its language pair as ISO 639-1 codes:
+
+```tsx
+import { models, useTranslator } from 'react-native-executorch';
+
+function MyComponent() {
+ const translator = useTranslator(models.translation.OPUS_MT_EN_DE.DEFAULT);
+
+ // Hook state:
+ // translator.isReady — true once model and tokenizer are loaded
+ // translator.downloadProgress — 0 to 100 download progress
+ // translator.error — Error instance if download or load failed
+
+ const handleTranslate = async (text: string) => {
+ if (!translator.isReady || !translator.translate) return;
+
+ const german = await translator.translate(text, { sourceLanguage: 'en', targetLanguage: 'de' });
+ console.log(german);
+ };
+}
+```
+
+An Opus-MT model accepts only its own pair. With `models.translation.EUROLLM_1_7B.DEFAULT` or `models.translation.HY_MT2_1_8B.DEFAULT`, the pair can change on every call without reloading the model.
+
+## Streaming
+
+Pass a callback to receive the translation as it is decoded. Each call gets the newly decoded piece of text; joined together, the pieces equal the returned translation.
+
+```typescript
+let partial = '';
+const result = await translator.translate('How are you? I am fine.', languages, (piece) => {
+ partial += piece;
+ setText(partial);
+});
+```
+
+Call [`translateStop()`](../../06-api-reference/react-native-executorch/namespaces/nlp/type-aliases/Translator.md#translatestop) to cancel; [`translate()`](../../06-api-reference/react-native-executorch/namespaces/nlp/type-aliases/Translator.md#translate) then resolves with the text translated so far.
+
+## How Input Is Processed
+
+Line breaks are kept in the output for both kinds of model.
+
+- **Opus-MT** models are trained on single sentences, so the input is split after sentence-ending punctuation (`.`, `!`, `?`) and at line breaks, and each sentence is translated on its own. A single sentence longer than the model's maximum source length (256 tokens) is truncated, and one sentence translates to at most 256 tokens.
+- **EuroLLM** and **Hy-MT2** translate each line on their own with the model card's prompt, decoding greedily. A line and its translation together must fit the 1024-token context.
+
+EuroLLM supports Arabic (`ar`), Bulgarian (`bg`), Catalan (`ca`), Chinese (`zh`), Croatian (`hr`), Czech (`cs`), Danish (`da`), Dutch (`nl`), English (`en`), Estonian (`et`), Finnish (`fi`), French (`fr`), Galician (`gl`), German (`de`), Greek (`el`), Hindi (`hi`), Hungarian (`hu`), Irish (`ga`), Italian (`it`), Japanese (`ja`), Korean (`ko`), Latvian (`lv`), Lithuanian (`lt`), Maltese (`mt`), Norwegian (`no`), Polish (`pl`), Portuguese (`pt`), Romanian (`ro`), Russian (`ru`), Slovak (`sk`), Slovenian (`sl`), Spanish (`es`), Swedish (`sv`), Turkish (`tr`) and Ukrainian (`uk`).
+
+Hy-MT2 supports Arabic (`ar`), Bengali (`bn`), Burmese (`my`), Cantonese (`yue`), Chinese (`zh`), Traditional Chinese (`zh-Hant`), Czech (`cs`), Dutch (`nl`), English (`en`), Filipino (`tl`), French (`fr`), German (`de`), Gujarati (`gu`), Hebrew (`he`), Hindi (`hi`), Indonesian (`id`), Italian (`it`), Japanese (`ja`), Kazakh (`kk`), Khmer (`km`), Korean (`ko`), Malay (`ms`), Marathi (`mr`), Mongolian (`mn`), Persian (`fa`), Polish (`pl`), Portuguese (`pt`), Russian (`ru`), Spanish (`es`), Tamil (`ta`), Telugu (`te`), Thai (`th`), Tibetan (`bo`), Turkish (`tr`), Ukrainian (`uk`), Urdu (`ur`), Uyghur (`ug`) and Vietnamese (`vi`).
+
+## Imperative API
+
+For use outside React components, create the pipeline with [`createMarianTranslator`](../../06-api-reference/functions/createMarianTranslator.md) or [`createLLMTranslator`](../../06-api-reference/functions/createLLMTranslator.md):
+
+```typescript
+import { createLLMTranslator, download, models } from 'react-native-executorch';
+
+const model = await download(models.translation.EUROLLM_1_7B.DEFAULT);
+const translator = await createLLMTranslator(model);
+
+try {
+ console.log(
+ await translator.translate('The weather is nice today.', {
+ sourceLanguage: 'en',
+ targetLanguage: 'pl',
+ })
+ );
+} finally {
+ translator.dispose();
+}
+```
+
+[`translateWorklet`](../../06-api-reference/type-aliases/MarianTranslator.md#translateworklet) runs the Opus-MT pipeline synchronously on a worklet runtime. See [Worklets & Threading](../../03-core-and-advanced/06-worklets-and-threading.md).
+
+## Available Models
+
+The models are available in [`models.translation`](../../06-api-reference/variables/models.md#translation):
+
+| Model | Direction | Size | Supported Backends |
+| :-------------- | :---------------------- | :------------------ | :---------------------------- |
+| `OPUS_MT_EN_DE` | English → German | 107 MB | XNNPACK (CPU) |
+| `OPUS_MT_DE_EN` | German → English | 107 MB | XNNPACK (CPU) |
+| `OPUS_MT_EN_ES` | English → Spanish | 114 MB | XNNPACK (CPU) |
+| `OPUS_MT_ES_EN` | Spanish → English | 114 MB | XNNPACK (CPU) |
+| `OPUS_MT_EN_FR` | English → French | 108 MB | XNNPACK (CPU) |
+| `OPUS_MT_FR_EN` | French → English | 108 MB | XNNPACK (CPU) |
+| `EUROLLM_1_7B` | Any of its 35 languages | 1.7 GB | XNNPACK (CPU), MLX (iOS, GPU) |
+| `HY_MT2_1_8B` | Any of its 38 languages | 1.2 GB (MLX 1.8 GB) | XNNPACK (CPU), MLX (iOS, GPU) |
+
+:::info License
+`OPUS_MT_EN_DE` is licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/) and requires attribution to the [Helsinki-NLP Opus-MT](https://huggingface.co/Helsinki-NLP/opus-mt-en-de) project. The other Opus-MT pairs, EuroLLM and Hy-MT2 are Apache-2.0.
+:::
+
+## API Reference
+
+### Hooks & Pipelines
+
+- [`useTranslator()`](../../06-api-reference/functions/useTranslator.md) — React hook for translation model downloading, state, and lifecycle.
+- [`createMarianTranslator()`](../../06-api-reference/functions/createMarianTranslator.md) — Imperative factory for Opus-MT translation pipelines.
+- [`createLLMTranslator()`](../../06-api-reference/functions/createLLMTranslator.md) — Imperative factory for EuroLLM and Hy-MT2 translation pipelines.
+
+### Types
+
+- [`nlp.TranslationLanguages`](../../06-api-reference/react-native-executorch/namespaces/nlp/type-aliases/TranslationLanguages.md) — Source and target language of one call.
+- [`TranslatorModel`](../../06-api-reference/type-aliases/TranslatorModel.md) — Any translation model configuration.
+- [`MarianTranslator`](../../06-api-reference/type-aliases/MarianTranslator.md) — Opus-MT runner, which adds [`translateWorklet`](../../06-api-reference/type-aliases/MarianTranslator.md#translateworklet).
+- [`MarianTranslatorModel`](../../06-api-reference/type-aliases/MarianTranslatorModel.md) — Opus-MT configuration with model path, tokenizer path, and language pair.
+- [`LLMTranslator`](../../06-api-reference/type-aliases/LLMTranslator.md) — EuroLLM and Hy-MT2 runner.
+- [`LLMTranslatorModel`](../../06-api-reference/type-aliases/LLMTranslatorModel.md) — EuroLLM or Hy-MT2 configuration with model, tokenizer, and tokenizer config paths and the translation prompt.
+
+### Model Presets
+
+- [`models.translation`](../../06-api-reference/variables/models.md#translation) — Pre-configured translation models registry.
+
+:::info Source Code
+View the implementation on GitHub:
+
+- [`src/extensions/nlp/tasks/marianTranslation.ts` ↗](https://github.com/software-mansion/react-native-executorch/blob/main/packages/react-native-executorch/src/extensions/nlp/tasks/marianTranslation.ts)
+- [`src/extensions/llm/tasks/llmTranslation.ts` ↗](https://github.com/software-mansion/react-native-executorch/blob/main/packages/react-native-executorch/src/extensions/llm/tasks/llmTranslation.ts)
+ :::
diff --git a/docs/docs/03-core-and-advanced/08-native-libraries.md b/docs/docs/03-core-and-advanced/08-native-libraries.md
index 2fa7fb6ac4..680a93e368 100644
--- a/docs/docs/03-core-and-advanced/08-native-libraries.md
+++ b/docs/docs/03-core-and-advanced/08-native-libraries.md
@@ -39,11 +39,11 @@ Add a `react-native-executorch` block to your `package.json`:
### Options
-| Option | Purpose | Accepted values |
-| ---------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `features` | High-level tasks — each automatically expands to the backends and libraries it needs | `"classification"`, `"imageEmbeddings"`, `"inpainting"`, `"instanceSegmentation"`, `"keypointDetection"`, `"llm"`, `"multimodalLLM"`, `"objectDetection"`, `"ocr"`, `"privacyFilter"`, `"segmentAnything"`, `"semanticSegmentation"`, `"speechToText"`, `"styleTransfer"`, `"textEmbeddings"`, `"textToImage"`, `"textToSpeech"`, `"tokenizer"`, `"vad"`, `"verticalOCR"` |
-| `backends` | Hardware acceleration backends directly | `"xnnpack"`, `"coreml"`, `"mlx"`, `"vulkan"` |
-| `libs` | Extra native C++ libraries | `"opencv"`, `"phonemis"` |
+| Option | Purpose | Accepted values |
+| ---------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `features` | High-level tasks — each automatically expands to the backends and libraries it needs | `"classification"`, `"imageEmbeddings"`, `"inpainting"`, `"instanceSegmentation"`, `"keypointDetection"`, `"llm"`, `"multimodalLLM"`, `"objectDetection"`, `"ocr"`, `"privacyFilter"`, `"segmentAnything"`, `"semanticSegmentation"`, `"speechToText"`, `"styleTransfer"`, `"textEmbeddings"`, `"textToImage"`, `"textToSpeech"`, `"tokenizer"`, `"translation"`, `"vad"`, `"verticalOCR"` |
+| `backends` | Hardware acceleration backends directly | `"xnnpack"`, `"coreml"`, `"mlx"`, `"vulkan"` |
+| `libs` | Extra native C++ libraries | `"opencv"`, `"phonemis"` |
The three lists are merged, so you can pair high-level `features` with specific `backends` or `libs`. Re-run your package manager install after editing.
@@ -90,6 +90,7 @@ Specifying a task under `features` is shorthand: it automatically expands to the
| `speechToText` | xnnpack, coreml, mlx, vulkan | — |
| `textToSpeech` | xnnpack, coreml, mlx, vulkan | phonemis |
| `vad` | xnnpack | — |
+| `translation` | xnnpack, mlx | — |
| `textEmbeddings` | xnnpack, coreml, mlx, vulkan | — |
| `imageEmbeddings` | xnnpack, coreml, mlx, vulkan | opencv |
| `classification` | xnnpack, coreml | opencv |
diff --git a/docs/scripts/generate-supported-models.mjs b/docs/scripts/generate-supported-models.mjs
index 7558b74b38..077abf0dc0 100644
--- a/docs/scripts/generate-supported-models.mjs
+++ b/docs/scripts/generate-supported-models.mjs
@@ -47,10 +47,10 @@ function render(tasks, addedIn) {
];
for (const task of tasks) {
lines.push(`## ${task.title}`, '');
- lines.push(
- `[Guide](../${task.doc}) · [Benchmarks](../04-benchmarks/01-benchmarks.mdx#${task.bench})`,
- ''
- );
+ const bench = task.bench
+ ? ` · [Benchmarks](../04-benchmarks/01-benchmarks.mdx#${task.bench})`
+ : '';
+ lines.push(`[Guide](../${task.doc})${bench}`, '');
if (task.note) lines.push(task.note, '');
lines.push(
`| Model | Description | Backends | Precisions |${addedIn ? ' Added in |' : ''}`,
diff --git a/docs/scripts/tasks/llms-txt-models.mjs b/docs/scripts/tasks/llms-txt-models.mjs
index 07997b271c..850647228a 100644
--- a/docs/scripts/tasks/llms-txt-models.mjs
+++ b/docs/scripts/tasks/llms-txt-models.mjs
@@ -82,10 +82,8 @@ function renderModels(tasks, addedIn) {
'',
];
for (const task of tasks) {
- lines.push(
- `### ${task.title} ([guide](${DOCS_URL}${readDocSlug(task.doc)}), [benchmarks](${DOCS_URL}/benchmarks#${task.bench}))`,
- ''
- );
+ const bench = task.bench ? `, [benchmarks](${DOCS_URL}/benchmarks#${task.bench})` : '';
+ lines.push(`### ${task.title} ([guide](${DOCS_URL}${readDocSlug(task.doc)})${bench})`, '');
if (task.note) lines.push(task.note, '');
for (const model of task.models) {
const parts = [model.description];
diff --git a/docs/scripts/tasks/model-registry.mjs b/docs/scripts/tasks/model-registry.mjs
index 0a5184a147..1052c592d0 100644
--- a/docs/scripts/tasks/model-registry.mjs
+++ b/docs/scripts/tasks/model-registry.mjs
@@ -21,7 +21,7 @@ const REGISTRY_PATH = join(DOCS_DIR, '..', REGISTRY_REPO_PATH);
// Every registry category, in the order the docs present them. `doc` is the
// task's page under docs/docs and `bench` the heading anchor of its chart on
-// the benchmarks page. A category the registry gains without an entry here
+// the benchmarks page, if it has one. A category the registry gains without an entry here
// fails the build, so the generated lists cannot silently drop a task.
const TASKS = {
llm: {
@@ -45,6 +45,10 @@ const TASKS = {
doc: '02-extensions/natural-language/04-privacy-filter.md',
bench: 'privacy-filter',
},
+ translation: {
+ title: 'Translation',
+ doc: '02-extensions/natural-language/07-translation.md',
+ },
tokenizer: { skip: true },
speechToText: {
title: 'Speech to Text',
diff --git a/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap
index a2a7f74425..6ea42949f8 100644
--- a/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap
+++ b/packages/react-native-executorch/__tests__/api/__snapshots__/apiSurface.test.ts.snap
@@ -22,6 +22,7 @@ exports[`public API surface exports one hook per user-facing task 1`] = `
"useTextToImage",
"useTextToSpeech",
"useTokenizer",
+ "useTranslator",
"useVoiceActivityDetector",
]
`;
@@ -76,6 +77,7 @@ exports[`public API surface matches the recorded contents of each namespace 1`]
"labelEntityType",
"loadTokenizer",
"piiSegments",
+ "splitSentences",
"viterbiDecode",
],
"schema": [
@@ -121,8 +123,11 @@ exports[`public API surface matches the recorded export list 1`] = `
"COCO_CLASSES",
"COCO_CLASSES_YOLO",
"COCO_LANDMARKS",
+ "EUROLLM_LANGUAGES",
"FACEMESH_LANDMARKS",
"FSMN_VAD_SAMPLE_RATE_HZ",
+ "HY_MT2_LANGUAGES",
+ "HY_MT2_LANGUAGES_ZH",
"IMAGENET1K_LABELS",
"IMAGENET_NORM",
"KOKORO_SAMPLE_RATE",
@@ -147,6 +152,8 @@ exports[`public API surface matches the recorded export list 1`] = `
"createKeypointDetector",
"createKokoroTextToSpeech",
"createLLMChatSession",
+ "createLLMTranslator",
+ "createMarianTranslator",
"createObjectDetector",
"createPaddleOcr",
"createPrivacyFilter",
@@ -197,6 +204,7 @@ exports[`public API surface matches the recorded export list 1`] = `
"useTextToImage",
"useTextToSpeech",
"useTokenizer",
+ "useTranslator",
"useVoiceActivityDetector",
"wrapAsync",
]
diff --git a/packages/react-native-executorch/__tests__/api/modelVariants.test.ts b/packages/react-native-executorch/__tests__/api/modelVariants.test.ts
index 831745d30a..8f53d1f19a 100644
--- a/packages/react-native-executorch/__tests__/api/modelVariants.test.ts
+++ b/packages/react-native-executorch/__tests__/api/modelVariants.test.ts
@@ -360,6 +360,7 @@ describe('feature map', () => {
textEmbeddings: 'textEmbeddings',
colbertEmbeddings: 'textEmbeddings',
privacyFilter: 'privacyFilter',
+ translation: 'translation',
imageEmbeddings: 'imageEmbeddings',
textToImage: 'textToImage',
inpainting: 'inpainting',
diff --git a/packages/react-native-executorch/__tests__/extensions/streamDecoding.test.ts b/packages/react-native-executorch/__tests__/extensions/streamDecoding.test.ts
new file mode 100644
index 0000000000..a8b09f025f
--- /dev/null
+++ b/packages/react-native-executorch/__tests__/extensions/streamDecoding.test.ts
@@ -0,0 +1,49 @@
+/**
+ * Streamed detokenization of LLM output.
+ */
+import { loadTokenizer } from '../../src/extensions/nlp';
+import { decodeStreamed } from '../../src/extensions/llm/utils/streamDecoding';
+import { fakeJsi } from '../support/fakeJsi';
+
+const TOKENIZER_PATH = '/models/tokenizer.json';
+
+/** Streams `ids` one at a time, as the runners do. */
+const stream = (tokenizer: Awaited>, ids: number[]) => {
+ const pieces: string[] = [];
+ let previous: Int32Array | undefined;
+ for (const id of ids) {
+ const pending = Int32Array.of(id);
+ const piece = decodeStreamed(tokenizer, previous, pending, false);
+ if (piece === undefined) continue;
+ pieces.push(piece);
+ previous = pending;
+ }
+ return pieces;
+};
+
+describe('decodeStreamed', () => {
+ it('keeps word breaks when the decoder strips a leading space', async () => {
+ // SentencePiece pieces carry their word break in front, and a decoder chain
+ // ending in Strip(start=1) (EuroLLM) removes it from any decoded string.
+ fakeJsi.registerTokenizer(TOKENIZER_PATH, {
+ tokens: [' Hallo', ' Welt', '.'],
+ concat: true,
+ stripStart: true,
+ });
+ const tokenizer = await loadTokenizer(TOKENIZER_PATH);
+
+ const pieces = stream(tokenizer, [0, 1, 2]);
+
+ expect(pieces).toEqual(['Hallo', ' Welt', '.']);
+ expect(pieces.join('')).toBe(tokenizer.decode(Int32Array.of(0, 1, 2), false));
+ tokenizer.dispose();
+ });
+
+ it('decodes the first tokens on their own', async () => {
+ fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: ['Hallo'], concat: true });
+ const tokenizer = await loadTokenizer(TOKENIZER_PATH);
+
+ expect(decodeStreamed(tokenizer, undefined, Int32Array.of(0), false)).toBe('Hallo');
+ tokenizer.dispose();
+ });
+});
diff --git a/packages/react-native-executorch/__tests__/support/fakeJsi.ts b/packages/react-native-executorch/__tests__/support/fakeJsi.ts
index 1c4bc7e5c8..3dbc9062e5 100644
--- a/packages/react-native-executorch/__tests__/support/fakeJsi.ts
+++ b/packages/react-native-executorch/__tests__/support/fakeJsi.ts
@@ -103,6 +103,17 @@ export type FakeVocabulary = {
* array is mutated in place, which lets a test resolve the ids it was given.
*/
open?: boolean;
+ /**
+ * Decodes by concatenating token strings, which then carry their own
+ * whitespace, as BPE and SentencePiece vocabularies do. Without it tokens
+ * are words and decode joins them with spaces.
+ */
+ concat?: boolean;
+ /**
+ * Strips one leading space from every decoded string, like a SentencePiece
+ * decoder chain ending in `Strip(start=1)` (e.g. EuroLLM).
+ */
+ stripStart?: boolean;
};
const vocabularies = new Map();
@@ -147,10 +158,11 @@ function createFakeTokenizer(path: string, vocabulary: FakeVocabulary) {
return undefined;
}
}
- return ids
+ const text = ids
.map((id) => vocabulary.tokens[id] ?? '')
.filter(Boolean)
- .join(' ');
+ .join(vocabulary.concat ? '' : ' ');
+ return vocabulary.stripStart ? text.replace(/^ /, '') : text;
};
return {
diff --git a/packages/react-native-executorch/__tests__/support/fakeLLM.ts b/packages/react-native-executorch/__tests__/support/fakeLLM.ts
index cb8471b2ed..0dc4c4b2e1 100644
--- a/packages/react-native-executorch/__tests__/support/fakeLLM.ts
+++ b/packages/react-native-executorch/__tests__/support/fakeLLM.ts
@@ -38,6 +38,8 @@ export type FakeLLMProgram = {
* the turns it cares about.
*/
generations?: readonly FakeGeneration[];
+ /** Whether the tokenizer strips a leading space off every decode. */
+ stripStart?: boolean;
};
/** A prompt the model read: the text of one prefill, and where it started. */
@@ -53,7 +55,7 @@ const VOCAB_SIZE = 4096;
* @returns Accessors for what the model was asked to read.
*/
export function registerFakeLLM(modelPath: string, tokenizerPath: string, program: FakeLLMProgram) {
- const { maxSeqLen = 128, maxContextLen = 1024, eosToken, generations = [] } = program;
+ const { maxSeqLen = 128, maxContextLen = 1024, eosToken, generations = [], stripStart } = program;
const tokens: string[] = [];
const idOf = (token: string): number => {
@@ -159,7 +161,7 @@ export function registerFakeLLM(modelPath: string, tokenizerPath: string, progra
},
});
- fakeJsi.registerTokenizer(tokenizerPath, { tokens, open: true });
+ fakeJsi.registerTokenizer(tokenizerPath, { tokens, open: true, concat: true, stripStart });
return {
/** @returns Every prompt the model read, prefill by prefill, in order. */
diff --git a/packages/react-native-executorch/__tests__/tasks/llmRunner.test.ts b/packages/react-native-executorch/__tests__/tasks/llmRunner.test.ts
index cf197d4225..da24ab75fb 100644
--- a/packages/react-native-executorch/__tests__/tasks/llmRunner.test.ts
+++ b/packages/react-native-executorch/__tests__/tasks/llmRunner.test.ts
@@ -572,7 +572,9 @@ describe('createLLMMultimodalRunner — execution & KV cache lifecycle', () => {
expect(stats.numTokens).toBe(3);
expect(tokens.length).toBe(3);
- expect(tokens).toEqual(['hello', 'hello', 'hello']);
+ // Each piece is decoded after the previous one, so the pieces add up to
+ // the decode of the whole sequence (this vocabulary joins words with spaces).
+ expect(tokens).toEqual(['hello', ' hello', ' hello']);
});
it('stops generation early when stop() is called', async () => {
@@ -813,7 +815,9 @@ describe('createLLMGemmaRunner — execution & multimodal prefill', () => {
expect(stats.numTokens).toBe(3);
expect(tokens.length).toBe(3);
- expect(tokens).toEqual(['hello', 'hello', 'hello']);
+ // Each piece is decoded after the previous one, so the pieces add up to
+ // the decode of the whole sequence (this vocabulary joins words with spaces).
+ expect(tokens).toEqual(['hello', ' hello', ' hello']);
});
it('resets KV cache and disposes cleanly', async () => {
diff --git a/packages/react-native-executorch/__tests__/tasks/llmTranslation.test.ts b/packages/react-native-executorch/__tests__/tasks/llmTranslation.test.ts
new file mode 100644
index 0000000000..b73507e3ba
--- /dev/null
+++ b/packages/react-native-executorch/__tests__/tasks/llmTranslation.test.ts
@@ -0,0 +1,193 @@
+/**
+ * The LLM (EuroLLM, Hy-MT2) translator.
+ *
+ * It runs on the real chat session and LLM runner; only the `.pte` below them
+ * is scripted (see `support/fakeLLM.ts`), so a test sees the exact prompt each
+ * line was sent with.
+ */
+import { createLLMTranslator } from '../../src/extensions/llm/tasks/llmTranslation';
+import { fakeJsi } from '../support/fakeJsi';
+import { fakeFs } from '../support/blobUtilMock';
+import { tracked } from '../support/lifetime';
+import { registerFakeLLM, type FakeGeneration } from '../support/fakeLLM';
+import { models } from '../../src/models';
+
+const MODEL_PATH = '/models/eurollm.pte';
+const TOKENIZER_PATH = '/models/tokenizer.json';
+const TOKENIZER_CONFIG_PATH = '/models/tokenizer_config.json';
+const EOS = '<|im_end|>';
+
+// EuroLLM-1.7B-Instruct's published chat template.
+const CHAT_TEMPLATE =
+ "{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}<|im_start|>system\n{% if messages[0]['role'] == 'system' %}{{ messages[0]['content'] }}{% endif %}<|im_end|>\n{% for message in messages %}{% if message['role'] != 'system' %}<|im_start|>{{ message['role'] }}\n{{ message['content'] }}<|im_end|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}";
+
+const paths = {
+ modelPath: MODEL_PATH,
+ tokenizerPath: TOKENIZER_PATH,
+ tokenizerConfigPath: TOKENIZER_CONFIG_PATH,
+};
+// The published preset with local paths, so the tests check its prompt.
+const config = {
+ ...models.translation.EUROLLM_1_7B.XNNPACK_8DA8W,
+ ...paths,
+};
+
+const EN_DE = { sourceLanguage: 'en', targetLanguage: 'de' };
+
+let llm: ReturnType;
+
+/** Registers the model, answering with `responses` in order, one per line. */
+const script = (...responses: string[]) => {
+ const generations: FakeGeneration[] = responses.map((response) => ({ response }));
+ llm = registerFakeLLM(MODEL_PATH, TOKENIZER_PATH, { eosToken: EOS, generations });
+};
+
+// The fake tokenizer splits on whitespace, so prompts are compared with every
+// whitespace run collapsed to one space.
+const squash = (text: string) => text.replace(/\s+/g, ' ').trim();
+const promptsSent = (): string[] => llm.prompts().map((prompt) => squash(prompt.text));
+const userTurn = (source: string, target: string, line: string) =>
+ squash(
+ `<|im_start|>system\n<|im_end|>\n<|im_start|>user\n` +
+ `Translate the following ${source} source text to ${target}:\n` +
+ `${source}: ${line} \n${target}: <|im_end|>\n<|im_start|>assistant\n`
+ );
+
+beforeEach(() => {
+ // The keys are the published `tokenizer_config.json` field names.
+ /* eslint-disable camelcase */
+ fakeFs.write(
+ TOKENIZER_CONFIG_PATH,
+ JSON.stringify({ chat_template: CHAT_TEMPLATE, eos_token: EOS })
+ );
+ /* eslint-enable camelcase */
+ script('Hallo Welt.');
+});
+
+describe('createLLMTranslator', () => {
+ it('sends each line with the model card prompt and keeps line breaks', async () => {
+ script('Hallo Welt.', 'Guten Morgen.');
+ const translator = tracked(await createLLMTranslator(config));
+
+ const text = await translator.translate('Hello world.\n\nGood morning.', EN_DE);
+
+ expect(text).toBe('Hallo Welt.\n\nGuten Morgen.');
+ // Each line starts from an empty cache, without the lines before it.
+ expect(promptsSent()).toEqual([
+ userTurn('English', 'German', 'Hello world.'),
+ userTurn('English', 'German', 'Good morning.'),
+ ]);
+ });
+
+ it('translates any supported pair with the same model', async () => {
+ const translator = tracked(await createLLMTranslator(config));
+
+ await translator.translate('Dzień dobry.', { sourceLanguage: 'pl', targetLanguage: 'en' });
+
+ expect(promptsSent()).toEqual([userTurn('Polish', 'English', 'Dzień dobry.')]);
+ });
+
+ it('sends placeholders in the text as written', async () => {
+ const translator = tracked(await createLLMTranslator(config));
+
+ await translator.translate('{target}', EN_DE);
+
+ expect(promptsSent()).toEqual([userTurn('English', 'German', '{target}')]);
+ });
+
+ it('drops the whitespace the reply starts with', async () => {
+ script(' Hallo');
+ const translator = tracked(await createLLMTranslator(config));
+
+ expect(await translator.translate('Hello', EN_DE)).toBe('Hallo');
+ });
+
+ it('streams pieces that add up to the translation', async () => {
+ script('Hallo Welt.', 'Guten Morgen.');
+ const translator = tracked(await createLLMTranslator(config));
+ const pieces: string[] = [];
+
+ const text = await translator.translate('Hello world.\nGood morning.', EN_DE, (piece) =>
+ pieces.push(piece)
+ );
+ // Pieces are delivered on the RN thread, which the mock defers to a macrotask.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(pieces.join('')).toBe(text);
+ });
+
+ it('rejects a language the model does not know', async () => {
+ const translator = tracked(await createLLMTranslator(config));
+
+ await expect(
+ translator.translate('Hello', { sourceLanguage: 'en', targetLanguage: 'xx' })
+ ).rejects.toThrow(/unsupported language 'xx'/);
+ });
+
+ it('releases everything on dispose', async () => {
+ const translator = tracked(await createLLMTranslator(config));
+ await translator.translate('Hello world.', EN_DE);
+ translator.dispose();
+
+ expect(fakeJsi.liveTensors()).toBe(0);
+ expect(fakeJsi.liveModels()).toEqual([]);
+ expect(fakeJsi.liveTokenizers()).toEqual([]);
+ });
+});
+
+describe('createLLMTranslator with Hy-MT2', () => {
+ const HY_EOS = '<|hy_place▁holder▁no▁2|>';
+ // Hy-MT2-1.8B's chat_template.jinja, which the published tokenizer_config.json carries.
+ const HY_CHAT_TEMPLATE =
+ "{% if messages[0]['role'] == 'system' %}{% set loop_messages = messages[1:] %}{% set system_message = messages[0]['content'] %}<|hy_begin▁of▁sentence|>{{ system_message }}<|hy_place▁holder▁no▁3|>{% else %}{% set loop_messages = messages %}<|hy_begin▁of▁sentence|>{% endif %}{% for message in loop_messages %}{% if message['role'] == 'user' %}<|hy_User|>{{ message['content'] }}{% elif message['role'] == 'assistant' %}<|hy_Assistant|>{{ message['content'] }}<|hy_place▁holder▁no▁2|>{% endif %}{% endfor %}{% if add_generation_prompt %}<|hy_Assistant|>{% else %}<|hy_place▁holder▁no▁8|>{% endif %}";
+ const hyConfig = { ...models.translation.HY_MT2_1_8B.XNNPACK_8DA4W, ...paths };
+ const hyTurn = (content: string) =>
+ squash(`<|hy_begin▁of▁sentence|><|hy_User|>${content}<|hy_Assistant|>`);
+
+ beforeEach(() => {
+ // The fake fs stores one byte per char, so anything outside printable ASCII
+ // goes in as a JSON escape.
+ /* eslint-disable camelcase */
+ const json = JSON.stringify({ chat_template: HY_CHAT_TEMPLATE, eos_token: HY_EOS });
+ /* eslint-enable camelcase */
+ fakeFs.write(
+ TOKENIZER_CONFIG_PATH,
+ json.replace(/[^ -~]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
+ );
+ llm = registerFakeLLM(MODEL_PATH, TOKENIZER_PATH, {
+ eosToken: HY_EOS,
+ generations: [{ response: 'Hallo Welt.' }],
+ });
+ });
+
+ it('sends the model card prompt naming the target language', async () => {
+ const translator = tracked(await createLLMTranslator(hyConfig));
+
+ expect(await translator.translate('Hello world.', EN_DE)).toBe('Hallo Welt.');
+ expect(promptsSent()).toEqual([
+ hyTurn(
+ 'Translate the following text into German. Note that you should only output ' +
+ 'the translated result without any additional explanation:\n\nHello world.'
+ ),
+ ]);
+ });
+
+ it('uses the Chinese prompt when either side is Chinese', async () => {
+ const translator = tracked(await createLLMTranslator(hyConfig));
+
+ await translator.translate('Hello world.', { sourceLanguage: 'en', targetLanguage: 'zh' });
+
+ expect(promptsSent()).toEqual([
+ hyTurn('将以下文本翻译为中文,注意只需要输出翻译后的结果,不要额外解释:\n\nHello world.'),
+ ]);
+ });
+
+ it('rejects a language the model does not know', async () => {
+ const translator = tracked(await createLLMTranslator(hyConfig));
+
+ // Maltese is a EuroLLM language, not a Hy-MT2 one.
+ await expect(
+ translator.translate('Hello', { sourceLanguage: 'mt', targetLanguage: 'en' })
+ ).rejects.toThrow(/unsupported language 'mt'/);
+ });
+});
diff --git a/packages/react-native-executorch/__tests__/tasks/marianTranslation.test.ts b/packages/react-native-executorch/__tests__/tasks/marianTranslation.test.ts
new file mode 100644
index 0000000000..cf9a5889b3
--- /dev/null
+++ b/packages/react-native-executorch/__tests__/tasks/marianTranslation.test.ts
@@ -0,0 +1,202 @@
+import { RangeDim, constraint, f32, i64, method } from '../../src/core/schema';
+import type { ConcreteDim } from '../../src/core/schema';
+import { createMarianTranslator } from '../../src/extensions/nlp/tasks/marianTranslation';
+import { splitSentences } from '../../src/extensions/nlp/utils/translationUtils';
+import { fakeJsi } from '../support/fakeJsi';
+import { tracked } from '../support/lifetime';
+import type { FakeTensor } from '../support/fakeTensor';
+import { exported } from '../support/fixtures';
+
+const MODEL_PATH = '/models/opus_mt.pte';
+const TOKENIZER_PATH = '/models/tokenizer.json';
+
+const VOCAB = ['', '', 'hello', 'world', 'hallo', 'welt'];
+const [PAD, EOS, HALLO, WELT] = [0, 1, 4, 5];
+const NUM_CROSS_KV = 12;
+
+const config = {
+ name: 'marian' as const,
+ modelPath: MODEL_PATH,
+ tokenizerPath: TOKENIZER_PATH,
+ sourceLanguage: 'en',
+ targetLanguage: 'de',
+};
+
+/** Builds the exported Opus-MT schema over a source-length domain. */
+function marianSchema(source: ConcreteDim) {
+ const kv = Array(NUM_CROSS_KV).fill(f32(2, source, 4));
+ const kvIndices = [...Array(NUM_CROSS_KV).keys()];
+ const dim = (paramSide: 'input' | 'output', tensorIdx: number) =>
+ ({ paramSide, tensorIdx, dimIdx: 1 }) as const;
+ return exported({
+ ...method('encode', [i64(1, source), i64(1, source)], kv, [
+ constraint.equality(dim('input', 0), dim('input', 1)),
+ ...kvIndices.map((i) => constraint.equality(dim('input', 0), dim('output', i))),
+ ]),
+ ...method(
+ 'decode',
+ [i64(1, 1), i64(1), i64(1, source), ...kv],
+ [f32(1, 1, VOCAB.length)],
+ kvIndices.map((i) => constraint.equality(dim('input', 2), dim('input', 3 + i)))
+ ),
+ });
+}
+
+const EN_DE = { sourceLanguage: 'en', targetLanguage: 'de' };
+
+const read = (t: FakeTensor) => Array.from({ length: t.numel }, (_, i) => t.getElement(i));
+
+/** What each `encode` received, and the decoder inputs of each step. */
+const encoded: { ids: number[]; mask: number[] }[] = [];
+const decoded: { token: number; position: number }[] = [];
+
+/**
+ * Registers a model whose decoder emits `target` (then ``) for every
+ * sentence, one token per position.
+ */
+function register(source: ConcreteDim, target: readonly number[] = [HALLO, WELT]) {
+ encoded.length = 0;
+ decoded.length = 0;
+ fakeJsi.registerModel(MODEL_PATH, {
+ schema: marianSchema(source),
+ execute: (methodName, inputs, outputs) => {
+ if (methodName === 'encode') {
+ const [ids, mask] = inputs as [FakeTensor, FakeTensor];
+ encoded.push({ ids: read(ids), mask: read(mask) });
+ return;
+ }
+ const [token, position] = inputs as [FakeTensor, FakeTensor];
+ const pos = position.getElement(0);
+ decoded.push({ token: token.getElement(0), position: pos });
+ const logits = outputs[0]!;
+ for (let i = 0; i < logits.numel; i++) logits.setElement(i, 0);
+ logits.setElement(target[pos] ?? EOS, 1);
+ },
+ });
+ fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: VOCAB, specialIds: [PAD, EOS] });
+}
+
+describe('createMarianTranslator', () => {
+ it('encodes the source with and decodes greedily from until ', async () => {
+ register(RangeDim(1, 8));
+ const translator = tracked(await createMarianTranslator(config));
+
+ expect(await translator.translate('hello world', EN_DE)).toBe('hallo welt');
+ expect(encoded).toEqual([{ ids: [2, 3, EOS], mask: [1, 1, 1] }]);
+ expect(decoded).toEqual([
+ { token: PAD, position: 0 },
+ { token: HALLO, position: 1 },
+ { token: WELT, position: 2 },
+ ]);
+ });
+
+ it('does not append a second when the tokenizer already adds one', async () => {
+ register(RangeDim(1, 8));
+ fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: VOCAB, suffix: [EOS] });
+ const translator = tracked(await createMarianTranslator(config));
+
+ await translator.translate('hello world', EN_DE);
+ expect(encoded[0]!.ids).toEqual([2, 3, EOS]);
+ });
+
+ it('pads and masks the source when the export fixes its length', async () => {
+ register(RangeDim(6, 6));
+ const translator = tracked(await createMarianTranslator(config));
+
+ expect(await translator.translate('hello world', EN_DE)).toBe('hallo welt');
+ expect(encoded).toEqual([{ ids: [2, 3, EOS, PAD, PAD, PAD], mask: [1, 1, 1, 0, 0, 0] }]);
+ });
+
+ it('truncates a long sentence to the maximum source length, ending in ', async () => {
+ register(RangeDim(1, 4));
+ const translator = tracked(await createMarianTranslator(config));
+
+ await translator.translate('hello world hello world hello', EN_DE);
+ expect(encoded[0]!.ids).toEqual([2, 3, 2, EOS]);
+ });
+
+ it('translates sentence by sentence and keeps line breaks', async () => {
+ register(RangeDim(1, 8));
+ const translator = tracked(await createMarianTranslator(config));
+
+ const text = await translator.translate('hello. world!\n\nhello', EN_DE);
+ expect(text).toBe('hallo welt hallo welt\n\nhallo welt');
+ expect(encoded).toHaveLength(3);
+ });
+
+ it('streams pieces that add up to the translation', async () => {
+ register(RangeDim(1, 8));
+ const translator = tracked(await createMarianTranslator(config));
+ const pieces: string[] = [];
+
+ const text = await translator.translate('hello world', EN_DE, (piece) => pieces.push(piece));
+ // Pieces are delivered on the RN thread, which the mock defers to a macrotask.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(pieces.join('')).toBe(text);
+ expect(pieces.length).toBeGreaterThan(1);
+ });
+
+ it('stops when the decoder fills its cache without emitting ', async () => {
+ register(RangeDim(1, 8), Array(300).fill(HALLO));
+ const translator = tracked(await createMarianTranslator(config));
+
+ await translator.translate('hello', EN_DE);
+ expect(decoded).toHaveLength(256);
+ });
+
+ it("rejects a language pair other than the model's own", async () => {
+ register(RangeDim(1, 8));
+ const translator = tracked(await createMarianTranslator(config));
+
+ await expect(
+ translator.translate('hello', { sourceLanguage: 'de', targetLanguage: 'en' })
+ ).rejects.toThrow(/only translates en to de/);
+ });
+
+ it('rejects a model without the source-length constraints', async () => {
+ const kv = Array(NUM_CROSS_KV).fill(f32(2, RangeDim(1, 8), 4));
+ fakeJsi.registerModel(MODEL_PATH, {
+ schema: exported({
+ ...method('encode', [i64(1, RangeDim(1, 8)), i64(1, RangeDim(1, 8))], kv),
+ ...method(
+ 'decode',
+ [i64(1, 1), i64(1), i64(1, RangeDim(1, 8)), ...kv],
+ [f32(1, 1, VOCAB.length)]
+ ),
+ }),
+ });
+ fakeJsi.registerTokenizer(TOKENIZER_PATH, { tokens: VOCAB });
+
+ await expect(createMarianTranslator(config)).rejects.toThrow(
+ /doesn't match any of the provided variants/
+ );
+ });
+
+ it('releases everything on dispose', async () => {
+ register(RangeDim(1, 8));
+ const translator = tracked(await createMarianTranslator(config));
+ await translator.translate('hello world. hello', EN_DE);
+ translator.dispose();
+
+ expect(fakeJsi.liveTensors()).toBe(0);
+ expect(fakeJsi.liveModels()).toEqual([]);
+ expect(fakeJsi.liveTokenizers()).toEqual([]);
+ });
+});
+
+describe('splitSentences', () => {
+ it('splits after closing punctuation and keeps line breaks as separators', () => {
+ expect(splitSentences('Hi there. How are you?\nFine!')).toEqual([
+ { sentence: 'Hi there.', separator: ' ' },
+ { sentence: 'How are you?', separator: '\n' },
+ { sentence: 'Fine!', separator: '' },
+ ]);
+ });
+
+ it('keeps closing quotes with their sentence and does not split decimals', () => {
+ expect(splitSentences('He said "go." It costs 3.50 now')).toEqual([
+ { sentence: 'He said "go."', separator: ' ' },
+ { sentence: 'It costs 3.50 now', separator: '' },
+ ]);
+ });
+});
diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js
index ab5794e497..e5944c9f40 100644
--- a/packages/react-native-executorch/scripts/download-libs.js
+++ b/packages/react-native-executorch/scripts/download-libs.js
@@ -42,7 +42,7 @@
* Recognized values:
* backends: xnnpack, coreml (iOS), mlx (iOS), vulkan (Android)
* libs: opencv, phonemis
- * features: llm, multimodalLLM, speechToText, textToSpeech, vad, privacyFilter,
+ * features: llm, multimodalLLM, speechToText, textToSpeech, vad, privacyFilter, translation,
* textEmbeddings, imageEmbeddings,
* classification, objectDetection, depthEstimation, semanticSegmentation, instanceSegmentation,
* ocr, verticalOCR, keypointDetection, styleTransfer, textToImage, segmentAnything,
@@ -143,6 +143,8 @@ const FEATURE_MAP = {
},
// FSMN VAD — xnnpack only.
vad: { backends: ['xnnpack'], libs: [] },
+ // Opus-MT ships xnnpack int8 only: it beat Core ML, MLX and Vulkan on device.
+ translation: { backends: ['xnnpack', 'mlx'], libs: [] },
// The MiniLM/CLIP-text/distiluse family ships coreml alongside xnnpack,
// LFM2.5-Embedding and distiluse add MLX iOS exports, and every sentence
// transformer now ships a vulkan Android export.
diff --git a/packages/react-native-executorch/src/constants.ts b/packages/react-native-executorch/src/constants.ts
index 3d85ad2d9d..59ac9bd7ce 100644
--- a/packages/react-native-executorch/src/constants.ts
+++ b/packages/react-native-executorch/src/constants.ts
@@ -1435,3 +1435,138 @@ export const PRIVACY_FILTER_NEMOTRON_LABELS = bioesLabels([
* @category NLP / Types
*/
export type PrivacyFilterNemotronLabel = (typeof PRIVACY_FILTER_NEMOTRON_LABELS)[number];
+
+/**
+ * Languages EuroLLM-1.7B-Instruct translates between, keyed by ISO 639-1 code,
+ * with the English names its prompt uses.
+ * @category NLP / Constants
+ */
+export const EUROLLM_LANGUAGES: Readonly> = {
+ ar: 'Arabic',
+ bg: 'Bulgarian',
+ ca: 'Catalan',
+ cs: 'Czech',
+ da: 'Danish',
+ de: 'German',
+ el: 'Greek',
+ en: 'English',
+ es: 'Spanish',
+ et: 'Estonian',
+ fi: 'Finnish',
+ fr: 'French',
+ ga: 'Irish',
+ gl: 'Galician',
+ hi: 'Hindi',
+ hr: 'Croatian',
+ hu: 'Hungarian',
+ it: 'Italian',
+ ja: 'Japanese',
+ ko: 'Korean',
+ lt: 'Lithuanian',
+ lv: 'Latvian',
+ mt: 'Maltese',
+ nl: 'Dutch',
+ no: 'Norwegian',
+ pl: 'Polish',
+ pt: 'Portuguese',
+ ro: 'Romanian',
+ ru: 'Russian',
+ sk: 'Slovak',
+ sl: 'Slovenian',
+ sv: 'Swedish',
+ tr: 'Turkish',
+ uk: 'Ukrainian',
+ zh: 'Chinese',
+};
+
+/**
+ * Languages Hy-MT2 translates between, keyed by ISO 639 code, with the English
+ * names its prompt uses.
+ * @category NLP / Constants
+ */
+export const HY_MT2_LANGUAGES: Readonly> = {
+ 'ar': 'Arabic',
+ 'bn': 'Bengali',
+ 'bo': 'Tibetan',
+ 'cs': 'Czech',
+ 'de': 'German',
+ 'en': 'English',
+ 'es': 'Spanish',
+ 'fa': 'Persian',
+ 'fr': 'French',
+ 'gu': 'Gujarati',
+ 'he': 'Hebrew',
+ 'hi': 'Hindi',
+ 'id': 'Indonesian',
+ 'it': 'Italian',
+ 'ja': 'Japanese',
+ 'kk': 'Kazakh',
+ 'km': 'Khmer',
+ 'ko': 'Korean',
+ 'mn': 'Mongolian',
+ 'mr': 'Marathi',
+ 'ms': 'Malay',
+ 'my': 'Burmese',
+ 'nl': 'Dutch',
+ 'pl': 'Polish',
+ 'pt': 'Portuguese',
+ 'ru': 'Russian',
+ 'ta': 'Tamil',
+ 'te': 'Telugu',
+ 'th': 'Thai',
+ 'tl': 'Filipino',
+ 'tr': 'Turkish',
+ 'ug': 'Uyghur',
+ 'uk': 'Ukrainian',
+ 'ur': 'Urdu',
+ 'vi': 'Vietnamese',
+ 'yue': 'Cantonese',
+ 'zh': 'Chinese',
+ 'zh-Hant': 'Traditional Chinese',
+};
+
+/**
+ * The Chinese names of {@link HY_MT2_LANGUAGES}, which Hy-MT2's Chinese prompt
+ * uses.
+ * @category NLP / Constants
+ */
+export const HY_MT2_LANGUAGES_ZH: Readonly> = {
+ 'ar': '阿拉伯语',
+ 'bn': '孟加拉语',
+ 'bo': '藏语',
+ 'cs': '捷克语',
+ 'de': '德语',
+ 'en': '英语',
+ 'es': '西班牙语',
+ 'fa': '波斯语',
+ 'fr': '法语',
+ 'gu': '古吉拉特语',
+ 'he': '希伯来语',
+ 'hi': '印地语',
+ 'id': '印尼语',
+ 'it': '意大利语',
+ 'ja': '日语',
+ 'kk': '哈萨克语',
+ 'km': '高棉语',
+ 'ko': '韩语',
+ 'mn': '蒙古语',
+ 'mr': '马拉地语',
+ 'ms': '马来语',
+ 'my': '缅甸语',
+ 'nl': '荷兰语',
+ 'pl': '波兰语',
+ 'pt': '葡萄牙语',
+ 'ru': '俄语',
+ 'ta': '泰米尔语',
+ 'te': '泰卢固语',
+ 'th': '泰语',
+ 'tl': '菲律宾语',
+ 'tr': '土耳其语',
+ 'ug': '维吾尔语',
+ 'uk': '乌克兰语',
+ 'ur': '乌尔都语',
+ 'vi': '越南语',
+ 'yue': '粤语',
+ 'zh': '中文',
+ 'zh-Hant': '繁体中文',
+};
diff --git a/packages/react-native-executorch/src/extensions/llm/runners/llmGemmaRunner.ts b/packages/react-native-executorch/src/extensions/llm/runners/llmGemmaRunner.ts
index 77bce0c539..336f806695 100644
--- a/packages/react-native-executorch/src/extensions/llm/runners/llmGemmaRunner.ts
+++ b/packages/react-native-executorch/src/extensions/llm/runners/llmGemmaRunner.ts
@@ -14,6 +14,7 @@ import {
import type { Tokenizer } from '../../nlp';
import { sample } from '../sampler';
+import { decodeStreamed } from '../utils/streamDecoding';
import type {
LLMGenerationConfig,
@@ -479,6 +480,8 @@ export function createLLMGemmaRunner(
let numTokens = 0;
let decodeOffset = 0;
+ // The token group streamed last, decoded as context for the next one.
+ let streamedGroup: Int32Array | undefined;
let curPos = pos.getBlocking();
try {
@@ -496,9 +499,11 @@ export function createLLMGemmaRunner(
if (onToken) {
const pendingTokens = Int32Array.from(generatedTokens.slice(decodeOffset));
- const text = tokenizer.tryDecode(pendingTokens, /* skipSpecialTokens= */ false);
+ // prettier-ignore
+ const text = decodeStreamed(tokenizer, streamedGroup, pendingTokens, /* skipSpecialTokens= */ false);
if (text !== undefined) {
decodeOffset = generatedTokens.length;
+ streamedGroup = pendingTokens;
onToken(text);
}
}
diff --git a/packages/react-native-executorch/src/extensions/llm/runners/llmMultimodalRunner.ts b/packages/react-native-executorch/src/extensions/llm/runners/llmMultimodalRunner.ts
index 37f17c2c7e..977642adc7 100644
--- a/packages/react-native-executorch/src/extensions/llm/runners/llmMultimodalRunner.ts
+++ b/packages/react-native-executorch/src/extensions/llm/runners/llmMultimodalRunner.ts
@@ -8,6 +8,7 @@ import type { Range } from '../../../core/schema';
import type { Tokenizer } from '../../nlp';
import { sample } from '../sampler';
+import { decodeStreamed } from '../utils/streamDecoding';
import type {
LLMGenerationConfig,
@@ -450,6 +451,8 @@ export function createLLMMultimodalRunner(
let numTokens = 0;
let decodeOffset = 0;
+ // The token group streamed last, decoded as context for the next one.
+ let streamedGroup: Int32Array | undefined;
let curPos = pos.getBlocking();
try {
@@ -467,9 +470,11 @@ export function createLLMMultimodalRunner(
if (onToken) {
const pendingTokens = Int32Array.from(generatedTokens.slice(decodeOffset));
- const text = tokenizer.tryDecode(pendingTokens, /* skipSpecialTokens= */ true);
+ // prettier-ignore
+ const text = decodeStreamed(tokenizer, streamedGroup, pendingTokens, /* skipSpecialTokens= */ true);
if (text !== undefined) {
decodeOffset = generatedTokens.length;
+ streamedGroup = pendingTokens;
onToken(text);
}
}
diff --git a/packages/react-native-executorch/src/extensions/llm/runners/llmTextRunner.ts b/packages/react-native-executorch/src/extensions/llm/runners/llmTextRunner.ts
index a93b9a20a4..a6b25735c6 100644
--- a/packages/react-native-executorch/src/extensions/llm/runners/llmTextRunner.ts
+++ b/packages/react-native-executorch/src/extensions/llm/runners/llmTextRunner.ts
@@ -7,6 +7,7 @@ import { f32, i64, method, DynamicDim as Dyn, validateSpec } from '../../../core
import type { Tokenizer } from '../../nlp';
import { sample } from '../sampler';
+import { decodeStreamed } from '../utils/streamDecoding';
import type {
LLMGenerationConfig,
@@ -196,6 +197,8 @@ export function createLLMTextRunner(
let numTokens = 0;
let decodeOffset = 0;
+ // The token group streamed last, decoded as context for the next one.
+ let streamedGroup: Int32Array | undefined;
let curPos = pos.getBlocking();
try {
@@ -213,9 +216,11 @@ export function createLLMTextRunner(
if (onToken) {
const pendingTokens = Int32Array.from(generatedTokens.slice(decodeOffset));
- const text = tokenizer.tryDecode(pendingTokens, /* skipSpecialTokens= */ false);
+ // prettier-ignore
+ const text = decodeStreamed(tokenizer, streamedGroup, pendingTokens, /* skipSpecialTokens= */ false);
if (text !== undefined) {
decodeOffset = generatedTokens.length;
+ streamedGroup = pendingTokens;
onToken(text);
}
}
diff --git a/packages/react-native-executorch/src/extensions/llm/tasks/llmTranslation.ts b/packages/react-native-executorch/src/extensions/llm/tasks/llmTranslation.ts
new file mode 100644
index 0000000000..294a8cdeda
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/tasks/llmTranslation.ts
@@ -0,0 +1,228 @@
+/**
+ * Instruction-tuned LLM (EuroLLM, Hy-MT2) machine translation task pipeline.
+ */
+
+import { scheduleOnRN, type WorkletRuntime } from 'react-native-worklets';
+import RNBlobUtil from 'react-native-blob-util';
+
+import { wrapAsync } from '../../../core/runtime';
+import { RnExecuTorchError } from '../../../core/error';
+import { createResourceScope } from '../../../core/lifetime';
+import type { TranslationLanguages, Translator } from '../../nlp/utils/translationUtils';
+import {
+ createLLMRunner,
+ type LLMGenerationConfig,
+ type LLMRunner,
+ type Prompt,
+} from '../llmRunner';
+import { parseTokenizerConfig } from '../utils/tokenizerConfig';
+import { createChatPreprocessor } from '../utils/chatPreprocessor';
+import type { LLMModel } from './llmChatSession';
+
+/**
+ * A translation prompt, as a model card gives it.
+ * @category NLP / Types
+ */
+export type LLMTranslationPrompt = {
+ /**
+ * The user turn, in which `{source}`, `{target}` and `{text}` stand for the
+ * source and target language names and the line to translate.
+ */
+ readonly template: string;
+ /** The name the template uses for each language, keyed by language code. */
+ readonly languageNames: Readonly>;
+};
+
+/**
+ * Paths and prompt required to instantiate an LLM translator. One model
+ * translates between any two of its supported languages.
+ * @category NLP / Types
+ */
+export type LLMTranslatorModel = LLMModel & {
+ /** Identifies the pipeline this config belongs to. */
+ readonly name: 'llm';
+ /**
+ * The model card's translation prompt. Its `languageNames` are the languages
+ * the model translates between.
+ */
+ readonly prompt: LLMTranslationPrompt;
+ /**
+ * Prompts used instead of `prompt` when the source or target language is
+ * their key, such as a Chinese prompt for pairs with Chinese.
+ */
+ readonly promptOverrides?: Readonly>;
+};
+
+/**
+ * LLM translation task runner. Each line is translated on its own.
+ * @category NLP / Types
+ */
+export type LLMTranslator = Translator;
+
+function generateWorklet(
+ runner: LLMRunner,
+ prompt: Prompt,
+ genConfig: LLMGenerationConfig,
+ stopTokens: readonly string[],
+ onToken?: (token: string) => void
+): string {
+ 'worklet';
+ let response = '';
+ runner.generate(prompt, genConfig, (token) => {
+ if (stopTokens.includes(token)) return;
+ response += token;
+ if (onToken) scheduleOnRN(onToken, token);
+ });
+ return response;
+}
+
+// Greedy decoding, as in the model card's evaluation.
+const GENERATION_CONFIG: LLMGenerationConfig = { temperature: 0 };
+
+/**
+ * Creates a translator on top of an instruction-tuned LLM such as EuroLLM or
+ * Hy-MT2.
+ *
+ * Every line is sent as its own single-turn chat with the model card's
+ * translation prompt, from an empty KV cache, and decoded greedily. One model
+ * translates between any two of its languages, so the pair can change per
+ * call.
+ * @category NLP / Tasks
+ * @param config Model, tokenizer and tokenizer config paths. See
+ * {@link LLMTranslatorModel}.
+ * @param runtime Optional worklet runtime thread on which to run the model
+ * execution.
+ * @returns A promise resolving to the instantiated {@link LLMTranslator}.
+ * @throws {RnExecuTorchError} With code `LOAD_FAILED` if the model or
+ * tokenizer fails to load, or `SCHEMA_MISMATCH` if the model schema does not
+ * match a text LLM.
+ */
+export async function createLLMTranslator(
+ config: LLMTranslatorModel,
+ runtime?: WorkletRuntime
+): Promise {
+ const scope = createResourceScope();
+ const dispose = scope.dispose;
+
+ try {
+ const { modelPath, tokenizerPath, tokenizerConfigPath } = config;
+ const tokenizerConfigStr = await RNBlobUtil.fs.readFile(tokenizerConfigPath, 'utf8');
+ const { chatTemplate, bosToken, eosToken, stopTokens } = parseTokenizerConfig(
+ JSON.parse(tokenizerConfigStr)
+ );
+ const chatPreprocessor = scope.track(
+ createChatPreprocessor({ chatTemplate, bosToken, eosToken })
+ );
+ // prettier-ignore
+ const runner = scope.track(await createLLMRunner(modelPath, tokenizerPath, undefined, runtime));
+ const generate = wrapAsync(generateWorklet, runtime);
+
+ let busy = false;
+ let stopped = false;
+
+ const { prompt: basePrompt, promptOverrides = {} } = config;
+ // Throws for a language the model does not know.
+ const userTurn = (line: string, source: string, target: string): string => {
+ for (const code of [source, target]) {
+ if (!Object.prototype.hasOwnProperty.call(basePrompt.languageNames, code)) {
+ throw RnExecuTorchError('INVALID_ARGUMENT', `translate: unsupported language '${code}'.`);
+ }
+ }
+ const { template, languageNames } =
+ promptOverrides[source] ?? promptOverrides[target] ?? basePrompt;
+ const values: Record = {
+ source: languageNames[source]!,
+ target: languageNames[target]!,
+ text: line,
+ };
+ return template.replace(/\{(source|target|text)\}/g, (_, key: string) => values[key]!);
+ };
+
+ const translateLine = async (
+ line: string,
+ source: string,
+ target: string,
+ onPiece: (piece: string) => void
+ ): Promise => {
+ const content = userTurn(line, source, target);
+ runner.reset();
+ const prompt = chatPreprocessor.process([{ role: 'user', content }], 1, {
+ addGenPrompt: true,
+ });
+ try {
+ let started = false;
+ const response = await generate(runner, prompt, GENERATION_CONFIG, stopTokens, (token) => {
+ const piece = started ? token : token.trimStart();
+ if (piece === '') return;
+ started = true;
+ onPiece(piece);
+ });
+ return response.trimStart();
+ } finally {
+ chatPreprocessor.clear();
+ }
+ };
+
+ const translate = async (
+ text: string,
+ languages: TranslationLanguages,
+ onToken?: (token: string) => void
+ ): Promise => {
+ const { sourceLanguage: source, targetLanguage: target } = languages;
+ // Validates the pair before anything is queued.
+ userTurn('', source, target);
+ if (busy) {
+ throw RnExecuTorchError('RESOURCE_BUSY', 'translate: A translation is in progress.');
+ }
+
+ busy = true;
+ stopped = false;
+ let output = '';
+ // Text between translated lines (line breaks, blank lines) is streamed
+ // ahead of the next translated piece, so the pieces keep their order.
+ let pending = '';
+
+ try {
+ // Odd indices hold the line breaks themselves.
+ const parts = text.split(/(\n+)/);
+ for (let i = 0; i < parts.length && !stopped; i++) {
+ const part = parts[i]!;
+ const line = part.trim();
+ if (i % 2 === 1 || line === '') {
+ output += part;
+ pending += part;
+ continue;
+ }
+
+ const lead = part.slice(0, part.length - part.trimStart().length);
+ const trail = part.slice(part.trimEnd().length);
+ // Pieces arrive later on the RN thread, so each line captures the
+ // separator it streams ahead of its first piece.
+ const prefix = pending + lead;
+ let first = true;
+ const translated = await translateLine(line, source, target, (piece) => {
+ onToken?.(first ? prefix + piece : piece);
+ first = false;
+ });
+ output += lead + translated + trail;
+ pending = translated === '' ? prefix + trail : trail;
+ }
+ if (!stopped && pending !== '') onToken?.(pending);
+ return output;
+ } finally {
+ busy = false;
+ }
+ };
+
+ const translateStop = () => {
+ if (!busy) return;
+ stopped = true;
+ runner.stop();
+ };
+
+ return { dispose, translate, translateStop };
+ } catch (error) {
+ dispose();
+ throw error;
+ }
+}
diff --git a/packages/react-native-executorch/src/extensions/llm/utils/streamDecoding.ts b/packages/react-native-executorch/src/extensions/llm/utils/streamDecoding.ts
new file mode 100644
index 0000000000..0fb8aedb89
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/llm/utils/streamDecoding.ts
@@ -0,0 +1,45 @@
+/**
+ * Incremental detokenization for streamed LLM generation.
+ */
+
+import type { Tokenizer } from '../../nlp';
+
+/**
+ * Decodes newly generated tokens for streaming, in the context of the tokens
+ * streamed just before them.
+ *
+ * Decoding the new tokens on their own runs the tokenizer's whole decoder
+ * chain on them, so a chain that strips a leading space (SentencePiece-style
+ * `Strip(start=1)`, e.g. EuroLLM) would drop every word break. Decoding them
+ * after the previous group and keeping only what they add matches decoding the
+ * whole sequence at once.
+ * @param tokenizer The model's tokenizer.
+ * @param previous The token group streamed last, or `undefined` at the start.
+ * @param pending The new tokens to decode.
+ * @param skipSpecialTokens Whether to omit special tokens.
+ * @returns The text the pending tokens add, or `undefined` if they end in an
+ * incomplete UTF-8 sequence and more tokens are needed.
+ */
+export function decodeStreamed(
+ tokenizer: Tokenizer,
+ previous: Int32Array | undefined,
+ pending: Int32Array,
+ skipSpecialTokens: boolean
+): string | undefined {
+ 'worklet';
+ if (previous === undefined || previous.length === 0) {
+ return tokenizer.tryDecode(pending, skipSpecialTokens);
+ }
+ const prefix = tokenizer.tryDecode(previous, skipSpecialTokens);
+ const joined = new Int32Array(previous.length + pending.length);
+ joined.set(previous);
+ joined.set(pending, previous.length);
+ const text = tokenizer.tryDecode(joined, skipSpecialTokens);
+ if (text === undefined) return undefined;
+ // The previous group decoded on its own before, so a mismatch means the
+ // decoder rewrote its tail; fall back to the pending tokens alone.
+ if (prefix === undefined || !text.startsWith(prefix)) {
+ return tokenizer.tryDecode(pending, skipSpecialTokens);
+ }
+ return text.slice(prefix.length);
+}
diff --git a/packages/react-native-executorch/src/extensions/nlp/index.ts b/packages/react-native-executorch/src/extensions/nlp/index.ts
index 476fe02d37..915c861e5e 100644
--- a/packages/react-native-executorch/src/extensions/nlp/index.ts
+++ b/packages/react-native-executorch/src/extensions/nlp/index.ts
@@ -1,7 +1,8 @@
/**
- * Natural Language Processing (NLP) tokenization, text embedding, and privacy
- * filtering pipelines.
+ * Natural Language Processing (NLP) tokenization, text embedding, privacy
+ * filtering, and translation pipelines.
*/
export * from './tokenizer';
export * from './utils/privacyFilterUtils';
+export * from './utils/translationUtils';
diff --git a/packages/react-native-executorch/src/extensions/nlp/tasks/marianTranslation.ts b/packages/react-native-executorch/src/extensions/nlp/tasks/marianTranslation.ts
new file mode 100644
index 0000000000..424d7e0053
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/nlp/tasks/marianTranslation.ts
@@ -0,0 +1,249 @@
+/**
+ * Marian (Opus-MT) machine translation task pipeline.
+ */
+
+import type { WorkletRuntime } from 'react-native-worklets';
+import { scheduleOnRN, createSynchronizable } from 'react-native-worklets';
+
+import { tensor, type Tensor } from '../../../core/tensor';
+import { loadModel } from '../../../core/model';
+import { wrapAsync } from '../../../core/runtime';
+import {
+ constraint,
+ f32,
+ i64,
+ method,
+ validateSpec,
+ DynamicDim as Dyn,
+} from '../../../core/schema';
+import { createResourceScope } from '../../../core/lifetime';
+
+import { argmax } from '../../math';
+import { loadTokenizer } from '../tokenizer';
+import { RnExecuTorchError } from '../../../core/error';
+import {
+ splitSentences,
+ type TranslationLanguages,
+ type Translator,
+} from '../utils/translationUtils';
+
+// Positions in the decoder's self-attention KV cache, i.e. the most tokens one
+// sentence can translate to.
+const MAX_TARGET_LEN = 256;
+// One cross-attention key and value per decoder layer (Opus-MT has 6).
+const NUM_CROSS_KV = 12;
+
+/**
+ * Paths and metadata required to instantiate a Marian (Opus-MT) translator.
+ * Each model translates one fixed language pair.
+ * @category NLP / Types
+ */
+export type MarianTranslatorModel = {
+ /** Identifies the pipeline this config belongs to. */
+ readonly name: 'marian';
+ /** Local path or remote URL of the `.pte` model file. */
+ readonly modelPath: string;
+ /** Local path or remote URL of the tokenizer file. */
+ readonly tokenizerPath: string;
+ /** ISO 639-1 code of the language the model translates from. */
+ readonly sourceLanguage: string;
+ /** ISO 639-1 code of the language the model translates to. */
+ readonly targetLanguage: string;
+};
+
+/**
+ * Marian (Opus-MT) translation task runner. The text is split into sentences,
+ * which are translated one at a time.
+ * @category NLP / Types
+ */
+export type MarianTranslator = Translator & {
+ /**
+ * Synchronous version of {@link Translator.translate} to be executed
+ * directly on the caller or worklet thread.
+ */
+ readonly translateWorklet: (
+ text: string,
+ languages: TranslationLanguages,
+ onToken?: (token: string) => void
+ ) => string;
+};
+
+/**
+ * Creates a translator for Marian encoder-decoder models such as Opus-MT.
+ *
+ * The source sentence is encoded once into per-layer cross-attention keys and
+ * values, then decoded greedily one token at a time, starting from the pad
+ * token, until the end-of-sentence token. Sentences longer than the model's
+ * maximum source length are truncated.
+ * @category NLP / Tasks
+ * @param config Model and tokenizer paths. See {@link MarianTranslatorModel}.
+ * @param runtime Optional worklet runtime thread on which to run the model
+ * execution.
+ * @returns A promise resolving to the instantiated {@link MarianTranslator}.
+ * @throws {RnExecuTorchError} With code `LOAD_FAILED` if the model or
+ * tokenizer fails to load, or `SCHEMA_MISMATCH` if the model schema does not
+ * match the Marian specification.
+ */
+export async function createMarianTranslator(
+ config: MarianTranslatorModel,
+ runtime?: WorkletRuntime
+): Promise {
+ const scope = createResourceScope();
+ const dispose = scope.dispose;
+
+ try {
+ const { modelPath, tokenizerPath } = config;
+ const model = scope.track(await wrapAsync(loadModel, runtime)(modelPath));
+ const tokenizer = scope.track(await wrapAsync(loadTokenizer, runtime)(tokenizerPath));
+
+ // The source length S must agree across the ids, the mask and every
+ // cross-attention key and value.
+ const crossKv = Array(NUM_CROSS_KV).fill(f32('H', Dyn('S'), 'Dh'));
+ const kvIndices = [...Array(NUM_CROSS_KV).keys()];
+ const sourceDim = (paramSide: 'input' | 'output', tensorIdx: number) =>
+ ({ paramSide, tensorIdx, dimIdx: 1 }) as const;
+ const { dims } = validateSpec(model.schema, {
+ default: {
+ ...method('encode', [i64(1, Dyn('S')), i64(1, Dyn('S'))], crossKv, [
+ constraint.equality(sourceDim('input', 0), sourceDim('input', 1)),
+ ...kvIndices.map((i) =>
+ constraint.equality(sourceDim('input', 0), sourceDim('output', i))
+ ),
+ ]),
+ ...method(
+ 'decode',
+ [i64(1, 1), i64(1), i64(1, Dyn('S')), ...crossKv],
+ [f32(1, 1, 'V')],
+ kvIndices.map((i) =>
+ constraint.equality(sourceDim('input', 2), sourceDim('input', 3 + i))
+ )
+ ),
+ },
+ });
+
+ const [sourceLen] = dims.range('S');
+ const [numHeads, headDim, vocabSize] = dims.constant('H', 'Dh', 'V');
+ // Core ML exports a fixed source length; shorter inputs are padded and
+ // masked.
+ const isPadded = sourceLen.min === sourceLen.max;
+
+ const padToken = tokenizer.tokenToId('');
+ const eosToken = tokenizer.tokenToId('');
+
+ const tensors = [
+ tensor('int64', [1, 1]), // tToken
+ tensor('int64', [1]), // tPosition
+ tensor('float32', [1, 1, vocabSize]), // tLogits
+ tensor('int32', [1, 1, 1]), // tArgmax
+ ] as const;
+ tensors.forEach(scope.track);
+ const [tToken, tPosition, tLogits, tArgmax] = tensors;
+
+ // Encoder inputs and outputs depend on the source length, so they are
+ // allocated per sentence unless that length is fixed.
+ const allocateSource = (len: number): Tensor[] => {
+ 'worklet';
+ return [
+ tensor('int64', [1, len]), // tSourceIds
+ tensor('int64', [1, len]), // tSourceMask
+ ...Array.from({ length: NUM_CROSS_KV }, () => tensor('float32', [numHeads, len, headDim])),
+ ];
+ };
+ const paddedSource = isPadded ? allocateSource(sourceLen.max) : [];
+ paddedSource.forEach(scope.track);
+
+ const isCancelled = createSynchronizable(false);
+
+ const translateSentence = (text: string, onText: (text: string) => void): string => {
+ 'worklet';
+ const ids = Array.from(tokenizer.encode(text));
+ if (ids[ids.length - 1] !== eosToken) ids.push(eosToken);
+ if (ids.length > sourceLen.max) {
+ ids.length = sourceLen.max;
+ ids[ids.length - 1] = eosToken;
+ }
+
+ const len = isPadded ? sourceLen.max : ids.length;
+ const idsData = new BigInt64Array(len).fill(BigInt(padToken));
+ const maskData = new BigInt64Array(len);
+ ids.forEach((id, i) => {
+ idsData[i] = BigInt(id);
+ maskData[i] = 1n;
+ });
+
+ const source = isPadded ? paddedSource : allocateSource(len);
+ try {
+ const [tSourceIds, tSourceMask, ...tCrossKv] = source as [Tensor, Tensor, ...Tensor[]];
+ tSourceIds.setData(idsData);
+ tSourceMask.setData(maskData);
+ model.execute('encode', [tSourceIds, tSourceMask], tCrossKv);
+
+ const generated: number[] = [];
+ let decoded = '';
+ let token = padToken;
+ for (let position = 0; position < MAX_TARGET_LEN; position++) {
+ if (isCancelled.getBlocking()) break;
+
+ tToken.setData(BigInt64Array.of(BigInt(token)));
+ tPosition.setData(BigInt64Array.of(BigInt(position)));
+ model.execute('decode', [tToken, tPosition, tSourceMask, ...tCrossKv], [tLogits]);
+ token = tLogits.through(argmax, tArgmax).getData(new Int32Array(1))[0]!;
+ if (token === eosToken) break;
+
+ generated.push(token);
+ // SentencePiece spacing depends on the following piece, so stream
+ // the growth of the whole decoded text instead of single pieces.
+ const next = tokenizer.decode(Int32Array.from(generated));
+ if (next.startsWith(decoded) && next.length > decoded.length) {
+ onText(next.slice(decoded.length));
+ }
+ decoded = next;
+ }
+ return decoded;
+ } finally {
+ if (!isPadded) source.forEach((t) => t.dispose());
+ }
+ };
+
+ const { sourceLanguage, targetLanguage } = config;
+ const translateWorklet = (
+ text: string,
+ languages: TranslationLanguages,
+ onToken?: (token: string) => void
+ ): string => {
+ 'worklet';
+ if (
+ languages.sourceLanguage !== sourceLanguage ||
+ languages.targetLanguage !== targetLanguage
+ ) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `translate: this model only translates ${sourceLanguage} to ${targetLanguage}.`
+ );
+ }
+ isCancelled.setBlocking(false);
+
+ const emit = (piece: string) => {
+ 'worklet';
+ if (onToken && piece !== '') scheduleOnRN(onToken, piece);
+ };
+
+ let result = '';
+ for (const { sentence, separator } of splitSentences(text)) {
+ if (isCancelled.getBlocking()) break;
+ if (sentence.trim() !== '') result += translateSentence(sentence, emit);
+ emit(separator);
+ result += separator;
+ }
+ return result.trim();
+ };
+
+ const translate = wrapAsync(translateWorklet, runtime);
+ const translateStop = () => isCancelled.setBlocking(true);
+
+ return { dispose, translate, translateWorklet, translateStop };
+ } catch (error) {
+ dispose();
+ throw error;
+ }
+}
diff --git a/packages/react-native-executorch/src/extensions/nlp/utils/translationUtils.ts b/packages/react-native-executorch/src/extensions/nlp/utils/translationUtils.ts
new file mode 100644
index 0000000000..80110fe9a2
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/nlp/utils/translationUtils.ts
@@ -0,0 +1,88 @@
+/**
+ * Types and text segmentation shared by the translation pipelines.
+ */
+
+/**
+ * Source and target language of one translation, as ISO 639-1 codes.
+ * @category NLP / Types
+ */
+export type TranslationLanguages = {
+ /** ISO 639-1 code of the language to translate from. */
+ readonly sourceLanguage: string;
+ /** ISO 639-1 code of the language to translate to. */
+ readonly targetLanguage: string;
+};
+
+/**
+ * Translation task runner, shared by every translation pipeline.
+ * @category NLP / Types
+ */
+export type Translator = {
+ /**
+ * Releases all allocated native resources.
+ */
+ readonly dispose: () => void;
+
+ /**
+ * Asynchronously translates text. Line breaks are kept.
+ * @param text The text to translate.
+ * @param languages The source and target language. A model built for one
+ * language pair accepts only that pair.
+ * @param onToken Optional callback fired on the RN thread with each newly
+ * decoded piece of the translation.
+ * @returns A promise resolving to the translated text.
+ * @throws {RnExecuTorchError} With code `INVALID_ARGUMENT` if the model does
+ * not translate between `languages`, `RESOURCE_BUSY` if the model is in use,
+ * or `RESOURCE_DISPOSED` if disposed.
+ */
+ readonly translate: (
+ text: string,
+ languages: TranslationLanguages,
+ onToken?: (token: string) => void
+ ) => Promise;
+
+ /**
+ * Interrupts an active translation call, which returns the text translated
+ * so far.
+ */
+ readonly translateStop: () => void;
+};
+
+/**
+ * A sentence and the whitespace that followed it in the source text.
+ * @category NLP / Types
+ */
+export type SentenceSegment = {
+ /** The sentence, including its closing punctuation. */
+ readonly sentence: string;
+ /** The line breaks that followed it, or a single space if there were none. */
+ readonly separator: string;
+};
+
+/**
+ * Splits text into sentences for sentence-level translation models. Line
+ * breaks are kept as separators so the translation keeps the source layout.
+ * @category NLP / Functions
+ * @param text The text to split.
+ * @returns The sentences in order, each with the separator that followed it.
+ * The last separator is empty.
+ */
+export function splitSentences(text: string): SentenceSegment[] {
+ 'worklet';
+ const segments: SentenceSegment[] = [];
+ // Closing punctuation (optionally followed by closing quotes or brackets) and
+ // the whitespace after it, or a run of line breaks. Built per call: a regex
+ // literal at module scope cannot be captured by a worklet.
+ const boundary = /([.!?…。!?]+["'”’»)\]]*)(\s+)|(\n+)/g;
+ let start = 0;
+ let match: RegExpExecArray | null;
+ while ((match = boundary.exec(text)) !== null) {
+ const end = match.index + (match[1]?.length ?? 0);
+ const whitespace = match[2] ?? match[3]!;
+ const breaks = whitespace.replace(/[^\n]/g, '');
+ segments.push({ sentence: text.slice(start, end), separator: breaks || ' ' });
+ start = match.index + match[0].length;
+ }
+ segments.push({ sentence: text.slice(start), separator: '' });
+ return segments;
+}
diff --git a/packages/react-native-executorch/src/hooks/useTranslator.ts b/packages/react-native-executorch/src/hooks/useTranslator.ts
new file mode 100644
index 0000000000..b464f2b323
--- /dev/null
+++ b/packages/react-native-executorch/src/hooks/useTranslator.ts
@@ -0,0 +1,78 @@
+import { useModel } from './useModel';
+import { useResourceDownload, type ResourceOptions } from './useResourceDownload';
+import {
+ createMarianTranslator,
+ type MarianTranslatorModel,
+} from '../extensions/nlp/tasks/marianTranslation';
+import {
+ createLLMTranslator,
+ type LLMTranslatorModel,
+} from '../extensions/llm/tasks/llmTranslation';
+import type { Translator } from '../extensions/nlp/utils/translationUtils';
+
+/**
+ * Configuration of any translation pipeline.
+ * @category NLP / Types
+ */
+export type TranslatorModel = MarianTranslatorModel | LLMTranslatorModel;
+
+/**
+ * React hook to load and run a translation pipeline: Marian (Opus-MT), one
+ * model per language pair, or an LLM (EuroLLM, Hy-MT2) that translates between
+ * any two of its languages.
+ *
+ * This hook manages downloading (if remote URLs are provided) and loading the
+ * model and tokenizer, tracking download progress and load errors, and
+ * releasing native memory when the component unmounts or the configuration
+ * changes.
+ *
+ * For imperative usage, see {@link createMarianTranslator} and
+ * {@link createLLMTranslator}.
+ * @category Hooks
+ * @param config The translation model configuration. See
+ * {@link TranslatorModel}.
+ * @param options Load and caching options. See {@link ResourceOptions}.
+ * @returns The same object as {@link Translator} (without `dispose`),
+ * combined with loading state and download progress.
+ * @see {@link Translator}
+ */
+export function useTranslator(
+ config: C,
+ options?: ResourceOptions
+): {
+ /** Whether the pipeline is loaded and ready to translate. */
+ isReady: boolean;
+ /** The download or load error, if any. */
+ error: Error | undefined;
+ /** Download progress across every asset, in percent. */
+ downloadProgress: number;
+ /** The config with every remote URL resolved to a local path. */
+ resource: C | undefined;
+ /** Translates text. Undefined until the pipeline is ready. */
+ translate: Translator['translate'] | undefined;
+ /** Cancels an in-flight translation. Undefined until the pipeline is ready. */
+ translateStop: Translator['translateStop'] | undefined;
+} {
+ // Each config names the pipeline it belongs to, so the factory is resolved
+ // from that tag alone.
+ const factory = (translatorConfig: TranslatorModel): Promise => {
+ switch (translatorConfig.name) {
+ case 'marian':
+ return createMarianTranslator(translatorConfig);
+ case 'llm':
+ return createLLMTranslator(translatorConfig);
+ }
+ };
+
+ const { resource, downloadProgress, downloadError } = useResourceDownload(config, options);
+ const { model, error } = useModel(factory, resource);
+
+ return {
+ isReady: !!model,
+ error: downloadError || error,
+ downloadProgress,
+ resource,
+ translate: model?.translate,
+ translateStop: model?.translateStop,
+ };
+}
diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts
index c69ba20399..b5c6fc885f 100644
--- a/packages/react-native-executorch/src/index.ts
+++ b/packages/react-native-executorch/src/index.ts
@@ -57,6 +57,7 @@ export * from './hooks/useLLMChatSession';
export * from './hooks/useTextEmbedder';
export * from './hooks/useColbertEmbedder';
export * from './hooks/usePrivacyFilter';
+export * from './hooks/useTranslator';
export * from './hooks/useImageEmbedder';
export * from './hooks/useVoiceActivityDetector';
export * from './hooks/useSpeechToText';
@@ -86,10 +87,12 @@ export * from './extensions/cv/tasks/imageEmbedding';
export * from './extensions/cv/tasks/sdxsTextToImage';
export * from './extensions/cv/tasks/paddleOcr';
export * from './extensions/llm/tasks/llmChatSession';
+export * from './extensions/llm/tasks/llmTranslation';
export * from './extensions/nlp/tasks/tokenization';
export * from './extensions/nlp/tasks/textEmbedding';
export * from './extensions/nlp/tasks/colbertEmbedding';
export * from './extensions/nlp/tasks/privacyFilter';
+export * from './extensions/nlp/tasks/marianTranslation';
export * from './extensions/speech/tasks/fsmnVoiceActivityDetection';
export * from './extensions/speech/tasks/whisperSpeechToText';
export * from './extensions/speech/tasks/qwen3AsrSpeechToText';
diff --git a/packages/react-native-executorch/src/models.ts b/packages/react-native-executorch/src/models.ts
index 41045110d4..3637134b94 100644
--- a/packages/react-native-executorch/src/models.ts
+++ b/packages/react-native-executorch/src/models.ts
@@ -29,6 +29,8 @@ import type { SdxsTextToImageModel } from './extensions/cv/tasks/sdxsTextToImage
import type { TextEmbedderModel } from './extensions/nlp/tasks/textEmbedding';
import type { ColbertEmbedderModel } from './extensions/nlp/tasks/colbertEmbedding';
import type { PrivacyFilterModel } from './extensions/nlp/tasks/privacyFilter';
+import type { MarianTranslatorModel } from './extensions/nlp/tasks/marianTranslation';
+import type { LLMTranslatorModel } from './extensions/llm/tasks/llmTranslation';
import type { FsmnVadModel } from './extensions/speech/tasks/fsmnVoiceActivityDetection';
import type { SupertonicTtsModel } from './extensions/speech/tasks/supertonicTextToSpeech';
import type { KokoroTtsModel } from './extensions/speech/tasks/kokoroTextToSpeech';
@@ -52,6 +54,9 @@ import {
SUPERTONIC_DEFAULT_VOICE_NAMES,
PRIVACY_FILTER_OPENAI_LABELS,
PRIVACY_FILTER_NEMOTRON_LABELS,
+ EUROLLM_LANGUAGES,
+ HY_MT2_LANGUAGES,
+ HY_MT2_LANGUAGES_ZH,
type PrivacyFilterOpenaiLabel,
type PrivacyFilterNemotronLabel,
type ImageNet1KLabel,
@@ -1893,6 +1898,110 @@ const PPOCRV6_SMALL_VULKAN_FP16: PaddleOcrModel = {
modelOpts: PPOCRV6_OPTS,
};
+// =============================================================================
+// Translation
+// =============================================================================
+const OPUS_MT_BASE_URL = `${BASE_URL}/opus-mt-ExecuTorch/${VERSION_TAG}`;
+
+const OPUS_MT_EN_DE_XNNPACK_INT8: MarianTranslatorModel = {
+ name: 'marian',
+ modelPath: `${OPUS_MT_BASE_URL}/en_de/xnnpack/opus_mt_en_de_xnnpack_int8.pte`,
+ tokenizerPath: `${OPUS_MT_BASE_URL}/en_de/tokenizer.json`,
+ sourceLanguage: 'en',
+ targetLanguage: 'de',
+};
+const OPUS_MT_DE_EN_XNNPACK_INT8: MarianTranslatorModel = {
+ name: 'marian',
+ modelPath: `${OPUS_MT_BASE_URL}/de_en/xnnpack/opus_mt_de_en_xnnpack_int8.pte`,
+ tokenizerPath: `${OPUS_MT_BASE_URL}/de_en/tokenizer.json`,
+ sourceLanguage: 'de',
+ targetLanguage: 'en',
+};
+const OPUS_MT_EN_ES_XNNPACK_INT8: MarianTranslatorModel = {
+ name: 'marian',
+ modelPath: `${OPUS_MT_BASE_URL}/en_es/xnnpack/opus_mt_en_es_xnnpack_int8.pte`,
+ tokenizerPath: `${OPUS_MT_BASE_URL}/en_es/tokenizer.json`,
+ sourceLanguage: 'en',
+ targetLanguage: 'es',
+};
+const OPUS_MT_ES_EN_XNNPACK_INT8: MarianTranslatorModel = {
+ name: 'marian',
+ modelPath: `${OPUS_MT_BASE_URL}/es_en/xnnpack/opus_mt_es_en_xnnpack_int8.pte`,
+ tokenizerPath: `${OPUS_MT_BASE_URL}/es_en/tokenizer.json`,
+ sourceLanguage: 'es',
+ targetLanguage: 'en',
+};
+const OPUS_MT_EN_FR_XNNPACK_INT8: MarianTranslatorModel = {
+ name: 'marian',
+ modelPath: `${OPUS_MT_BASE_URL}/en_fr/xnnpack/opus_mt_en_fr_xnnpack_int8.pte`,
+ tokenizerPath: `${OPUS_MT_BASE_URL}/en_fr/tokenizer.json`,
+ sourceLanguage: 'en',
+ targetLanguage: 'fr',
+};
+const OPUS_MT_FR_EN_XNNPACK_INT8: MarianTranslatorModel = {
+ name: 'marian',
+ modelPath: `${OPUS_MT_BASE_URL}/fr_en/xnnpack/opus_mt_fr_en_xnnpack_int8.pte`,
+ tokenizerPath: `${OPUS_MT_BASE_URL}/fr_en/tokenizer.json`,
+ sourceLanguage: 'fr',
+ targetLanguage: 'en',
+};
+
+const EUROLLM_BASE_URL = `${BASE_URL}/EuroLLM-1.7B-Instruct-ExecuTorch/${VERSION_TAG}/1_7b`;
+
+const EUROLLM_PROMPT = {
+ template:
+ 'Translate the following {source} source text to {target}:\n{source}: {text} \n{target}: ',
+ languageNames: EUROLLM_LANGUAGES,
+};
+
+const EUROLLM_1_7B_XNNPACK_8DA8W: LLMTranslatorModel = {
+ name: 'llm',
+ modelPath: `${EUROLLM_BASE_URL}/xnnpack/eurollm_1_7b_xnnpack_8da8w.pte`,
+ tokenizerPath: `${EUROLLM_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${EUROLLM_BASE_URL}/tokenizer_config.json`,
+ prompt: EUROLLM_PROMPT,
+};
+const EUROLLM_1_7B_MLX_INT8: LLMTranslatorModel = {
+ name: 'llm',
+ modelPath: `${EUROLLM_BASE_URL}/mlx/eurollm_1_7b_mlx_int8.pte`,
+ tokenizerPath: `${EUROLLM_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${EUROLLM_BASE_URL}/tokenizer_config.json`,
+ prompt: EUROLLM_PROMPT,
+};
+
+const HY_MT2_BASE_URL = `${BASE_URL}/Hy-MT2-1.8B-ExecuTorch/${VERSION_TAG}/1_8b`;
+
+// The model card names only the target. Pairs with Chinese take its Chinese
+// prompt, with Chinese language names.
+const HY_MT2_PROMPT_ZH = {
+ template: '将以下文本翻译为{target},注意只需要输出翻译后的结果,不要额外解释:\n\n{text}',
+ languageNames: HY_MT2_LANGUAGES_ZH,
+};
+const HY_MT2_PROMPTS = {
+ prompt: {
+ template:
+ 'Translate the following text into {target}. Note that you should only output ' +
+ 'the translated result without any additional explanation:\n\n{text}',
+ languageNames: HY_MT2_LANGUAGES,
+ },
+ promptOverrides: { 'zh': HY_MT2_PROMPT_ZH, 'zh-Hant': HY_MT2_PROMPT_ZH, 'yue': HY_MT2_PROMPT_ZH },
+};
+
+const HY_MT2_1_8B_XNNPACK_8DA4W: LLMTranslatorModel = {
+ name: 'llm',
+ modelPath: `${HY_MT2_BASE_URL}/xnnpack/hy_mt2_1_8b_xnnpack_8da4w.pte`,
+ tokenizerPath: `${HY_MT2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HY_MT2_BASE_URL}/tokenizer_config.json`,
+ ...HY_MT2_PROMPTS,
+};
+const HY_MT2_1_8B_MLX_INT8: LLMTranslatorModel = {
+ name: 'llm',
+ modelPath: `${HY_MT2_BASE_URL}/mlx/hy_mt2_1_8b_mlx_int8.pte`,
+ tokenizerPath: `${HY_MT2_BASE_URL}/tokenizer.json`,
+ tokenizerConfigPath: `${HY_MT2_BASE_URL}/tokenizer_config.json`,
+ ...HY_MT2_PROMPTS,
+};
+
// =============================================================================
// LLMs
// =============================================================================
@@ -3468,6 +3577,35 @@ export const models = {
}),
},
+ /**
+ * Machine translation models. Opus-MT models each translate one language
+ * pair, a sentence at a time.
+ */
+ translation: {
+ /** Opus-MT English to German. */
+ OPUS_MT_EN_DE: variants({ XNNPACK_INT8: OPUS_MT_EN_DE_XNNPACK_INT8 }),
+ /** Opus-MT German to English. */
+ OPUS_MT_DE_EN: variants({ XNNPACK_INT8: OPUS_MT_DE_EN_XNNPACK_INT8 }),
+ /** Opus-MT English to Spanish. */
+ OPUS_MT_EN_ES: variants({ XNNPACK_INT8: OPUS_MT_EN_ES_XNNPACK_INT8 }),
+ /** Opus-MT Spanish to English. */
+ OPUS_MT_ES_EN: variants({ XNNPACK_INT8: OPUS_MT_ES_EN_XNNPACK_INT8 }),
+ /** Opus-MT English to French. */
+ OPUS_MT_EN_FR: variants({ XNNPACK_INT8: OPUS_MT_EN_FR_XNNPACK_INT8 }),
+ /** Opus-MT French to English. */
+ OPUS_MT_FR_EN: variants({ XNNPACK_INT8: OPUS_MT_FR_EN_XNNPACK_INT8 }),
+ /** EuroLLM 1.7B Instruct: any two of its 35 languages with one model. */
+ EUROLLM_1_7B: variants({
+ XNNPACK_8DA8W: EUROLLM_1_7B_XNNPACK_8DA8W,
+ MLX_INT8: EUROLLM_1_7B_MLX_INT8,
+ }),
+ /** Hy-MT2 1.8B: any two of its 38 languages and dialects with one model. */
+ HY_MT2_1_8B: variants({
+ XNNPACK_8DA4W: HY_MT2_1_8B_XNNPACK_8DA4W,
+ MLX_INT8: HY_MT2_1_8B_MLX_INT8,
+ }),
+ },
+
/**
* Image feature extraction and vision embedding models.
*/