From d488e2d7568eb17f685a69a9d1287cd8d007cb44 Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Thu, 16 Jul 2026 22:20:55 +0800 Subject: [PATCH 1/4] reflector: use rust inference --- .cnb.yml | 17 +- Cargo.lock | 628 +++++++++++++++++---------------------------- Cargo.toml | 5 +- Dockerfile | 9 +- Dockerfile.enzh | 4 +- README.md | 32 +-- README_ZH.md | 32 +-- src/endpoint.rs | 41 ++- src/inference.rs | 412 +++++++++++++++++++++++++++++ src/main.rs | 133 ++++++---- src/translation.rs | 107 +++++--- 11 files changed, 866 insertions(+), 554 deletions(-) create mode 100644 src/inference.rs diff --git a/.cnb.yml b/.cnb.yml index ffd8ee2..3e4bd90 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -7,7 +7,8 @@ main: - name: download models script: | set -e - mkdir -p models-enzh/enzh + rm -rf models/en-zh + mkdir -p models/en-zh models_json_url="https://storage.googleapis.com/moz-fx-translations-data--303e-prod-translations-data/db/models.json" echo "Fetching models.json from $models_json_url" curl -fsSL "$models_json_url" -o models.json || { echo "Failed to download models.json"; exit 1; } @@ -17,8 +18,8 @@ main: exit 1 fi echo "Base URL: $base_url" - # Get the first en-zh model (architecture: base) - model_data=$(jq -r '.models."en-zh"[0]' models.json) + # Get the en-zh base model + model_data=$(jq -r '[.models."en-zh"[] | select(.architecture == "base")][0]' models.json) if [ -z "$model_data" ] || [ "$model_data" = "null" ]; then echo "No en-zh model found in models.json" exit 1 @@ -41,18 +42,16 @@ main: fi file=$(basename "$file_path") echo "Downloading $file from $base_url/$file_path" - curl -fsSL "$base_url/$file_path" -o "models-enzh/enzh/$file" || { echo "Failed to download $file"; exit 1; } - gunzip -f "models-enzh/enzh/$file" - extracted_file="${file%.gz}" - echo "$extracted_file downloaded and extracted" + curl -fsSL "$base_url/$file_path" -o "models/en-zh/$file" || { echo "Failed to download $file"; exit 1; } + echo "$file downloaded" done rm -f models.json echo "Download completed. Model structure:" pwd - ls -R models-enzh + ls -R models - name: docker login script: docker login -u ${CNB_TOKEN_USER_NAME} -p "${CNB_TOKEN}" ${CNB_DOCKER_REGISTRY} - name: docker build - script: docker build -t ${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:latest -f Dockerfile.enzh . + script: docker build -t ${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:latest -f Dockerfile.enzh models - name: docker push script: docker push ${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}:latest diff --git a/Cargo.lock b/Cargo.lock index ba7e3d8..3b947fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -13,9 +19,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "atomic-waker" @@ -25,9 +31,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -77,25 +83,15 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cfg-if" @@ -104,57 +100,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "core-foundation" -version = "0.9.4" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "core-foundation-sys", - "libc", + "cfg-if", ] [[package]] -name = "core-foundation-sys" +name = "crossbeam-deque" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] [[package]] -name = "errno" -version = "0.3.14" +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ - "libc", - "windows-sys 0.61.2", + "crossbeam-utils", ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "find-msvc-tools" -version = "0.1.7" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "foreign-types-shared", + "libc", + "windows-sys", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] [[package]] name = "form_urlencoded" @@ -167,54 +176,48 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-task", "pin-project-lite", - "pin-utils", + "slab", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -222,9 +225,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -232,9 +235,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -257,9 +260,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -271,19 +274,17 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", ] [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", - "futures-core", "http", "http-body", "hyper", @@ -293,14 +294,13 @@ dependencies = [ ] [[package]] -name = "intel-mkl-tool" -version = "0.8.1" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "887a16b4537d82227af54d3372971cfa5e0cde53322e60f57584056c16ada1b4" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "anyhow", - "log", - "walkdir", + "equivalent", + "hashbrown", ] [[package]] @@ -314,9 +314,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lazy_static" @@ -326,18 +326,30 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libyaml-rs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "linguaspark" version = "0.1.0" -source = "git+https://github.com/LinguaSpark/core.git?branch=main#6a2bc711cce73bc053675c0293dd37c8029e62dd" -dependencies = [ - "intel-mkl-tool", - "minreq", +source = "git+https://github.com/LinguaSpark/linguaspark.git?rev=ed78244#ed782448088a6a0f4d93f9e922a20fa16cce4024" +dependencies = [ + "rayon", + "rten-gemm", + "rten-simd", + "rten-tensor", + "rten-vecmath", + "sentencepiece-rs", + "serde", "thiserror", + "yaml_serde", ] [[package]] @@ -346,6 +358,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "flate2", "isolang", "linguaspark", "serde", @@ -358,12 +371,6 @@ dependencies = [ "whichlang", ] -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "lock_api" version = "0.4.14" @@ -375,9 +382,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchers" @@ -396,9 +403,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -407,40 +414,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "minreq" -version = "2.14.1" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "native-tls", + "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "windows-sys", ] [[package]] @@ -449,57 +440,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking_lot" @@ -550,45 +498,47 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] [[package]] name = "redox_syscall" @@ -601,9 +551,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -612,75 +562,76 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "rustix" -version = "1.1.3" +name = "rten-base" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "2738cf8bb4c27f828ac788d01ccf4e367e8e773cfec6851f81851b5211de6a79" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "rayon", ] [[package]] -name = "ryu" -version = "1.0.22" +name = "rten-gemm" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "330a81a0ca209fb5ce21bd17efa0bd287d5881c6cebfbff0b21c4294a1a14a9e" +dependencies = [ + "rayon", + "rten-base", + "rten-simd", + "rten-tensor", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "rten-simd" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "b19a0032dfcb70dd20960c1c51a37674b237586cbc1ce586f45b46605d108e82" + +[[package]] +name = "rten-tensor" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05dc744a270aa32d154f1a3df8e48740ccc1be9dfbcf23295ada66d83aa98de6" dependencies = [ - "winapi-util", + "rayon", + "rten-base", + "smallvec", + "typeid", ] [[package]] -name = "schannel" -version = "0.1.28" +name = "rten-vecmath" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "9574ddebf5671bc08ceb76e2e1638fadc57fdeff318634eab2c29e9a803cff64" dependencies = [ - "windows-sys 0.61.2", + "rten-base", + "rten-simd", ] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "security-framework" -version = "2.11.1" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "security-framework-sys" -version = "2.15.0" +name = "sentencepiece-rs" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "6bc4483945bfd66b8727ed59005af1543d3d966ca92f9bc065bfef532de1d7e8" [[package]] name = "serde" @@ -714,9 +665,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -757,12 +708,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -773,33 +718,45 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] name = "syn" -version = "2.0.114" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -812,33 +769,20 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -847,18 +791,18 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" dependencies = [ "bytes", "libc", @@ -868,14 +812,14 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -900,14 +844,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" dependencies = [ "bitflags", "bytes", "http", "http-body", + "percent-encoding", "pin-project-lite", "tower-layer", "tower-service", @@ -972,9 +917,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -988,11 +933,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "valuable" @@ -1000,67 +951,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "whichlang" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b9aa3ad29c3d08283ac6b769e3ec15ad1ddb88af7d2e9bc402c574973b937e7" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1071,78 +979,20 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.53.5" +name = "yaml_serde" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "zmij" -version = "1.0.13" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e24d8ad..4d33e2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,17 +6,18 @@ edition = "2024" [dependencies] anyhow = "1" axum = "0.8" +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } isolang = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } -tower-http = { version = "0.6", features = ["cors", "trace"] } +tower-http = { version = "0.7", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } whichlang = "0.1" -linguaspark = { git = "https://github.com/LinguaSpark/core.git", branch = "main" } +linguaspark = { git = "https://github.com/LinguaSpark/linguaspark.git", rev = "ed78244" } [profile.release] strip = true diff --git a/Dockerfile b/Dockerfile index f8cdefe..9ef2531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,20 +3,15 @@ FROM rust:bookworm AS builder WORKDIR /app COPY . . -RUN cargo build --release - -RUN mkdir -p /app/lib && \ - find /app/target/release/build -name "linguaspark-*" -type d | xargs -I {} find {} -path "*/out/*.so" -type f | xargs -I {} cp {} /app/lib/ && \ - ls -l /app/lib +RUN cargo build --release --locked FROM debian:bookworm-slim WORKDIR /app COPY --from=builder /app/target/release/linguaspark-server /app/linguaspark-server -COPY --from=builder /app/lib/*.so /lib/x86_64-linux-gnu/ ENV MODELS_DIR=/app/models -ENV NUM_WORKERS=1 +ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 # ENV ENV_API_KEY= diff --git a/Dockerfile.enzh b/Dockerfile.enzh index 59a9926..b4f799b 100644 --- a/Dockerfile.enzh +++ b/Dockerfile.enzh @@ -1,9 +1,9 @@ FROM ghcr.io/linguaspark/server:main -COPY ./models-enzh /app/models +COPY en-zh /app/models/en-zh ENV MODELS_DIR=/app/models -ENV NUM_WORKERS=1 +ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 # ENV ENV_API_KEY= diff --git a/README.md b/README.md index 0c68caf..bafca26 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub Repo](https://img.shields.io/badge/GitHub-Repository-blue.svg)](https://github.com/LinguaSpark/server) [![Docker Image](https://img.shields.io/badge/Docker-Image-blue.svg)](https://github.com/LinguaSpark/server/pkgs/container/translation-service) -A lightweight multilingual translation service based on Rust and Bergamot translation engine, compatible with multiple translation frontend APIs. +A lightweight multilingual translation service powered by the pure Rust LinguaSpark inference engine and compatible with multiple translation frontend APIs. [简体中文](README_ZH.md) @@ -16,7 +16,7 @@ While searching for similar projects, I found Mozilla's [translation-service](ht ## Features - 💪 Written in Rust for excellent performance and low memory footprint -- 🔄 Based on [Bergamot Translator](https://github.com/browsermt/bergamot-translator) engine used in Firefox +- 🔄 Pure Rust inference through [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - 🧠 Compatible with [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - 🔍 Built-in language detection with automatic source language identification - 🔌 Supports multiple translation API formats: @@ -31,7 +31,7 @@ While searching for similar projects, I found Mozilla's [translation-service](ht ## Tech Stack - **Web Framework**: [Axum](https://github.com/tokio-rs/axum) -- **Translation Engine**: [Bergamot Translator](https://github.com/browsermt/bergamot-translator) +- **Translation Engine**: [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - **Translation Models**: [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - **Language Detection**: [Whichlang](https://github.com/quickwit-oss/whichlang) @@ -60,7 +60,7 @@ docker run -d --name translation-service \ docker.cnb.cool/aalivexy/translation-service:latest ``` -> Note: The English-Chinese model image is about 70MiB, and each worker uses approximately 300MiB+ of memory with low translation latency. +> Note: Model weights are shared by all inference executors. When `NUM_WORKERS` is unset, the service uses the available logical CPU count. ### Docker Compose Deployment @@ -100,14 +100,14 @@ FROM ghcr.io/linguaspark/server:main COPY ./your-models-directory /app/models ENV MODELS_DIR=/app/models -ENV NUM_WORKERS=1 +ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 ENV RUST_LOG=info EXPOSE 3000 -ENTRYPOINT ["/app/server"] +ENTRYPOINT ["/app/linguaspark-server"] ``` ## Translation Models @@ -119,25 +119,27 @@ ENTRYPOINT ["/app/server"] ``` models/ -├── enzh/ # Language pair directory name format: "[source language code][target language code]" -│ ├── model.intgemm8.bin # Translation model -│ ├── model.s2t.bin # Shortlist file -│ ├── srcvocab.spm # Source language vocabulary -│ └── trgvocab.spm # Target language vocabulary +├── en-zh/ # Both "en-zh" and the legacy "enzh" form are accepted +│ ├── model.enzh.intgemm.alphas.bin.gz +│ ├── lex.50.50.enzh.s2t.bin.gz +│ ├── srcvocab.enzh.spm.gz +│ └── trgvocab.enzh.spm.gz └── zhen/ # Another language pair └── ... ``` +Assets may be gzip-compressed (`.gz`) or already decompressed. Models with a single `vocab*.spm[.gz]` file are also supported as shared-vocabulary models. + ### Language Pair Support -The translation service will automatically scan all language pair directories under the `models` directory and load them. Directory names should follow the `[source language][target language]` format using [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language codes. +The translation service automatically scans all language pair directories under `models`. Directory names must use either `enzh` or `en-zh` form with [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language codes. ## Environment Variables | Variable Name | Description | Default Value | |---------------|-------------|---------------| | `MODELS_DIR` | Path to models directory | `/app/models` | -| `NUM_WORKERS` | Number of translation worker threads | `1` | +| `NUM_WORKERS` | Global maximum number of concurrent inference tasks; empty uses available logical CPUs | `""` | | `IP` | IP address for the service to listen on | `127.0.0.1` | | `PORT` | Port for the service to listen on | `3000` | | `API_KEY` | API key (leave empty to disable) | `""` | @@ -316,7 +318,7 @@ Response: ## Authentication -If the `API_KEY` environment variable is set, all API requests must provide authentication credentials using one of the following methods: +If the `API_KEY` environment variable is set, all API requests except `GET /health` must provide authentication credentials using one of the following methods: 1. Authorization header: `Authorization: Bearer your_api_key` 2. Query parameter: `?token=your_api_key` @@ -327,7 +329,7 @@ This project is open-sourced under the AGPL-3.0 license. ## Acknowledgements -- [Bergamot Translator](https://github.com/browsermt/bergamot-translator) - Translation engine +- [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - Pure Rust translation inference - [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - Translation models - [MTranServer](https://github.com/xxnuo/MTranServer/) - Inspiration - [Mozilla Translation Service](https://github.com/mozilla/translation-service/) - Reference implementation diff --git a/README_ZH.md b/README_ZH.md index 5deb0bb..e0e4301 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -3,7 +3,7 @@ [![GitHub Repo](https://img.shields.io/badge/GitHub-Repository-blue.svg)](https://github.com/LinguaSpark/server) [![Docker Image](https://img.shields.io/badge/Docker-Image-blue.svg)](https://github.com/LinguaSpark/server/pkgs/container/translation-service) -一个基于 Rust 和 Bergamot 翻译引擎的轻量级多语言翻译服务,兼容多种翻译前端 API。 +一个由纯 Rust LinguaSpark 推理引擎驱动的轻量级多语言翻译服务,兼容多种翻译前端 API。 [English](README.md) @@ -16,7 +16,7 @@ ## 功能特性 - 💪 使用 Rust 编写,性能优异,内存占用低 -- 🔄 基于 Firefox 同款的 [Bergamot Translator](https://github.com/browsermt/bergamot-translator) 引擎 +- 🔄 使用纯 Rust [LinguaSpark](https://github.com/LinguaSpark/linguaspark) 推理引擎 - 🧠 兼容 [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - 🔍 内置语言检测,支持自动识别源语言 - 🔌 支持多种翻译前端 API 格式: @@ -31,7 +31,7 @@ ## 技术栈 - **Web 框架**: [Axum](https://github.com/tokio-rs/axum) -- **翻译引擎**: [Bergamot Translator](https://github.com/browsermt/bergamot-translator) +- **翻译引擎**: [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - **翻译模型**: [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - **语言检测**: [Whichlang](https://github.com/quickwit-oss/whichlang) @@ -47,7 +47,7 @@ docker run -d --name translation-service \ docker.cnb.cool/aalivexy/translation-service:latest ``` -> 注意:自带英译中模型的镜像大小约 70MiB,启动后单 worker 大约占用内存 300MiB+,且翻译延迟较低。 +> 注意:所有推理执行器共享模型权重。未设置 `NUM_WORKERS` 时,服务会使用可用逻辑 CPU 数量。 ### 方式二:使用预构建镜像(不含翻译模型) @@ -98,14 +98,14 @@ FROM ghcr.io/linguaspark/server:main COPY ./your-models-directory /app/models ENV MODELS_DIR=/app/models -ENV NUM_WORKERS=1 +ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 ENV RUST_LOG=info EXPOSE 3000 -ENTRYPOINT ["/app/server"] +ENTRYPOINT ["/app/linguaspark-server"] ``` ## 翻译模型 @@ -117,25 +117,27 @@ ENTRYPOINT ["/app/server"] ``` models/ -├── enzh/ # 语言对目录名格式为 "[源语言代码][目标语言代码]" -│ ├── model.intgemm8.bin # 翻译模型 -│ ├── model.s2t.bin # shortlist 文件 -│ ├── srcvocab.spm # 源语言词表 -│ └── trgvocab.spm # 目标语言词表 +├── en-zh/ # 同时接受 "en-zh" 和旧版 "enzh" 形式 +│ ├── model.enzh.intgemm.alphas.bin.gz +│ ├── lex.50.50.enzh.s2t.bin.gz +│ ├── srcvocab.enzh.spm.gz +│ └── trgvocab.enzh.spm.gz └── zhen/ # 另一个语言对 └── ... ``` +模型资产既可以保留 `.gz` 压缩,也可以使用已解压文件。只有一个 `vocab*.spm[.gz]` 的共享词表模型同样受支持。 + ### 语言对支持 -翻译服务会自动扫描 `models` 目录下的所有语言对目录,并加载它们。目录名应遵循 `[源语言][目标语言]` 的格式,使用 [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) 语言代码。 +翻译服务会自动扫描 `models` 目录下的语言对目录并加载模型。目录名必须使用 `enzh` 或 `en-zh` 形式,并采用 [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) 语言代码。 ## 环境变量 | 变量名 | 描述 | 默认值 | |--------|------|--------| | `MODELS_DIR` | 模型目录路径 | `/app/models` | -| `NUM_WORKERS` | 翻译工作线程数 | `1` | +| `NUM_WORKERS` | 整个进程的最大并发推理数;留空时使用可用逻辑 CPU 数量 | `""` | | `IP` | 服务监听的 IP 地址 | `127.0.0.1` | | `PORT` | 服务监听的端口 | `3000` | | `API_KEY` | API 密钥(留空则不启用) | `""` | @@ -314,7 +316,7 @@ GET /health ## 认证 -如果设置了 `API_KEY` 环境变量,所有 API 请求都需要提供认证凭据,支持两种方式: +如果设置了 `API_KEY` 环境变量,除 `GET /health` 外的所有 API 请求都需要提供认证凭据,支持两种方式: 1. Authorization 头: `Authorization: Bearer your_api_key` 2. 查询参数: `?token=your_api_key` @@ -325,7 +327,7 @@ GET /health ## 致谢 -- [Bergamot Translator](https://github.com/browsermt/bergamot-translator) - 提供翻译引擎 +- [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - 提供纯 Rust 翻译推理 - [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - 提供翻译模型 - [MTranServer](https://github.com/xxnuo/MTranServer/) - 提供灵感来源 - [Mozilla Translation Service](https://github.com/mozilla/translation-service/) - 提供参考实现 diff --git a/src/endpoint.rs b/src/endpoint.rs index 0910a4d..c830e9d 100644 --- a/src/endpoint.rs +++ b/src/endpoint.rs @@ -1,6 +1,6 @@ use crate::{ AppError, AppState, - translation::{detect_language_code, perform_translation}, + translation::{detect_language_code, perform_batch_translation, perform_translation}, }; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; @@ -102,22 +102,19 @@ pub async fn translate_immersive( State(state): State>, Json(request): Json, ) -> Result, AppError> { - let mut translations = Vec::with_capacity(request.text_list.len()); - - for text in request.text_list { - let (translated_text, from_lang, _) = perform_translation( - &state, - &text, - request.source_lang.clone(), - &request.target_lang, - ) - .await?; - - translations.push(ImmersiveTranslationItem { - detected_source_lang: from_lang, - text: translated_text, - }); - } + let translations = perform_batch_translation( + &state, + request.text_list, + request.source_lang, + &request.target_lang, + ) + .await? + .into_iter() + .map(|(text, detected_source_lang)| ImmersiveTranslationItem { + detected_source_lang, + text, + }) + .collect(); Ok(Json(ImmersiveTranslationResponse { translations })) } @@ -142,21 +139,21 @@ pub async fn translate_hcfy( Json(request): Json, ) -> Result, AppError> { const LANGUAGE_CODE_MAP: &[(&str, &str)] = - &[("中文(简体)", "zh"), ("英语", "en"), ("日语", "jp")]; + &[("中文(简体)", "zh"), ("英语", "en"), ("日语", "ja")]; fn convert_language_name(lang: &str) -> String { LANGUAGE_CODE_MAP .iter() .find(|&&(name, _)| name == lang) .map(|&(_, code)| code) - .unwrap_or_else(|| lang) + .unwrap_or(lang) .to_string() } fn get_language_name(code: &str) -> String { LANGUAGE_CODE_MAP .iter() - .find(|&&(_, c)| c == code) + .find(|&&(_, mapped_code)| mapped_code == code) .map(|&(name, _)| name) .unwrap_or(code) .to_string() @@ -190,7 +187,7 @@ pub async fn translate_hcfy( #[derive(Debug, Deserialize)] pub struct DeeplxTranslationRequest { text: String, - source_lang: String, + source_lang: Option, target_lang: String, } @@ -212,7 +209,7 @@ pub async fn translate_deeplx( let (text, from_lang, to_lang) = perform_translation( &state, &request.text, - Some(request.source_lang.to_lowercase()), + request.source_lang.map(|lang| lang.to_lowercase()), &request.target_lang.to_lowercase(), ) .await?; diff --git a/src/inference.rs b/src/inference.rs new file mode 100644 index 0000000..5bb5102 --- /dev/null +++ b/src/inference.rs @@ -0,0 +1,412 @@ +use crate::{AppError, translation::parse_language_code}; +use flate2::read::GzDecoder; +use isolang::Language; +use linguaspark::{DecodeOptions, Executor, Model, ModelAssets, VocabularyAssets}; +use std::{ + collections::HashMap, + fs, + io::Read, + path::{Path, PathBuf}, +}; +use tokio::sync::{Mutex as AsyncMutex, mpsc}; + +struct ExecutorPool { + available_tx: mpsc::UnboundedSender, + available_rx: AsyncMutex>, +} + +impl ExecutorPool { + fn new(num_workers: usize) -> Result { + let (available_tx, available_rx) = mpsc::unbounded_channel(); + for worker in 0..num_workers { + let executor = Executor::new().map_err(|error| { + AppError::ConfigError(format!( + "Failed to create inference executor {}: {}", + worker + 1, + error + )) + })?; + available_tx.send(executor).map_err(|error| { + AppError::ConfigError(format!("Failed to initialize executor pool: {error}")) + })?; + } + Ok(Self { + available_tx, + available_rx: AsyncMutex::new(available_rx), + }) + } + + async fn acquire(&self) -> Result { + let executor = self.available_rx.lock().await.recv().await.ok_or_else(|| { + AppError::InferenceError("Inference executor pool was closed".to_string()) + })?; + Ok(ExecutorLease { + executor: Some(executor), + available_tx: self.available_tx.clone(), + }) + } + + async fn execute(&self, operation: F) -> Result + where + R: Send + 'static, + F: FnOnce(&mut Executor) -> Result + Send + 'static, + { + let mut lease = self.acquire().await?; + let task = tokio::task::spawn_blocking(move || operation(lease.executor_mut())); + + match task.await { + Ok(result) => result, + Err(error) if error.is_panic() => { + tracing::error!("Inference executor panicked: {error}"); + Err(AppError::InferenceError( + "Inference executor panicked".to_string(), + )) + } + Err(error) => Err(AppError::InferenceError(format!( + "Inference task failed: {error}" + ))), + } + } +} + +struct ExecutorLease { + executor: Option, + available_tx: mpsc::UnboundedSender, +} + +impl ExecutorLease { + fn executor_mut(&mut self) -> &mut Executor { + self.executor + .as_mut() + .expect("executor lease must contain an executor") + } +} + +impl Drop for ExecutorLease { + fn drop(&mut self) { + if let Some(executor) = self.executor.take() + && let Err(error) = self.available_tx.send(executor) + { + tracing::debug!("Executor pool closed while returning executor: {error}"); + } + } +} + +pub struct InferenceEngine { + models: HashMap<(Language, Language), Model>, + executors: ExecutorPool, +} + +impl InferenceEngine { + pub fn load(models_dir: &Path, num_workers: usize) -> Result { + if num_workers == 0 { + return Err(AppError::ConfigError( + "NUM_WORKERS must be at least 1".to_string(), + )); + } + + let mut directories = fs::read_dir(models_dir)?.collect::, _>>()?; + directories.sort_by_key(|entry| entry.file_name()); + + let mut models = HashMap::new(); + for entry in directories { + if !entry.path().is_dir() { + continue; + } + let path = entry.path(); + let directory_name = entry.file_name().to_string_lossy().into_owned(); + let (source, target) = parse_language_pair(&directory_name)?; + let source_code = iso_code(&source)?; + let target_code = iso_code(&target)?; + let key = (source, target); + if models.contains_key(&key) { + return Err(AppError::ConfigError(format!( + "Duplicate model for language pair '{}-{}'", + source_code, target_code + ))); + } + + tracing::info!( + "Loading model '{}-{}' from {}", + source_code, + target_code, + path.display() + ); + let assets = discover_model_assets(&path)?; + let model = Model::from_assets(assets).map_err(|error| { + AppError::ConfigError(format!( + "Failed to load model '{}-{}' from '{}': {}", + source_code, + target_code, + path.display(), + error + )) + })?; + models.insert(key, model); + } + + if models.is_empty() { + return Err(AppError::ConfigError(format!( + "No model directories found in '{}'", + models_dir.display() + ))); + } + + tracing::info!("Creating {num_workers} global inference executor(s)"); + Ok(Self { + models, + executors: ExecutorPool::new(num_workers)?, + }) + } + + pub fn sole_language_pair(&self) -> Option<(Language, Language)> { + let mut pairs = self.models.keys(); + let pair = *pairs.next()?; + pairs.next().is_none().then_some(pair) + } + + pub async fn translate( + &self, + from: Language, + to: Language, + text: &str, + ) -> Result { + let mut translations = self + .translate_batch(from, to, vec![text.to_string()]) + .await?; + translations.pop().ok_or_else(|| { + AppError::InferenceError("Single-input batch returned no translation".to_string()) + }) + } + + pub async fn translate_batch( + &self, + from: Language, + to: Language, + texts: Vec, + ) -> Result, AppError> { + if texts.is_empty() { + return Ok(Vec::new()); + } + + if let Some(model) = self.models.get(&(from, to)).cloned() { + return self.execute_batch(model, texts).await; + } + + let first = self + .models + .get(&(from, Language::Eng)) + .cloned() + .ok_or_else(|| unsupported_pair(from, to))?; + let second = self + .models + .get(&(Language::Eng, to)) + .cloned() + .ok_or_else(|| unsupported_pair(from, to))?; + let intermediate = self.execute_batch(first, texts).await?; + self.execute_batch(second, intermediate).await + } + + async fn execute_batch( + &self, + model: Model, + texts: Vec, + ) -> Result, AppError> { + self.executors + .execute(move |executor| { + executor + .translate_batch(&model, &texts, &DecodeOptions::default()) + .map(|translations| { + translations + .into_iter() + .map(|translation| translation.text) + .collect() + }) + .map_err(|error| AppError::InferenceError(error.to_string())) + }) + .await + } +} + +fn unsupported_pair(from: Language, to: Language) -> AppError { + AppError::TranslationError(format!( + "Translation from '{}' to '{}' is not supported", + display_code(&from), + display_code(&to) + )) +} + +fn parse_language_pair(name: &str) -> Result<(Language, Language), AppError> { + let (source, target) = if name.len() == 4 && name.is_ascii() { + (&name[..2], &name[2..]) + } else { + let mut parts = name.split('-'); + match (parts.next(), parts.next(), parts.next()) { + (Some(source), Some(target), None) => (source, target), + _ => { + return Err(AppError::ConfigError(format!( + "Invalid model directory '{}'; expected 'enzh' or 'en-zh'", + name + ))); + } + } + }; + Ok((parse_language_code(source)?, parse_language_code(target)?)) +} + +fn iso_code(language: &Language) -> Result<&'static str, AppError> { + if language.to_639_3() == "cmn" { + return Ok("zh"); + } + language.to_639_1().ok_or_else(|| { + AppError::ConfigError(format!( + "Language '{}' does not have an ISO 639-1 code", + language + )) + }) +} + +fn display_code(language: &Language) -> &'static str { + if language.to_639_3() == "cmn" { + "zh" + } else { + language.to_639_1().unwrap_or_else(|| language.to_639_3()) + } +} + +fn discover_model_assets(model_dir: &Path) -> Result { + let mut model_path = None; + let mut shortlist_path = None; + let mut shared_vocab_path = None; + let mut source_vocab_path = None; + let mut target_vocab_path = None; + + for entry in fs::read_dir(model_dir)? { + let entry = entry?; + if !entry.path().is_file() { + continue; + } + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().into_owned(); + let uncompressed_name = file_name.strip_suffix(".gz").unwrap_or(&file_name); + + if uncompressed_name.ends_with(".s2t.bin") { + set_unique(&mut shortlist_path, path, "shortlist", model_dir)?; + } else if uncompressed_name.ends_with(".bin") && uncompressed_name.contains(".intgemm") { + set_unique(&mut model_path, path, "model", model_dir)?; + } else if uncompressed_name.ends_with(".spm") { + if uncompressed_name.starts_with("srcvocab") { + set_unique(&mut source_vocab_path, path, "source vocabulary", model_dir)?; + } else if uncompressed_name.starts_with("trgvocab") { + set_unique(&mut target_vocab_path, path, "target vocabulary", model_dir)?; + } else if uncompressed_name.starts_with("vocab") { + set_unique(&mut shared_vocab_path, path, "shared vocabulary", model_dir)?; + } + } + } + + let required = |path: Option, kind: &str| { + path.ok_or_else(|| { + AppError::ConfigError(format!( + "Missing {} in model directory '{}'", + kind, + model_dir.display() + )) + }) + }; + let model = read_asset(&required(model_path, "model")?)?; + let shortlist = read_asset(&required(shortlist_path, "shortlist")?)?; + let vocabularies = match (shared_vocab_path, source_vocab_path, target_vocab_path) { + (Some(shared), None, None) => VocabularyAssets::Shared(read_asset(&shared)?), + (None, Some(source), Some(target)) => VocabularyAssets::Separate { + source: read_asset(&source)?, + target: read_asset(&target)?, + }, + _ => { + return Err(AppError::ConfigError(format!( + "Model directory '{}' must contain either one shared vocabulary or one source and one target vocabulary", + model_dir.display() + ))); + } + }; + + Ok(ModelAssets { + model, + vocabularies, + shortlist, + }) +} + +fn read_asset(path: &Path) -> Result, AppError> { + let bytes = fs::read(path)?; + if path.extension().is_some_and(|extension| extension == "gz") { + let mut decoded = Vec::new(); + GzDecoder::new(bytes.as_slice()).read_to_end(&mut decoded)?; + Ok(decoded) + } else { + Ok(bytes) + } +} + +fn set_unique( + slot: &mut Option, + path: PathBuf, + kind: &str, + model_dir: &Path, +) -> Result<(), AppError> { + if slot.replace(path).is_some() { + return Err(AppError::ConfigError(format!( + "Multiple {} files found in model directory '{}'", + kind, + model_dir.display() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::ExecutorPool; + use crate::AppError; + use std::sync::Arc; + + #[tokio::test] + async fn executor_returns_after_success_and_error() { + let pool = ExecutorPool::new(1).unwrap(); + assert_eq!(pool.execute(|_| Ok(42)).await.unwrap(), 42); + + let error = pool + .execute::<(), _>(|_| Err(AppError::InferenceError("expected".to_string()))) + .await; + assert!(error.is_err()); + assert_eq!(pool.execute(|_| Ok(7)).await.unwrap(), 7); + } + + #[tokio::test] + async fn executor_returns_after_panic() { + let pool = ExecutorPool::new(1).unwrap(); + let error = pool + .execute::<(), _>(|_| panic!("expected test panic")) + .await + .unwrap_err(); + assert!(matches!(error, AppError::InferenceError(_))); + assert_eq!(pool.execute(|_| Ok(11)).await.unwrap(), 11); + } + + #[tokio::test] + async fn executor_pool_enforces_global_concurrency_limit() { + let pool = Arc::new(ExecutorPool::new(1).unwrap()); + let lease = pool.acquire().await.unwrap(); + let second_pool = Arc::clone(&pool); + let mut second = Box::pin(second_pool.execute(|_| Ok(()))); + + tokio::select! { + biased; + result = &mut second => panic!("second task started without an available executor: {result:?}"), + _ = std::future::ready(()) => {} + } + + drop(lease); + second.await.unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 733812a..8e2d1eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,13 @@ use anyhow::Context; use axum::{ Router, - extract::Json, + extract::{Json, State}, http::{HeaderMap, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, }; use isolang::Language; -use linguaspark::Translator; use std::{fs, io, net::SocketAddr, path::PathBuf, sync::Arc}; use tokio::{net::TcpListener, signal}; use tower_http::{ @@ -17,9 +16,10 @@ use tower_http::{ }, trace::TraceLayer, }; -use tracing::{debug, error, info}; +use tracing::{debug, info}; mod endpoint; +mod inference; mod translation; const ENV_MODELS_PATH: &str = "MODELS_DIR"; @@ -40,8 +40,8 @@ enum AppError { #[error("Unauthorized")] Unauthorized, - #[error("Translator error: {0}")] - TranslatorError(#[from] linguaspark::TranslatorError), + #[error("Inference error: {0}")] + InferenceError(String), #[error("Configuration error: {0}")] ConfigError(String), @@ -56,7 +56,7 @@ impl IntoResponse for AppError { StatusCode::UNAUTHORIZED, "Invalid or missing API key".to_string(), ), - AppError::TranslatorError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + AppError::InferenceError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), AppError::ConfigError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), }; @@ -65,18 +65,36 @@ impl IntoResponse for AppError { } struct AppState { - translator: Translator, - models: Vec<(Language, Language)>, + inference: inference::InferenceEngine, + sole_language_pair: Option<(Language, Language)>, + api_key: Option, +} + +fn resolve_num_workers( + value: Option<&str>, + available_parallelism: Option, +) -> Result { + match value.filter(|value| !value.is_empty()) { + Some(value) => value + .parse::() + .ok() + .filter(|&workers| workers > 0) + .ok_or_else(|| { + AppError::ConfigError(format!( + "NUM_WORKERS must be a positive integer, got '{value}'" + )) + }), + None => Ok(available_parallelism.unwrap_or(1)), + } } async fn auth_middleware( + State(state): State>, headers: HeaderMap, request: axum::extract::Request, next: Next, ) -> Result { - let expected_key = std::env::var(ENV_API_KEY).unwrap_or_default(); - - if !expected_key.is_empty() { + if let Some(expected_key) = state.api_key.as_deref() { let header_key = headers .get("Authorization") .and_then(|header| header.to_str().ok()) @@ -93,7 +111,7 @@ async fn auth_middleware( }) }); - if header_key != Some(&expected_key) && query_key != Some(&expected_key) { + if header_key != Some(expected_key) && query_key != Some(expected_key) { debug!("Invalid API key"); return Err(AppError::Unauthorized); } @@ -101,37 +119,6 @@ async fn auth_middleware( Ok(next.run(request).await) } -fn load_models_manually( - translator: &Translator, - models_dir: &PathBuf, -) -> Result, AppError> { - let mut models = Vec::new(); - - for entry in fs::read_dir(models_dir)? { - let entry = entry?; - let model_dir_path = entry.path(); - let language_pair = entry.file_name().to_string_lossy().into_owned(); - - info!("Looking for models in {}", model_dir_path.display()); - translator.load_model(&language_pair, model_dir_path)?; - - if language_pair.len() >= 4 { - let from_lang = translation::parse_language_code(&language_pair[0..2])?; - let to_lang = translation::parse_language_code(&language_pair[2..4])?; - models.push((from_lang, to_lang)); - } else { - return Err(AppError::ConfigError(format!( - "Invalid language pair format: '{}'. Expected format like 'enzh', 'jpen'", - language_pair - ))); - } - - info!("Loaded model for language pair '{}'", language_pair); - } - - Ok(models) -} - async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() @@ -185,10 +172,21 @@ async fn main() -> anyhow::Result<()> { default_dir }); - let num_workers = std::env::var(ENV_NUM_WORKERS) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(1); + let configured_workers = std::env::var(ENV_NUM_WORKERS).ok(); + let available_parallelism = if configured_workers.as_deref().is_none_or(str::is_empty) { + std::thread::available_parallelism() + .map(|parallelism| parallelism.get()) + .map_err(|error| { + tracing::warn!( + "Failed to detect available CPU parallelism: {error}; using 1 worker" + ); + error + }) + .ok() + } else { + None + }; + let num_workers = resolve_num_workers(configured_workers.as_deref(), available_parallelism)?; let server_ip = std::env::var(ENV_SERVER_IP).unwrap_or_else(|_| "127.0.0.1".to_string()); let server_port = std::env::var(ENV_SERVER_PORT) @@ -197,15 +195,20 @@ async fn main() -> anyhow::Result<()> { .unwrap_or(3000); let server_address = format!("{}:{}", server_ip, server_port); - - info!("Initializing translator with {} workers", num_workers); - let translator = Translator::new(num_workers).context("Failed to initialize translator")?; + let api_key = std::env::var(ENV_API_KEY) + .ok() + .filter(|key| !key.is_empty()); info!("Loading translation models from {}", models_dir.display()); - let models = load_models_manually(&translator, &models_dir) + let inference = inference::InferenceEngine::load(&models_dir, num_workers) .context("Failed to load translation models")?; + let sole_language_pair = inference.sole_language_pair(); - let app_state = Arc::new(AppState { translator, models }); + let app_state = Arc::new(AppState { + inference, + sole_language_pair, + api_key, + }); let cors = CorsLayer::new() .allow_origin(AllowOrigin::mirror_request()) @@ -214,13 +217,19 @@ async fn main() -> anyhow::Result<()> { .allow_headers(AllowHeaders::mirror_request()) .allow_private_network(AllowPrivateNetwork::yes()); - let app = Router::new() + let protected_routes = Router::new() .route("/translate", post(endpoint::translate)) .route("/kiss", post(endpoint::translate_kiss)) .route("/imme", post(endpoint::translate_immersive)) .route("/hcfy", post(endpoint::translate_hcfy)) .route("/deeplx", post(endpoint::translate_deeplx)) .route("/detect", post(endpoint::detect_language)) + .route_layer(middleware::from_fn_with_state( + Arc::clone(&app_state), + auth_middleware, + )); + + let app = Router::new() .route( "/health", get(async || { @@ -229,7 +238,7 @@ async fn main() -> anyhow::Result<()> { })) }), ) - .route_layer(middleware::from_fn(auth_middleware)) + .merge(protected_routes) .layer(TraceLayer::new_for_http()) .layer(cors) .with_state(app_state); @@ -254,3 +263,19 @@ async fn main() -> anyhow::Result<()> { info!("Server has been shut down gracefully"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::resolve_num_workers; + + #[test] + fn resolves_worker_configuration() { + assert_eq!(resolve_num_workers(None, Some(8)).unwrap(), 8); + assert_eq!(resolve_num_workers(Some(""), Some(4)).unwrap(), 4); + assert_eq!(resolve_num_workers(None, None).unwrap(), 1); + assert_eq!(resolve_num_workers(Some("3"), Some(8)).unwrap(), 3); + assert!(resolve_num_workers(Some("0"), Some(8)).is_err()); + assert!(resolve_num_workers(Some("invalid"), Some(8)).is_err()); + assert!(resolve_num_workers(Some("-1"), Some(8)).is_err()); + } +} diff --git a/src/translation.rs b/src/translation.rs index 9dd0298..298e206 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -25,16 +25,28 @@ fn get_iso_code(lang: &Language) -> Result<&'static str, AppError> { } pub fn detect_language_code(text: &str) -> Result<&'static str, AppError> { - get_iso_code( - &Language::from_639_3(whichlang::detect_language(text).three_letter_code()).ok_or_else( - || { - AppError::TranslationError(format!( - "Failed to identify language for text: '{}'", - text - )) - }, - )?, - ) + get_iso_code(&detect_language(text)?) +} + +fn detect_language(text: &str) -> Result { + Language::from_639_3(whichlang::detect_language(text).three_letter_code()).ok_or_else(|| { + AppError::TranslationError(format!("Failed to identify language for text: '{}'", text)) + }) +} + +fn resolve_source_language( + state: &AppState, + text: &str, + from_lang: Option<&str>, + target_lang: Language, +) -> Result { + match from_lang { + None | Some("") | Some("auto") => match state.sole_language_pair { + Some((source, target)) if target == target_lang => Ok(source), + _ => detect_language(text), + }, + Some(code) => parse_language_code(code), + } } pub async fn perform_translation( @@ -43,30 +55,10 @@ pub async fn perform_translation( from_lang: Option, to_lang: &str, ) -> Result<(String, String, String), AppError> { - let source_lang = match from_lang.as_deref() { - None | Some("") | Some("auto") => { - if state.models.len() == 1 { - // If there's only one model, use it as the source language - state - .models - .first() - .map(|model| model.0) - .unwrap_or(Language::Eng) - } else { - Language::from_639_3(whichlang::detect_language(text).three_letter_code()) - .ok_or_else(|| { - AppError::TranslationError(format!( - "Failed to detect language for text: '{}'", - text - )) - })? - } - } - Some(code) => parse_language_code(code)?, - }; - let target_lang = parse_language_code(to_lang)?; + let source_lang = resolve_source_language(state, text, from_lang.as_deref(), target_lang)?; + let from_code = get_iso_code(&source_lang)?; let to_code = get_iso_code(&target_lang)?; @@ -75,14 +67,51 @@ pub async fn perform_translation( return Ok((text.to_string(), from_code.to_string(), to_code.to_string())); } - if !state.translator.is_supported(from_code, to_code)? { - return Err(AppError::TranslationError(format!( - "Translation from '{}' to '{}' is not supported", - from_code, to_code - ))); + let translated_text = state + .inference + .translate(source_lang, target_lang, text) + .await?; + + Ok((translated_text, from_code.to_string(), to_code.to_string())) +} + +pub async fn perform_batch_translation( + state: &Arc, + texts: Vec, + from_lang: Option, + to_lang: &str, +) -> Result, AppError> { + if texts.is_empty() { + return Ok(Vec::new()); } - let translated_text = state.translator.translate(from_code, to_code, text)?; + let target_lang = parse_language_code(to_lang)?; + let source_lang = resolve_source_language(state, &texts[0], from_lang.as_deref(), target_lang)?; + let source_code = get_iso_code(&source_lang)?.to_string(); - Ok((translated_text, from_code.to_string(), to_code.to_string())) + if source_lang == target_lang { + return Ok(texts + .into_iter() + .map(|text| (text, source_code.clone())) + .collect()); + } + + let input_count = texts.len(); + let translations = state + .inference + .translate_batch(source_lang, target_lang, texts) + .await?; + + if translations.len() != input_count { + return Err(AppError::InferenceError(format!( + "Batch translation returned {} result(s) for {} input(s)", + translations.len(), + input_count + ))); + } + + Ok(translations + .into_iter() + .map(|translation| (translation, source_code.clone())) + .collect()) } From 2c3419f7f3ee6918ca607d5a41799d9e61b4304e Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Fri, 17 Jul 2026 20:18:56 +0800 Subject: [PATCH 2/4] clean up --- .cnb.yml | 6 +++--- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- Dockerfile | 1 - Dockerfile.enzh | 1 - README.md | 4 ---- README_ZH.md | 4 ---- 7 files changed, 9 insertions(+), 19 deletions(-) diff --git a/.cnb.yml b/.cnb.yml index 3e4bd90..a856c21 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -18,10 +18,10 @@ main: exit 1 fi echo "Base URL: $base_url" - # Get the en-zh base model - model_data=$(jq -r '[.models."en-zh"[] | select(.architecture == "base")][0]' models.json) + # Get the en-zh base-memory model + model_data=$(jq -r '[.models."en-zh"[] | select(.architecture == "base-memory")][0]' models.json) if [ -z "$model_data" ] || [ "$model_data" = "null" ]; then - echo "No en-zh model found in models.json" + echo "No en-zh base-memory model found in models.json" exit 1 fi # Extract file paths diff --git a/Cargo.lock b/Cargo.lock index 3b947fa..6e63d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,7 +339,7 @@ checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "linguaspark" version = "0.1.0" -source = "git+https://github.com/LinguaSpark/linguaspark.git?rev=ed78244#ed782448088a6a0f4d93f9e922a20fa16cce4024" +source = "git+https://github.com/LinguaSpark/linguaspark.git?rev=8725e69#8725e69e2826c812e88d0ac217c00647af29ece0" dependencies = [ "rayon", "rten-gemm", @@ -800,9 +800,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.4" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317fafbbe3f02fc663dad00ea6186197de963cd4190e86a26d8d0fae095539af" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -817,9 +817,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 4d33e2f..fe44948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } whichlang = "0.1" -linguaspark = { git = "https://github.com/LinguaSpark/linguaspark.git", rev = "ed78244" } +linguaspark = { git = "https://github.com/LinguaSpark/linguaspark.git", rev = "8725e69" } [profile.release] strip = true diff --git a/Dockerfile b/Dockerfile index 9ef2531..0098ee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,6 @@ WORKDIR /app COPY --from=builder /app/target/release/linguaspark-server /app/linguaspark-server ENV MODELS_DIR=/app/models -ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 # ENV ENV_API_KEY= diff --git a/Dockerfile.enzh b/Dockerfile.enzh index b4f799b..9cd5775 100644 --- a/Dockerfile.enzh +++ b/Dockerfile.enzh @@ -3,7 +3,6 @@ FROM ghcr.io/linguaspark/server:main COPY en-zh /app/models/en-zh ENV MODELS_DIR=/app/models -ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 # ENV ENV_API_KEY= diff --git a/README.md b/README.md index bafca26..540c032 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,6 @@ docker run -d --name translation-service \ docker.cnb.cool/aalivexy/translation-service:latest ``` -> Note: Model weights are shared by all inference executors. When `NUM_WORKERS` is unset, the service uses the available logical CPU count. - ### Docker Compose Deployment Create a `compose.yaml` file: @@ -100,7 +98,6 @@ FROM ghcr.io/linguaspark/server:main COPY ./your-models-directory /app/models ENV MODELS_DIR=/app/models -ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 ENV RUST_LOG=info @@ -139,7 +136,6 @@ The translation service automatically scans all language pair directories under | Variable Name | Description | Default Value | |---------------|-------------|---------------| | `MODELS_DIR` | Path to models directory | `/app/models` | -| `NUM_WORKERS` | Global maximum number of concurrent inference tasks; empty uses available logical CPUs | `""` | | `IP` | IP address for the service to listen on | `127.0.0.1` | | `PORT` | Port for the service to listen on | `3000` | | `API_KEY` | API key (leave empty to disable) | `""` | diff --git a/README_ZH.md b/README_ZH.md index e0e4301..ae8bc4f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -47,8 +47,6 @@ docker run -d --name translation-service \ docker.cnb.cool/aalivexy/translation-service:latest ``` -> 注意:所有推理执行器共享模型权重。未设置 `NUM_WORKERS` 时,服务会使用可用逻辑 CPU 数量。 - ### 方式二:使用预构建镜像(不含翻译模型) ```bash @@ -98,7 +96,6 @@ FROM ghcr.io/linguaspark/server:main COPY ./your-models-directory /app/models ENV MODELS_DIR=/app/models -ENV NUM_WORKERS= ENV IP=0.0.0.0 ENV PORT=3000 ENV RUST_LOG=info @@ -137,7 +134,6 @@ models/ | 变量名 | 描述 | 默认值 | |--------|------|--------| | `MODELS_DIR` | 模型目录路径 | `/app/models` | -| `NUM_WORKERS` | 整个进程的最大并发推理数;留空时使用可用逻辑 CPU 数量 | `""` | | `IP` | 服务监听的 IP 地址 | `127.0.0.1` | | `PORT` | 服务监听的端口 | `3000` | | `API_KEY` | API 密钥(留空则不启用) | `""` | From 63829f8cdcaf820603cb4d8b26d1ebcbd2b7fdb4 Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Sat, 18 Jul 2026 17:58:42 +0800 Subject: [PATCH 3/4] support native batch translation and improve configuration --- Cargo.lock | 2 +- Cargo.toml | 3 +- README.md | 39 +++++++++++++++++----- README_ZH.md | 38 ++++++++++++++++----- src/endpoint.rs | 48 ++++++++++++++++++++++++--- src/inference.rs | 83 +++++++++++++--------------------------------- src/main.rs | 46 +++++++++++-------------- src/translation.rs | 13 ++++++-- 8 files changed, 159 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e63d80..6b3b95e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,7 +339,7 @@ checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "linguaspark" version = "0.1.0" -source = "git+https://github.com/LinguaSpark/linguaspark.git?rev=8725e69#8725e69e2826c812e88d0ac217c00647af29ece0" +source = "git+https://github.com/LinguaSpark/linguaspark.git?rev=269605c#269605c0a6e78697d94162b71dd97a9d98885276" dependencies = [ "rayon", "rten-gemm", diff --git a/Cargo.toml b/Cargo.toml index fe44948..35ab451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } whichlang = "0.1" -linguaspark = { git = "https://github.com/LinguaSpark/linguaspark.git", rev = "8725e69" } +linguaspark = { git = "https://github.com/LinguaSpark/linguaspark.git", rev = "269605c" } [profile.release] strip = true -opt-level = "z" lto = true codegen-units = 1 diff --git a/README.md b/README.md index 540c032..0d542b7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A lightweight multilingual translation service powered by the pure Rust LinguaSp ## Project Background -This project originated when I discovered the [MTranServer](https://github.com/xxnuo/MTranServer/) repository, which uses [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) for machine translation and is compatible with APIs like Immersive Translate and Kiss Translator, but found that it wasn't open-sourced yet. +This project originated when I discovered the [MTranServer](https://github.com/xxnuo/MTranServer/) repository, which uses [Firefox Translations Models](https://github.com/mozilla/translations) for machine translation and is compatible with APIs like Immersive Translate and Kiss Translator, but found that it wasn't open-sourced yet. While searching for similar projects, I found Mozilla's [translation-service](https://github.com/mozilla/translation-service/), which works but hasn't been updated for a year and isn't compatible with Immersive Translate or Kiss Translator APIs. Since that project is written in C++ and I'm not very familiar with C++, I rewrote this project in Rust. @@ -17,7 +17,7 @@ While searching for similar projects, I found Mozilla's [translation-service](ht - 💪 Written in Rust for excellent performance and low memory footprint - 🔄 Pure Rust inference through [LinguaSpark](https://github.com/LinguaSpark/linguaspark) -- 🧠 Compatible with [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) +- 🧠 Compatible with [Firefox Translations Models](https://github.com/mozilla/translations) - 🔍 Built-in language detection with automatic source language identification - 🔌 Supports multiple translation API formats: - Native API @@ -32,7 +32,7 @@ While searching for similar projects, I found Mozilla's [translation-service](ht - **Web Framework**: [Axum](https://github.com/tokio-rs/axum) - **Translation Engine**: [LinguaSpark](https://github.com/LinguaSpark/linguaspark) -- **Translation Models**: [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) +- **Translation Models**: [Firefox Translations Models](https://github.com/mozilla/translations) - **Language Detection**: [Whichlang](https://github.com/quickwit-oss/whichlang) ## Deployment @@ -111,17 +111,17 @@ ENTRYPOINT ["/app/linguaspark-server"] ### Getting Models -1. Download pre-trained models from [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) +1. Download pre-trained models from [Firefox Translations Models](https://github.com/mozilla/translations) 2. Place them in the models directory with the following structure: ``` models/ -├── en-zh/ # Both "en-zh" and the legacy "enzh" form are accepted +├── en-zh/ │ ├── model.enzh.intgemm.alphas.bin.gz │ ├── lex.50.50.enzh.s2t.bin.gz │ ├── srcvocab.enzh.spm.gz │ └── trgvocab.enzh.spm.gz -└── zhen/ # Another language pair +└── zh-en/ # Another language pair └── ... ``` @@ -129,7 +129,7 @@ Assets may be gzip-compressed (`.gz`) or already decompressed. Models with a sin ### Language Pair Support -The translation service automatically scans all language pair directories under `models`. Directory names must use either `enzh` or `en-zh` form with [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language codes. +The translation service automatically scans all language pair directories under `models`. Directory names use the `source-target` form, such as `en-zh` or `zh-en`, with [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language codes. ## Environment Variables @@ -169,6 +169,28 @@ Response: } ``` +The native endpoint also accepts a batch in `text`. All texts in one batch are +translated as the same source language; when `from` is omitted, the first text +is used for language detection. + +```json +{ + "text": ["Hello world", "How are you?"], + "from": "en", + "to": "zh" +} +``` + +The response keeps the same shape: + +```json +{ + "text": ["你好世界", "你好吗?"], + "from": "en", + "to": "zh" +} +``` + #### Language Detection ``` @@ -326,6 +348,7 @@ This project is open-sourced under the AGPL-3.0 license. ## Acknowledgements - [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - Pure Rust translation inference -- [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - Translation models +- [Firefox Translations Models](https://github.com/mozilla/translations) - Translation models - [MTranServer](https://github.com/xxnuo/MTranServer/) - Inspiration - [Mozilla Translation Service](https://github.com/mozilla/translation-service/) - Reference implementation +- [Bergamot Translator](https://github.com/browsermt/bergamot-translator) - Reference implementation diff --git a/README_ZH.md b/README_ZH.md index ae8bc4f..e19bf12 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -9,7 +9,7 @@ ## 项目背景 -这个项目的起源是我看到了 [MTranServer](https://github.com/xxnuo/MTranServer/) 这个仓库,它使用了 [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) 进行机器翻译,并且兼容了沉浸式翻译、简约翻译等 API,但发现它目前还没开源。 +这个项目的起源是我看到了 [MTranServer](https://github.com/xxnuo/MTranServer/) 这个仓库,它使用了 [Firefox Translations Models](https://github.com/mozilla/translations) 进行机器翻译,并且兼容了沉浸式翻译、简约翻译等 API,但发现它目前还没开源。 在寻找类似项目时,我发现了 Mozilla 的 [translation-service](https://github.com/mozilla/translation-service/),虽然能用但有一年没更新了,也不兼容沉浸式翻译、简约翻译的 API。由于该项目是 C++ 编写的,而我对 C++ 不太熟悉,所以我使用 Rust 重新编写了这个项目。 @@ -17,7 +17,7 @@ - 💪 使用 Rust 编写,性能优异,内存占用低 - 🔄 使用纯 Rust [LinguaSpark](https://github.com/LinguaSpark/linguaspark) 推理引擎 -- 🧠 兼容 [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) +- 🧠 兼容 [Firefox Translations Models](https://github.com/mozilla/translations) - 🔍 内置语言检测,支持自动识别源语言 - 🔌 支持多种翻译前端 API 格式: - 原生 API @@ -32,7 +32,7 @@ - **Web 框架**: [Axum](https://github.com/tokio-rs/axum) - **翻译引擎**: [LinguaSpark](https://github.com/LinguaSpark/linguaspark) -- **翻译模型**: [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) +- **翻译模型**: [Firefox Translations Models](https://github.com/mozilla/translations) - **语言检测**: [Whichlang](https://github.com/quickwit-oss/whichlang) ## 部署 @@ -109,17 +109,17 @@ ENTRYPOINT ["/app/linguaspark-server"] ### 获取模型 -1. 从 [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) 下载预训练模型 +1. 从 [Firefox Translations Models](https://github.com/mozilla/translations) 下载预训练模型 2. 模型放置结构应为: ``` models/ -├── en-zh/ # 同时接受 "en-zh" 和旧版 "enzh" 形式 +├── en-zh/ │ ├── model.enzh.intgemm.alphas.bin.gz │ ├── lex.50.50.enzh.s2t.bin.gz │ ├── srcvocab.enzh.spm.gz │ └── trgvocab.enzh.spm.gz -└── zhen/ # 另一个语言对 +└── zh-en/ # 另一个语言对 └── ... ``` @@ -127,7 +127,7 @@ models/ ### 语言对支持 -翻译服务会自动扫描 `models` 目录下的语言对目录并加载模型。目录名必须使用 `enzh` 或 `en-zh` 形式,并采用 [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) 语言代码。 +翻译服务会自动扫描 `models` 目录下的语言对目录并加载模型。目录名使用 `源语言-目标语言` 形式,例如 `en-zh` 或 `zh-en`,并采用 [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) 语言代码。 ## 环境变量 @@ -167,6 +167,27 @@ POST /translate } ``` +原生接口的 `text` 也可以传入数组进行批量翻译。同一批文本会按相同的源语言处理; +省略 `from` 时,使用第一条文本检测源语言。 + +```json +{ + "text": ["Hello world", "How are you?"], + "from": "en", + "to": "zh" +} +``` + +响应会保持相同的数据形态: + +```json +{ + "text": ["你好世界", "你好吗?"], + "from": "en", + "to": "zh" +} +``` + #### 语言检测 ``` @@ -324,6 +345,7 @@ GET /health ## 致谢 - [LinguaSpark](https://github.com/LinguaSpark/linguaspark) - 提供纯 Rust 翻译推理 -- [Firefox Translations Models](https://github.com/mozilla/firefox-translations-models/) - 提供翻译模型 +- [Firefox Translations Models](https://github.com/mozilla/translations) - 提供翻译模型 - [MTranServer](https://github.com/xxnuo/MTranServer/) - 提供灵感来源 - [Mozilla Translation Service](https://github.com/mozilla/translation-service/) - 提供参考实现 +- [Bergamot Translator](https://github.com/browsermt/bergamot-translator) - 提供参考实现 diff --git a/src/endpoint.rs b/src/endpoint.rs index c830e9d..c4befcf 100644 --- a/src/endpoint.rs +++ b/src/endpoint.rs @@ -1,6 +1,9 @@ use crate::{ AppError, AppState, - translation::{detect_language_code, perform_batch_translation, perform_translation}, + translation::{ + detect_language_code, normalize_language_code, perform_batch_translation, + perform_translation, + }, }; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; @@ -24,16 +27,30 @@ pub async fn detect_language( })) } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum TranslationInput { + Single(String), + Batch(Vec), +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum TranslationOutput { + Single(String), + Batch(Vec), +} + #[derive(Debug, Deserialize)] pub struct TranslationRequest { - text: String, + text: TranslationInput, from: Option, to: String, } #[derive(Debug, Serialize)] pub struct TranslationResponse { - text: String, + text: TranslationOutput, from: String, to: String, } @@ -42,8 +59,29 @@ pub async fn translate( State(state): State>, Json(request): Json, ) -> Result, AppError> { - let (text, from_lang, to_lang) = - perform_translation(&state, &request.text, request.from, &request.to).await?; + let (text, from_lang, to_lang) = match request.text { + TranslationInput::Single(text) => { + let (text, from, to) = + perform_translation(&state, &text, request.from, &request.to).await?; + (TranslationOutput::Single(text), from, to) + } + TranslationInput::Batch(texts) => { + if texts.is_empty() { + return Err(AppError::TranslationError( + "Translation batch must contain at least one text".to_string(), + )); + } + let to = normalize_language_code(&request.to)?.to_string(); + let translations = + perform_batch_translation(&state, texts, request.from, &request.to).await?; + let from = translations + .first() + .map(|(_, from)| from.clone()) + .expect("non-empty translation batch must return source language"); + let texts = translations.into_iter().map(|(text, _)| text).collect(); + (TranslationOutput::Batch(texts), from, to) + } + }; Ok(Json(TranslationResponse { text, diff --git a/src/inference.rs b/src/inference.rs index 5bb5102..5bb53f0 100644 --- a/src/inference.rs +++ b/src/inference.rs @@ -203,8 +203,12 @@ impl InferenceEngine { .get(&(Language::Eng, to)) .cloned() .ok_or_else(|| unsupported_pair(from, to))?; - let intermediate = self.execute_batch(first, texts).await?; - self.execute_batch(second, intermediate).await + self.executors + .execute(move |executor| { + let intermediate = translate_model_batch(executor, &first, &texts)?; + translate_model_batch(executor, &second, &intermediate) + }) + .await } async fn execute_batch( @@ -213,21 +217,27 @@ impl InferenceEngine { texts: Vec, ) -> Result, AppError> { self.executors - .execute(move |executor| { - executor - .translate_batch(&model, &texts, &DecodeOptions::default()) - .map(|translations| { - translations - .into_iter() - .map(|translation| translation.text) - .collect() - }) - .map_err(|error| AppError::InferenceError(error.to_string())) - }) + .execute(move |executor| translate_model_batch(executor, &model, &texts)) .await } } +fn translate_model_batch( + executor: &mut Executor, + model: &Model, + texts: &[String], +) -> Result, AppError> { + executor + .translate_batch(model, texts, &DecodeOptions::default()) + .map(|translations| { + translations + .into_iter() + .map(|translation| translation.text) + .collect() + }) + .map_err(|error| AppError::InferenceError(error.to_string())) +} + fn unsupported_pair(from: Language, to: Language) -> AppError { AppError::TranslationError(format!( "Translation from '{}' to '{}' is not supported", @@ -363,50 +373,3 @@ fn set_unique( } Ok(()) } - -#[cfg(test)] -mod tests { - use super::ExecutorPool; - use crate::AppError; - use std::sync::Arc; - - #[tokio::test] - async fn executor_returns_after_success_and_error() { - let pool = ExecutorPool::new(1).unwrap(); - assert_eq!(pool.execute(|_| Ok(42)).await.unwrap(), 42); - - let error = pool - .execute::<(), _>(|_| Err(AppError::InferenceError("expected".to_string()))) - .await; - assert!(error.is_err()); - assert_eq!(pool.execute(|_| Ok(7)).await.unwrap(), 7); - } - - #[tokio::test] - async fn executor_returns_after_panic() { - let pool = ExecutorPool::new(1).unwrap(); - let error = pool - .execute::<(), _>(|_| panic!("expected test panic")) - .await - .unwrap_err(); - assert!(matches!(error, AppError::InferenceError(_))); - assert_eq!(pool.execute(|_| Ok(11)).await.unwrap(), 11); - } - - #[tokio::test] - async fn executor_pool_enforces_global_concurrency_limit() { - let pool = Arc::new(ExecutorPool::new(1).unwrap()); - let lease = pool.acquire().await.unwrap(); - let second_pool = Arc::clone(&pool); - let mut second = Box::pin(second_pool.execute(|_| Ok(()))); - - tokio::select! { - biased; - result = &mut second => panic!("second task started without an available executor: {result:?}"), - _ = std::future::ready(()) => {} - } - - drop(lease); - second.await.unwrap(); - } -} diff --git a/src/main.rs b/src/main.rs index 8e2d1eb..ff9835a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,12 @@ use axum::{ routing::{get, post}, }; use isolang::Language; -use std::{fs, io, net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{ + fs, io, + net::{IpAddr, SocketAddr}, + path::PathBuf, + sync::Arc, +}; use tokio::{net::TcpListener, signal}; use tower_http::{ cors::{ @@ -23,6 +28,8 @@ mod inference; mod translation; const ENV_MODELS_PATH: &str = "MODELS_DIR"; +// Internal tuning override, intentionally not documented for regular users. +// When unset, the server adapts to the system's available CPU parallelism. const ENV_NUM_WORKERS: &str = "NUM_WORKERS"; const ENV_SERVER_IP: &str = "IP"; const ENV_SERVER_PORT: &str = "PORT"; @@ -189,12 +196,17 @@ async fn main() -> anyhow::Result<()> { let num_workers = resolve_num_workers(configured_workers.as_deref(), available_parallelism)?; let server_ip = std::env::var(ENV_SERVER_IP).unwrap_or_else(|_| "127.0.0.1".to_string()); - let server_port = std::env::var(ENV_SERVER_PORT) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(3000); - - let server_address = format!("{}:{}", server_ip, server_port); + let server_ip: IpAddr = server_ip + .parse() + .context(format!("Invalid {} value: '{}'", ENV_SERVER_IP, server_ip))?; + let server_port = match std::env::var(ENV_SERVER_PORT) { + Ok(port) => port + .parse::() + .context(format!("Invalid {} value: '{}'", ENV_SERVER_PORT, port))?, + Err(std::env::VarError::NotPresent) => 3000, + Err(error) => return Err(error).context(format!("Failed to read {ENV_SERVER_PORT}")), + }; + let addr = SocketAddr::new(server_ip, server_port); let api_key = std::env::var(ENV_API_KEY) .ok() .filter(|key| !key.is_empty()); @@ -243,10 +255,6 @@ async fn main() -> anyhow::Result<()> { .layer(cors) .with_state(app_state); - let addr: SocketAddr = server_address.parse().context(format!( - "Failed to parse server address: {}", - server_address - ))?; info!( "Starting server on {} (IP: {}, Port: {})", addr, server_ip, server_port @@ -263,19 +271,3 @@ async fn main() -> anyhow::Result<()> { info!("Server has been shut down gracefully"); Ok(()) } - -#[cfg(test)] -mod tests { - use super::resolve_num_workers; - - #[test] - fn resolves_worker_configuration() { - assert_eq!(resolve_num_workers(None, Some(8)).unwrap(), 8); - assert_eq!(resolve_num_workers(Some(""), Some(4)).unwrap(), 4); - assert_eq!(resolve_num_workers(None, None).unwrap(), 1); - assert_eq!(resolve_num_workers(Some("3"), Some(8)).unwrap(), 3); - assert!(resolve_num_workers(Some("0"), Some(8)).is_err()); - assert!(resolve_num_workers(Some("invalid"), Some(8)).is_err()); - assert!(resolve_num_workers(Some("-1"), Some(8)).is_err()); - } -} diff --git a/src/translation.rs b/src/translation.rs index 298e206..d40ac1b 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -3,7 +3,8 @@ use isolang::Language; use std::sync::Arc; pub fn parse_language_code(code: &str) -> Result { - Language::from_639_1(code.split('-').next().unwrap_or(code)).ok_or_else(|| { + let normalized = code.split('-').next().unwrap_or(code).to_ascii_lowercase(); + Language::from_639_1(&normalized).ok_or_else(|| { AppError::TranslationError(format!( "Invalid language code: '{}'. Please use ISO 639-1 format.", code @@ -28,6 +29,10 @@ pub fn detect_language_code(text: &str) -> Result<&'static str, AppError> { get_iso_code(&detect_language(text)?) } +pub fn normalize_language_code(code: &str) -> Result<&'static str, AppError> { + get_iso_code(&parse_language_code(code)?) +} + fn detect_language(text: &str) -> Result { Language::from_639_3(whichlang::detect_language(text).three_letter_code()).ok_or_else(|| { AppError::TranslationError(format!("Failed to identify language for text: '{}'", text)) @@ -41,7 +46,11 @@ fn resolve_source_language( target_lang: Language, ) -> Result { match from_lang { - None | Some("") | Some("auto") => match state.sole_language_pair { + None | Some("") => match state.sole_language_pair { + Some((source, target)) if target == target_lang => Ok(source), + _ => detect_language(text), + }, + Some(code) if code.eq_ignore_ascii_case("auto") => match state.sole_language_pair { Some((source, target)) if target == target_lang => Ok(source), _ => detect_language(text), }, From 172856d425c7a381066a12a85b7dd4b2bd0c684c Mon Sep 17 00:00:00 2001 From: Aalivexy Date: Sun, 19 Jul 2026 19:50:56 +0800 Subject: [PATCH 4/4] fix: report invalid model language pairs as config errors --- src/inference.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/inference.rs b/src/inference.rs index 5bb53f0..def2166 100644 --- a/src/inference.rs +++ b/src/inference.rs @@ -261,7 +261,16 @@ fn parse_language_pair(name: &str) -> Result<(Language, Language), AppError> { } } }; - Ok((parse_language_code(source)?, parse_language_code(target)?)) + let parse_model_language = |code: &str| { + parse_language_code(code).map_err(|_| { + AppError::ConfigError(format!( + "Invalid language code '{}' in model directory '{}'; expected ISO 639-1 codes", + code, name + )) + }) + }; + + Ok((parse_model_language(source)?, parse_model_language(target)?)) } fn iso_code(language: &Language) -> Result<&'static str, AppError> {