diff --git a/Tools/langtool/Cargo.lock b/Tools/langtool/Cargo.lock index b8ff12cae1..a28e40f2f5 100644 --- a/Tools/langtool/Cargo.lock +++ b/Tools/langtool/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -81,9 +81,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -140,9 +140,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.0" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -185,9 +185,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -375,9 +375,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -414,9 +414,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -424,33 +424,33 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -500,9 +500,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -560,9 +560,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -640,9 +640,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -654,9 +654,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -667,9 +667,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -681,16 +681,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -701,15 +702,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -781,7 +782,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -830,9 +831,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -866,15 +867,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" @@ -1026,15 +1027,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1062,7 +1063,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1070,9 +1071,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -1085,7 +1086,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1160,9 +1161,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1324,9 +1325,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -1596,11 +1597,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -1616,9 +1617,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -1627,9 +1628,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1832,9 +1833,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -1845,9 +1846,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -1855,9 +1856,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1865,9 +1866,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -1878,9 +1879,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -1946,9 +1947,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -2150,9 +2151,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11-clipboard" @@ -2187,9 +2188,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "yoke" @@ -2243,9 +2244,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2254,9 +2255,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2265,13 +2266,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/Tools/langtool/Cargo.toml b/Tools/langtool/Cargo.toml index 44b854da8b..426ccf8944 100644 --- a/Tools/langtool/Cargo.toml +++ b/Tools/langtool/Cargo.toml @@ -5,7 +5,7 @@ name = "langtool" version = "0.1.0" [dependencies] -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.6", features = ["derive"] } reqwest = { version = "0.13", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/Tools/langtool/README.md b/Tools/langtool/README.md index f9a1d5b80b..fd5010f592 100644 --- a/Tools/langtool/README.md +++ b/Tools/langtool/README.md @@ -16,6 +16,25 @@ To see command line usage, type: cargo run -- --help ``` +## AI translation + +Some commands (`add-new-key-ai`, `add-new-key-value-ai`, `finish-language-with-ai`) use an LLM to +generate translations. Two providers are supported, pick one by setting its API key in the +environment: + +* Claude (Anthropic): `ANTHROPIC_API_KEY` +* OpenAI: `OPENAI_API_KEY` + +If both are set, Claude is used unless you say otherwise with `--provider openai`. To pick a +specific model, use `--model`, for example: + +```bash +cargo run -- --model claude-sonnet-5 finish-language-with-ai sv_SE +cargo run -- --model gpt-4o finish-language-with-ai sv_SE +``` + +The provider is inferred from the model name, so `--provider` is only needed to disambiguate. + To autoformat the code, use: ```bash diff --git a/Tools/langtool/src/ai.rs b/Tools/langtool/src/ai.rs new file mode 100644 index 0000000000..90449b08b4 --- /dev/null +++ b/Tools/langtool/src/ai.rs @@ -0,0 +1,82 @@ +use clap::ValueEnum; +use std::error::Error; + +use crate::chatgpt::ChatGPT; +use crate::claude::Claude; + +// gpt-5, gpt-5-mini, gpt-5-nano, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, o3, o4-mini, gpt-4o +const DEFAULT_OPENAI_MODEL: &str = "gpt-4o-mini"; +// claude-opus-5, claude-sonnet-5, claude-haiku-4-5 +const DEFAULT_CLAUDE_MODEL: &str = "claude-opus-5"; + +const OPENAI_KEY_VAR: &str = "OPENAI_API_KEY"; +const CLAUDE_KEY_VAR: &str = "ANTHROPIC_API_KEY"; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum Provider { + /// OpenAI, needs OPENAI_API_KEY in the environment. + #[value(name = "openai")] + OpenAI, + /// Anthropic, needs ANTHROPIC_API_KEY in the environment. + Claude, +} + +pub enum Ai { + OpenAI(ChatGPT), + Claude(Claude), +} + +impl Ai { + /// Picks a provider and creates a client for it, if we have an API key for it. + /// + /// If no provider was specified on the command line, we guess from the model name if we + /// have one, otherwise we just use whichever API key is available (Claude wins if both are). + pub fn create(provider: Option, model: Option) -> Option { + let openai_key = std::env::var(OPENAI_KEY_VAR).ok(); + let claude_key = std::env::var(CLAUDE_KEY_VAR).ok(); + + let provider = match provider { + Some(provider) => provider, + None => match model.as_deref() { + Some(model) if model.starts_with("claude") => Provider::Claude, + Some(model) if model.starts_with("gpt") || model.starts_with('o') => { + Provider::OpenAI + } + _ => { + if claude_key.is_some() { + Provider::Claude + } else { + Provider::OpenAI + } + } + }, + }; + + // If we don't have a key for the chosen provider, we return None here and the AI + // commands will report it and skip themselves. + match provider { + Provider::OpenAI => openai_key.map(|key| { + let model = model.unwrap_or_else(|| DEFAULT_OPENAI_MODEL.to_string()); + Ai::OpenAI(ChatGPT::new(key, model)) + }), + Provider::Claude => claude_key.map(|key| { + let model = model.unwrap_or_else(|| DEFAULT_CLAUDE_MODEL.to_string()); + Ai::Claude(Claude::new(key, model)) + }), + } + } + + pub fn chat(&self, request: &str) -> Result> { + match self { + Ai::OpenAI(chatgpt) => chatgpt.chat(request), + Ai::Claude(claude) => claude.chat(request), + } + } + + pub fn description(&self) -> String { + match self { + Ai::OpenAI(chatgpt) => format!("OpenAI ({})", chatgpt.model()), + Ai::Claude(claude) => format!("Claude ({})", claude.model()), + } + } +} diff --git a/Tools/langtool/src/chatgpt.rs b/Tools/langtool/src/chatgpt.rs index 72a2d5f1e3..88a0b77676 100644 --- a/Tools/langtool/src/chatgpt.rs +++ b/Tools/langtool/src/chatgpt.rs @@ -41,6 +41,10 @@ impl ChatGPT { ChatGPT { api_key, model } } + pub fn model(&self) -> &str { + &self.model + } + pub fn chat(&self, request: &str) -> Result> { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(300)) diff --git a/Tools/langtool/src/claude.rs b/Tools/langtool/src/claude.rs new file mode 100644 index 0000000000..cc574c6acc --- /dev/null +++ b/Tools/langtool/src/claude.rs @@ -0,0 +1,113 @@ +use anyhow::{Context, anyhow}; +use serde::{Deserialize, Serialize}; +use std::{error::Error, time::Duration}; + +// The Anthropic API version header. Not the model version, this basically never changes. +const API_VERSION: &str = "2023-06-01"; + +// Plenty for a section's worth of translated strings. If we ever hit it, we get a +// "max_tokens" stop reason and complain loudly instead of silently truncating. +const MAX_TOKENS: u32 = 16000; + +#[derive(Serialize)] +pub struct MessagesRequest<'a> { + pub model: &'a str, + pub max_tokens: u32, + pub messages: Vec>, +} + +#[derive(Serialize)] +pub struct Message<'a> { + pub role: &'a str, + pub content: &'a str, +} + +#[derive(Deserialize)] +pub struct MessagesResponse { + pub content: Vec, + pub stop_reason: Option, +} + +// Content comes back as a list of blocks. We only care about the text ones, but there can +// be others (thinking blocks, tool use), so we can't just assume block 0 is text. +#[derive(Deserialize)] +pub struct ContentBlock { + #[serde(rename = "type")] + pub block_type: String, + #[serde(default)] + pub text: Option, +} + +pub struct Claude { + api_key: String, + model: String, +} + +impl Claude { + pub fn new(api_key: String, model: String) -> Self { + Claude { api_key, model } + } + + pub fn model(&self) -> &str { + &self.model + } + + pub fn chat(&self, request: &str) -> Result> { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(300)) + .build()?; + + let req_body = MessagesRequest { + model: &self.model, + max_tokens: MAX_TOKENS, + messages: vec![Message { + role: "user", + content: request, + }], + }; + + let res = client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &self.api_key) + .header("anthropic-version", API_VERSION) + .json(&req_body) + .send() + .context("response")?; + + // Errors come back as a JSON body with a useful message in it, so print it as-is + // rather than just the status code. + let status = res.status(); + if !status.is_success() { + let body = res.text().unwrap_or_default(); + return Err(anyhow!("Claude API error ({status}): {body}").into()); + } + + let res: MessagesResponse = res.json().context("json")?; + + match res.stop_reason.as_deref() { + Some("max_tokens") => { + return Err( + anyhow!("Response hit the {MAX_TOKENS} token limit, it got cut off").into(), + ); + } + Some("refusal") => { + return Err(anyhow!("Claude declined to answer this request").into()); + } + _ => {} + } + + let text = res + .content + .iter() + .filter(|block| block.block_type == "text") + .filter_map(|block| block.text.as_deref()) + .collect::>() + .join(""); + + if text.is_empty() { + return Err(anyhow!("No text in the response from Claude").into()); + } + + Ok(text) + } +} diff --git a/Tools/langtool/src/main.rs b/Tools/langtool/src/main.rs index d9e95b1ade..f15b72f5e8 100644 --- a/Tools/langtool/src/main.rs +++ b/Tools/langtool/src/main.rs @@ -8,12 +8,18 @@ use section::{Section, line_value}; mod inifile; use inifile::IniFile; +mod ai; mod chatgpt; +mod claude; use clap::Parser; mod util; -use crate::{chatgpt::ChatGPT, section::split_line, util::ask_letter}; +use crate::{ + ai::{Ai, Provider}, + section::split_line, + util::ask_letter, +}; #[derive(Parser, Debug)] struct Args { @@ -23,9 +29,12 @@ struct Args { dry_run: bool, #[arg(short, long)] verbose: bool, - // gpt-5, gpt-5-mini, gpt-5-nano, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, o3, o4-mini, gpt-4o, gpt-4o-realtime-preview - #[arg(short, long, default_value = "gpt-4o-mini")] - model: String, + /// Which AI service to use for translation. Defaults to whichever one we have an API key for. + #[arg(short, long, value_enum)] + provider: Option, + /// Model to use, see ai.rs for examples. Defaults to a reasonable one for the provider. + #[arg(short, long)] + model: Option, } #[derive(Parser, Debug)] @@ -391,10 +400,10 @@ fn finish_language_with_ai( target_ini: &mut IniFile, ref_ini: &IniFile, section: Option<&str>, - ai: &ChatGPT, + ai: &Ai, dry_run: bool, ) -> anyhow::Result<()> { - println!("Finishing language with AI"); + println!("Finishing language with {}", ai.description()); println!( "Step 1: Compare all strings in the section with the matching strings from the reference." ); @@ -653,15 +662,14 @@ fn parse_response(response: &str) -> Option> { fn main() { let opt = Args::parse(); - let api_key = std::env::var("OPENAI_API_KEY").ok(); - let ai = api_key.map(|key| chatgpt::ChatGPT::new(key, opt.model)); + let ai = Ai::create(opt.provider, opt.model); // TODO: Grab extra arguments from opt somehow. // let args: Vec = vec![]; //std::env::args().skip(1).collect(); execute_command(opt.cmd, ai.as_ref(), opt.dry_run, opt.verbose); } -fn execute_command(cmd: Command, ai: Option<&ChatGPT>, dry_run: bool, verbose: bool) { +fn execute_command(cmd: Command, ai: Option<&Ai>, dry_run: bool, verbose: bool) { let root = "../../assets/lang"; let reference_ini_filename = "en_US.ini"; @@ -1002,7 +1010,7 @@ fn execute_command(cmd: Command, ai: Option<&ChatGPT>, dry_run: bool, verbose: b } fn generate_ai_response( - ai: Option<&ChatGPT>, + ai: Option<&Ai>, filenames: &[String], section: &str, key: &str, @@ -1016,7 +1024,7 @@ fn generate_ai_response( ); println!("generated prompt:\n{prompt}"); Some(if let Some(ai) = &ai { - println!("Using AI for translation..."); + println!("Using {} for translation...", ai.description()); let response = ai .chat(&prompt) .map_err(|e| anyhow::anyhow!("chat failed: {e}"))