langtool: Add Claude as an alternative to OpenAI for AI translation

New claude.rs talks to the Anthropic Messages API, and ai.rs picks between the
two providers. The provider is chosen with --provider, or guessed from the model
name, or from whichever of ANTHROPIC_API_KEY/OPENAI_API_KEY is set.

Also update the dependencies (and bump the clap requirement to 4.6).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-08-23 16:18:07 +02:00
1 parent a67ae4e7f6
commit 77e356961d
7 files changed
+332 -105

No files matched your search

+94 -93
View File
@@ -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]]
+1 -1
View File
@@ -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"
+19
View File
@@ -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
+82
View File
@@ -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<Provider>, model: Option<String>) -> Option<Ai> {
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<String, Box<dyn Error>> {
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()),
}
}
}
+4
View File
@@ -41,6 +41,10 @@ impl ChatGPT {
ChatGPT { api_key, model }
}
pub fn model(&self) -> &str {
&self.model
}
pub fn chat(&self, request: &str) -> Result<String, Box<dyn Error>> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(300))
+113
View File
@@ -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<Message<'a>>,
}
#[derive(Serialize)]
pub struct Message<'a> {
pub role: &'a str,
pub content: &'a str,
}
#[derive(Deserialize)]
pub struct MessagesResponse {
pub content: Vec<ContentBlock>,
pub stop_reason: Option<String>,
}
// 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<String>,
}
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<String, Box<dyn Error>> {
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::<Vec<_>>()
.join("");
if text.is_empty() {
return Err(anyhow!("No text in the response from Claude").into());
}
Ok(text)
}
}
+19 -11
View File
@@ -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<Provider>,
/// Model to use, see ai.rs for examples. Defaults to a reasonable one for the provider.
#[arg(short, long)]
model: Option<String>,
}
#[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<BTreeMap<String, String>> {
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<String> = 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}"))