diff --git a/Cargo.lock b/Cargo.lock index b1a9def..42f4be3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aead" @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -57,9 +57,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -78,9 +78,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -111,28 +111,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -152,15 +130,37 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] [[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", "axum-macros", @@ -211,26 +211,38 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -241,25 +253,36 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -269,11 +292,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -289,15 +318,15 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[package]] name = "clap" -version = "4.5.60" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -305,9 +334,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -317,9 +346,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -333,12 +362,43 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -374,6 +434,27 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -381,10 +462,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "0.4.3" @@ -411,12 +501,41 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.5.8" +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "powerfmt", + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", ] [[package]] @@ -425,15 +544,28 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -461,6 +593,71 @@ version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -488,9 +685,25 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" @@ -510,21 +723,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -534,6 +732,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -602,6 +806,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -617,6 +822,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -625,7 +844,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -640,11 +859,22 @@ dependencies = [ "polyval", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -670,9 +900,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -686,11 +916,29 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[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", @@ -731,11 +979,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[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", @@ -748,7 +1005,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -756,36 +1012,19 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -945,12 +1184,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -971,50 +1210,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "iri-string" -version = "0.7.10" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "memchr", - "serde", + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] [[package]] -name = "itoa" -version = "1.0.17" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", "js-sys", - "pem", - "ring", + "p256", + "p384", + "rand 0.8.6", + "rsa", "serde", "serde_json", - "simple_asn1", + "sha2 0.10.9", + "signature", ] [[package]] @@ -1022,6 +1317,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1031,13 +1329,19 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "link-assistant-router" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes-gcm", "async-trait", @@ -1058,12 +1362,12 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tempfile", "tokio", "tokio-test", "tower", - "tower-http", + "tower-http 0.7.0", "tracing", "tracing-subscriber", "uuid", @@ -1097,21 +1401,30 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "log-lazy" -version = "0.1.0" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7d707bdd9922c7bca6d61fd822e6040e8854aafb3c28075ce1825997af61d9" +dependencies = [ + "chrono", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2891f2d5625c64238aa93df2e8bcd60d5506ee13df66bcb5b91e5c06d65768b5" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" @@ -1130,9 +1443,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -1142,32 +1455,15 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1178,21 +1474,21 @@ dependencies = [ ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ + "lazy_static", + "libm", "num-integer", + "num-iter", "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", ] -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - [[package]] name = "num-integer" version = "0.1.46" @@ -1202,6 +1498,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1209,6 +1516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1230,57 +1538,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "openssl" -version = "0.10.76" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "p256" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "proc-macro2", - "quote", - "syn", + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", ] [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "p384" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", ] [[package]] -name = "pem" -version = "3.0.6" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ - "base64", - "serde_core", + "base64ct", ] [[package]] @@ -1291,21 +1584,30 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] [[package]] name = "polyval" @@ -1314,25 +1616,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "prettyplease" @@ -1344,30 +1649,142 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1377,6 +1794,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1390,15 +1816,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -1411,24 +1837,24 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -1437,6 +1863,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1451,6 +1887,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1466,10 +1937,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -1477,21 +1949,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1509,6 +2022,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1518,6 +2040,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1543,9 +2079,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1579,9 +2115,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", @@ -1620,8 +2156,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1635,9 +2182,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1650,17 +2197,31 @@ dependencies = [ ] [[package]] -name = "simple_asn1" -version = "0.6.4" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1669,20 +2230,36 @@ 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.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1703,9 +2280,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1816,51 +2393,35 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.47" +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", + "displaydoc", + "zerovec", ] [[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" +name = "tinyvec" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ - "num-conv", - "time-core", + "tinyvec_macros", ] [[package]] -name = "tinystr" -version = "0.8.2" +name = "tinyvec_macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -1874,25 +2435,15 @@ dependencies = [ [[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", "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -1905,9 +2456,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -1916,12 +2467,10 @@ dependencies = [ [[package]] name = "tokio-test" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "async-stream", - "bytes", "futures-core", "tokio", "tokio-stream", @@ -1958,20 +2507,36 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", "tracing", ] @@ -2070,15 +2635,15 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[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 = "unicode-xid" @@ -2092,7 +2657,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -2128,9 +2693,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2143,18 +2708,22 @@ 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 = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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 = "want" version = "0.3.1" @@ -2172,11 +2741,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] @@ -2185,14 +2754,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -2203,23 +2772,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2227,9 +2792,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -2240,9 +2805,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -2271,9 +2836,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -2296,14 +2861,42 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[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-core" version = "0.62.2" @@ -2380,7 +2973,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2398,14 +3000,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "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", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2414,48 +3033,102 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[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 = "wit-bindgen" version = "0.51.0" @@ -2546,15 +3219,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2563,9 +3236,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2573,20 +3246,40 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2596,15 +3289,15 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2613,9 +3306,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2624,9 +3317,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 744188a..043752c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "link-assistant-router" version = "0.19.0" -edition = "2021" +edition = "2024" description = "Link.Assistant.Router — Claude MAX OAuth proxy and token gateway for Anthropic APIs" readme = "README.md" license = "Unlicense" @@ -9,7 +9,7 @@ keywords = ["proxy", "anthropic", "claude", "gateway", "oauth"] categories = ["web-programming::http-server"] repository = "https://github.com/link-assistant/router" documentation = "https://github.com/link-assistant/router" -rust-version = "1.70" +rust-version = "1.85" [lib] name = "link_assistant_router" @@ -21,29 +21,29 @@ path = "src/main.rs" [dependencies] axum = { version = "0.8", features = ["macros"] } -hyper = { version = "1.0", features = ["full"] } +hyper = { version = "1.10", features = ["full"] } hyper-util = { version = "0.1", features = ["tokio"] } -reqwest = { version = "0.12", features = ["stream", "json"] } -tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "signal", "fs", "process", "sync"] } +reqwest = { version = "0.13", features = ["stream", "json"] } +tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "time", "signal", "fs", "process", "sync"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -jsonwebtoken = "9.0" +jsonwebtoken = { version = "10.0", default-features = false, features = ["rust_crypto"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4"] } tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.6", features = ["trace", "cors"] } +tower-http = { version = "0.7", features = ["trace", "cors"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -log-lazy = "0.1" -bytes = "1.0" +log-lazy = "0.2" +bytes = "1.11" futures-util = "0.3" -http = "1.0" +http = "1.4" http-body-util = "0.1" lino-arguments = "0.3" clap = { version = "4", features = ["derive", "env"] } base64 = "0.22" async-trait = "0.1" -sha2 = "0.10" +sha2 = "0.11" hex = "0.4" aes-gcm = "0.10" diff --git a/README.md b/README.md index 1d9f665..9f9771d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Link.Assistant.Router is a transparent proxy that sits between API clients (such - **Proxies all Anthropic API requests** transparently, including SSE/streaming responses - **Supports Claude MAX (OAuth)** by reading Claude Code session credentials +- **Vendor subscriptions** — `UPSTREAM_PROVIDER=codex|gemini|qwen` reads each vendor CLI's OAuth credentials read-only (`~/.codex`, `~/.gemini`, `~/.qwen`), refreshes expired tokens in memory, and routes to the ChatGPT/Code Assist/DashScope backend with full dialect translation - **OpenAI-compatible endpoints** — `/v1/chat/completions`, `/v1/responses`, `/v1/models` translate to Anthropic or forward to a configured OpenAI-compatible provider - **Optional Gonka upstream** — `UPSTREAM_PROVIDER=gonka` forwards OpenAI-compatible routes to Gonka instead of translating them to Anthropic - **Optional Crater ForgeFed upstream** — `UPSTREAM_PROVIDER=crater` turns OpenAI chat requests into ForgeFed `Offer{Ticket}` tasks and waits for resolved task results @@ -60,6 +61,28 @@ chat requests, delivers a ForgeFed `Offer` containing a `Ticket` to `CRATER_FORGEFED_INBOX`, reads `Accept.result`, polls that task URI until `isResolved:true`, and maps the resolved content back to OpenAI JSON or SSE. +### Vendor subscriptions (Codex, Gemini, Qwen) + +Set `UPSTREAM_PROVIDER` to `codex`, `gemini`, or `qwen` to serve a vendor +subscription instead of an API key. Clients still authenticate to the router +with their `la_sk_...` token; the router supplies the vendor OAuth token. + +| Provider | `UPSTREAM_PROVIDER` (aliases) | Credentials (read-only) | Upstream | +| --- | --- | --- | --- | +| Claude | `anthropic` | `~/.claude/.credentials.json` | `api.anthropic.com` | +| Codex / ChatGPT | `codex` (`chatgpt`, `openai-codex`) | `~/.codex/auth.json` | ChatGPT backend Responses API | +| Gemini | `gemini` (`google`, `code-assist`) | `~/.gemini/oauth_creds.json` | Code Assist `generateContent` | +| Qwen | `qwen` (`qwen-code`, `dashscope`) | `~/.qwen/oauth_creds.json` | DashScope OpenAI-compatible | + +The credential files are produced by each vendor's own CLI (run its `login` +once); the router only reads them. Expired tokens are refreshed in memory using +the vendor's public OAuth client — the files on disk are never modified and +secrets are never logged. `/v1/chat/completions` and `/v1/responses` are +translated to each backend's dialect (Codex uses the OpenAI Responses API; +Gemini uses the Code Assist envelope with synthesized SSE for streaming; Qwen is +OpenAI-compatible). Run `router doctor` to verify each credential file is +present and its token valid. + ## Quick Start ### Prerequisites diff --git a/changelog.d/20260617_173000_issue_37_case_study.md b/changelog.d/20260617_173000_issue_37_case_study.md new file mode 100644 index 0000000..fe6a8a2 --- /dev/null +++ b/changelog.d/20260617_173000_issue_37_case_study.md @@ -0,0 +1,14 @@ +--- +bump: patch +--- + +### Added + +- Added the issue #37 case-study package under `docs/case-studies/issue-37`, + analyzing how to adopt the best experience from ProxyPal + (`heyhuynhgiabuu/proxypal`) to fully support Claude, Codex, Gemini, and Qwen + subscriptions. Includes a requirement trace (process + functional), file-level + solution plans per requirement, an existing-components survey (CLIProxyAPI, + ProxyPal, LiteLLM, the `oauth2`/`openidconnect` crates), online research with + primary sources for each provider's OAuth endpoints/tokens/quotas, a deep + inventory of ProxyPal and its CLIProxyAPI engine, and raw research snapshots. diff --git a/changelog.d/20260617_180000_issue_37_subscriptions.md b/changelog.d/20260617_180000_issue_37_subscriptions.md new file mode 100644 index 0000000..8d126c3 --- /dev/null +++ b/changelog.d/20260617_180000_issue_37_subscriptions.md @@ -0,0 +1,31 @@ +--- +bump: minor +--- + +### Added + +- Multi-provider subscription support for Codex (ChatGPT), Gemini (Code Assist), + and Qwen (DashScope), alongside the existing Claude support, adopting the best + practices from ProxyPal. The router now reads each vendor CLI's OAuth + credential file read-only (`~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, + `~/.qwen/oauth_creds.json`) via a unified `subscription` module and routes + `/v1/chat/completions`, `/v1/responses`, and `/v1/models` to the correct + upstream. +- `UpstreamProvider::{Codex, Gemini, Qwen}` selectable upstreams with provider + aliases (e.g. `chatgpt`, `google`, `dashscope`). +- Dialect translation between OpenAI Chat Completions, the OpenAI Responses API + (Codex/ChatGPT backend), and the Gemini Code Assist `generateContent` envelope, + including SSE synthesis when a client requests streaming from Gemini. +- In-memory OAuth token refresh: expired Codex/Gemini/Qwen tokens are refreshed + using each vendor's public OAuth client and cached in memory, keeping the proxy + working even when the vendor CLI is not running. Vendor credential files remain + read-only and secrets are never logged. +- `router doctor` now probes the Codex/Gemini/Qwen subscription credential files + and reports whether each is present, valid, or expired. +- Rate-limit headers (`Retry-After`, `x-ratelimit-*`) from subscription upstreams + are relayed to clients so they can back off intelligently. + +### Changed + +- Updated dependencies to their latest versions and built on the latest stable + Rust (edition 2024). diff --git a/docs/case-studies/issue-37/README.md b/docs/case-studies/issue-37/README.md new file mode 100644 index 0000000..77ce982 --- /dev/null +++ b/docs/case-studies/issue-37/README.md @@ -0,0 +1,143 @@ +# Issue 37 Case Study: Adopt the best of ProxyPal — full multi-provider subscription support + +## Summary + +[Issue #37](https://github.com/link-assistant/router/issues/37) asks us to "use +all the best experience from +[heyhuynhgiabuu/proxypal](https://github.com/heyhuynhgiabuu/proxypal)" so that +the router can **"fully support claude, codex, gemini, qwen, and their +subscriptions with all our features and more."** It then specifies a concrete +*process*: collect the issue's data into `docs/case-studies/issue-37/`, do a deep +case-study analysis (including online research), list **every** requirement, +propose a solution/plan for each, and survey existing components/libraries — all +in PR #38. + +This case study delivers that process. It is a **planning and analysis +deliverable**: the functional super-goal (four providers' OAuth subscriptions + +login + auto-config + analytics + a possible GUI) is a multi-PR roadmap, and the +issue's explicit ask is to *analyze it and plan it*, which is exactly what these +documents do. Implementation is then sequenced as follow-up PRs, each verified +against a real subscription (the same discipline issue #35 used for Claude). + +## What's in this folder + +| File | Purpose | +| --- | --- | +| [`README.md`](./README.md) | This analysis: what ProxyPal teaches, where we stand, the gap, the plan. | +| [`requirements.md`](./requirements.md) | Every requirement (process + functional) traced to a solution and status. | +| [`solution-plans.md`](./solution-plans.md) | One detailed, file-level plan per functional requirement, plus execution order. | +| [`components-survey.md`](./components-survey.md) | Existing components/libraries surveyed, with build-vs-borrow decisions. | +| [`online-research.md`](./online-research.md) | Cited primary sources: each provider's OAuth endpoints, tokens, quotas, 429 handling. | +| [`proxypal-analysis.md`](./proxypal-analysis.md) | Deep inventory of ProxyPal + the CLIProxyAPI engine it wraps. | +| [`raw/`](./raw/) | Captured data: issue JSON/comments + ProxyPal snapshot metadata. | + +## The key insight: ProxyPal is a UX shell, not an engine + +The most important finding (full evidence in +[`proxypal-analysis.md`](./proxypal-analysis.md)): **ProxyPal implements no proxy +and no OAuth.** It is a Tauri v2 + SolidJS desktop GUI that wraps the Go-based +[**CLIProxyAPI**](https://github.com/router-for-me/CLIProxyAPI) binary as a +sidecar. CLIProxyAPI is the engine that performs the OAuth, the model/API-dialect +translation, the multi-account rotation, and the proxying. ProxyPal's value is +the *experience*: one-click login per provider, auto-configuring installed coding +tools, usage analytics, and lifecycle management. + +``` +ProxyPal (what the issue points at) link-assistant/router (us) +─────────────────────────────────── ────────────────────────── +Tauri shell (Rust) We already ARE a Rust proxy engine. + └─ SolidJS UI ───────────────────────► Adopt these IDEAS (login, configure, + (login / configure / analytics) analytics) as CLI/JSON surfaces. + └─ CLIProxyAPI engine (Go) ───────► Adopt this ARCHITECTURE (per-provider + (OAuth, translate, rotate) auth + translation + rotation) in OUR + engine — don't embed a foreign one. +``` + +So "use the best experience from ProxyPal" resolves to **two distinct things**: + +1. **Adopt CLIProxyAPI's engine architecture** — per-provider OAuth, a dialect + translation registry, and smart account rotation/cooldown — implemented + *natively in our Rust engine* (we already own one; we shouldn't bolt on a Go + sidecar and throw away the `la_sk_` token gateway, Lino store, Gonka/Crater, + and single-binary deployment that define this project). +2. **Adopt ProxyPal's UX** — login, auto-configure-your-coding-tool, and usage + analytics — re-expressed as `router` subcommands and JSON endpoints (with a + GUI as optional later work). + +## Where the router stands today + +Full inventory was produced by reading every source file; highlights relevant to +this issue (file evidence in [`requirements.md`](./requirements.md) and +[`solution-plans.md`](./solution-plans.md)): + +- **Claude is fully supported.** `src/oauth.rs` reads the real nested + `~/.claude/.credentials.json`, the proxy injects `anthropic-version` + + `anthropic-beta: oauth-2025-04-20`, and `la_sk_` tokens hide the real OAuth + credential (issue #35). This is our model for every other provider. +- **A capable proxy core:** axum server; OpenAI↔Anthropic translation + (`src/openai.rs`, incl. streaming SSE); `/v1/messages`, `/v1/chat/completions`, + `/v1/responses`, `/v1/models`; admin + `/metrics` + `/v1/usage` + `/v1/accounts`. +- **Multi-account routing** (`src/accounts.rs`): round-robin/priority/least-used + with a fixed 60s cooldown on 429 — **Claude-only**. +- **Several upstreams** via `UPSTREAM_PROVIDER`: `anthropic` (default, incl. + Bedrock/Vertex request shapes), `gonka` (signed), `crater` (ForgeFed), + `openai-compatible`/`litellm` (static API key). +- **Scoped tokens:** `la_sk_` JWTs with TTL, revocation, and per-token + `max_requests` budgets; dual Lino-text + binary store. + +## The gap (what "fully support codex/gemini/qwen" requires) + +| Capability | Today | Needed | +| --- | --- | --- | +| Claude subscription | ✅ file read + beta header | refresh + native login (hardening) | +| Codex/ChatGPT subscription | ❌ API-key only | read `~/.codex` + ChatGPT Responses route; native login | +| Gemini subscription | ❌ (Vertex *shape* only, no Google creds) | read `~/.gemini` + Code Assist route + Gemini translation; native login | +| Qwen subscription | ❌ | read `~/.qwen` → DashScope; device-code login | +| Provider abstraction | one variant (`OpenAICompatible`) | per-provider auth + model map + translation | +| Native OAuth login / device-code | ❌ (reads files only) | `router login ` (PKCE / device-code) via `oauth2` crate | +| Token refresh | ❌ (`refresh_token()` re-reads the file) | real `exchange_refresh_token()` per provider | +| Cross-provider account pool | ❌ Claude-only | mixed-provider pool + `fill-first` + `Retry-After` cooldown | +| Auto-configure client tools | ❌ | `router configure ` + `router doctor` detection | +| Per-provider usage/quota/savings | coarse counters | per-provider token+cost + vendor quota polling | +| GUI / dashboard | ❌ (JSON/Prometheus only) | optional thin web dashboard / desktop wrapper | + +## The plan + +The full, file-level, phased plan is in +[`solution-plans.md`](./solution-plans.md). The shape: + +- **Read before login.** For each new provider, first *read the credential file + the vendor CLI already writes* (Phase 1) — zero OAuth code, immediate + subscription access, exactly how Claude works today — then add native + `router login` (Phase 2) to drop the vendor-CLI dependency. +- **Foundation first.** Generalize the provider abstraction + a translation + registry (Plan 5) before adding Codex (Plan 2), Gemini (Plan 3), Qwen (Plan 4). +- **Borrow Rust libraries, not engines.** Use the `oauth2` crate (PKCE + + device-code + refresh) for login; adopt CLIProxyAPI's rotation/cooldown + *design*; re-express ProxyPal's UX as CLI subcommands. +- **Verify each provider live.** Every implementation PR ships redacted live + evidence against a real subscription, like issue #35's Claude proof. + +### Suggested follow-up PR order + +1. Provider abstraction + translation registry (Plan 5) +2. Codex via `~/.codex` read (Plan 2 Phase 1) +3. Gemini via `~/.gemini` read + Gemini translation (Plan 3 Phase 1) +4. Native `router login` + refresh (Plan 6) +5. Cross-provider pool + smart cooldown (Plan 7) +6. `router configure` auto-setup (Plan 8) +7. Per-provider usage/quota (Plan 9) +8. Qwen (Plan 4) · 9. Optional GUI (Plan 10) + +## Why implementation is not all in this PR + +The issue's explicit deliverables (collect/analyze/list/plan/survey) are +**complete here**. The functional super-goal spans four providers' OAuth, native +login + refresh, a generalized provider abstraction, cross-provider routing, +auto-config, and analytics — each of which can only be *verified* against a real +subscription (Codex/Gemini/Qwen credentials are not available in this +environment). Shipping untested OAuth for credentials we cannot exercise would +contradict this repo's "reproduce and verify" discipline. So this PR delivers the +exhaustive, evidence-backed roadmap, and each provider lands in a focused +follow-up PR with its own live proof — the same path issue #35 used to land +Claude correctly. diff --git a/docs/case-studies/issue-37/components-survey.md b/docs/case-studies/issue-37/components-survey.md new file mode 100644 index 0000000..6e40d82 --- /dev/null +++ b/docs/case-studies/issue-37/components-survey.md @@ -0,0 +1,75 @@ +# Existing Components & Libraries Survey + +The issue asks to "check known existing components/libraries that solve a similar +problem or can help in solutions." The super-problem — *use multiple AI +subscriptions (Claude, Codex, Gemini, Qwen, ...) through one local proxy with any +client* — decomposes into five sub-problems. This file surveys prior art for +each and records the build-vs-borrow decision. + +## Sub-problem A — Multi-provider proxy engine + +| Component | Lang | What it gives | Fit for this repo | +| --- | --- | --- | --- | +| [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) | Go | The closest match: wraps Claude/Codex/Gemini/Qwen/Copilot OAuth, translates dialects, rotates accounts, 429 cooldown. | **Design reference.** It is exactly what the issue describes — but in Go. We already own a Rust engine; we copy its *design* (provider executors, translation registry, fill-first rotation, cooldown), not the code. Could be embedded as a sidecar (the ProxyPal route) but that abandons our Rust engine and its `la_sk_` token model. | +| [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal) | Rust+TS (Tauri) | The *experience* layer over CLIProxyAPI: one-click login, auto-config, analytics. | **UX reference** (see [`proxypal-analysis.md`](./proxypal-analysis.md)). The login/auto-config/analytics ideas map onto CLI subcommands and (optionally) a future web dashboard. | +| [LiteLLM](https://github.com/BerriAI/litellm) | Python | 100+ providers, virtual keys, budgets, routing, OpenAI-format proxy. | Reference for the proxy contract (already cited in ADR 0001). Too heavy / wrong language to embed; we already interop with it as an `openai-compatible` upstream. | +| [claude-code-router](https://github.com/musistudio/claude-code-router) | TS | Routes Claude Code `/v1/messages` to OpenAI-compatible backends; `/model` switching. | Reference for the Anthropic-in / any-out routing direction. | +| [Bifrost](https://github.com/maximhq/bifrost) | Go | OpenAI-compatible gateway, load-balancing, cluster mode. | Reference for multi-account load balancing at scale. | + +**Decision:** grow our existing Rust engine; adopt CLIProxyAPI's architecture +(per-provider executor + translation registry + rotation/cooldown). Do **not** +embed a foreign engine — that would discard the `la_sk_` token gateway, the Lino +token store, Gonka/Crater, and the single-binary Rust deployment that are this +repo's identity. + +## Sub-problem B — Per-provider OAuth / device-code login + +| Component | What it gives | Fit | +| --- | --- | --- | +| [`oauth2`](https://docs.rs/oauth2) v5 (Rust) | Typed OAuth2 Authorization-Code + **PKCE** (`PkceCodeChallenge::new_random_sha256`) and **device-code** (`exchange_device_code`, RFC 8628). reqwest backend (already a dependency). | **Adopt.** Covers Claude, Codex, Gemini (auth-code+PKCE) and Qwen, Copilot (device-code) with one crate, no new HTTP stack. | +| [`openidconnect`](https://docs.rs/openidconnect) v4 (Rust) | OIDC on top of `oauth2` (id-token, discovery); documents SSRF-safe redirect handling. | Optional — only if we want id-token parsing (Codex `account_id` comes from the id-token). | +| Provider CLIs (`claude`, `codex`, `gemini`, `qwen`) | Already perform the OAuth and write credential files we can read. | **Already used for Claude** (`src/oauth.rs` reads `~/.claude`). Lowest-effort path for new providers: read `~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, `~/.qwen/oauth_creds.json` like we read `~/.claude` — *before* implementing native login. | + +**Decision:** two-phase. **Phase 1 (read-only):** extend the credential reader +to ingest Codex/Gemini/Qwen credential files produced by their official CLIs — +mirrors how we already support Claude, needs no OAuth code, immediately unlocks +subscriptions. **Phase 2 (native login):** add `router login ` using +the `oauth2` crate (PKCE + device-code) so the router stands alone without the +vendor CLIs. + +## Sub-problem C — Token refresh & expiry + +| Component | What it gives | Fit | +| --- | --- | --- | +| `oauth2` crate refresh | `exchange_refresh_token()` against each provider's token endpoint. | **Adopt** in Phase 2; today `src/oauth.rs::refresh_token()` only re-reads the file (no network refresh). | +| Provider CLIs' own refresh | The CLI refreshes its file; we re-read it. | Phase 1 free-ride: if the user keeps the vendor CLI logged in, files stay fresh. | + +## Sub-problem D — Auto-configure client tools + +| Component | What it gives | Fit | +| --- | --- | --- | +| ProxyPal `configure_cli_agent` / `get_tool_setup_info` | Detects + rewrites `~/.claude/settings.json`, `~/.codex/config.toml`, shell profiles, etc. | **Re-implement as a CLI** (`router configure ` + `router doctor` detection). We already have a `doctor` subcommand to extend. Pure file I/O, no new deps. | + +## Sub-problem E — Usage analytics / quota / 429 rotation + +| Component | What it gives | Fit | +| --- | --- | --- | +| Our `src/metrics.rs` | Prometheus counters, `/v1/usage`, `/v1/accounts`. | **Extend**, don't replace — add per-provider/per-token token-count + cost. We already emit better structured metrics than ProxyPal's log-tail. | +| ProxyPal quota widgets | Per-provider usage via each vendor's usage endpoint. | Reference for *which* endpoints to poll (Claude `/api/oauth/usage`, Codex `/wham/usage`, Copilot `/copilot_internal/user`). Map to a `router quota` command / `/v1/quota` endpoint. | +| CLIProxyAPI rotation/cooldown | `fill-first`, session affinity, `Retry-After(-Ms)` parsing, synthesized `model_cooldown` 429. | **Adopt** to upgrade our fixed-60s cooldown in `src/accounts.rs`. | +| [`tower`](https://docs.rs/tower) middleware | Retry/timeout/rate-limit layers. | Already a dependency; use for inbound per-token rate limiting (a known CLIProxyAPI gap). | + +## Overall build-vs-borrow decision + +**Borrow designs and pure-Rust libraries; build on our own engine.** + +- **Borrow:** `oauth2` crate (login), CLIProxyAPI's provider/translation/rotation + *architecture*, ProxyPal's *UX* (login → configure → analytics) re-expressed as + CLI subcommands. +- **Build/extend:** the existing axum proxy, `ProviderKind` enum (today only + `OpenAICompatible` — generalize to per-provider auth+translation), the + credential reader (multi-provider), `AccountRouter` (cross-provider pool + + smarter cooldown), and the `doctor`/new `configure`/`login` CLI surface. +- **Do not embed** CLIProxyAPI/LiteLLM as a foreign engine — it would discard the + `la_sk_` token gateway, Lino store, Gonka/Crater, and single-Rust-binary + deployment that distinguish this project. diff --git a/docs/case-studies/issue-37/online-research.md b/docs/case-studies/issue-37/online-research.md new file mode 100644 index 0000000..0b57281 --- /dev/null +++ b/docs/case-studies/issue-37/online-research.md @@ -0,0 +1,210 @@ +# Online Research + +Primary and authoritative sources for the facts behind this case study. Claims +are tagged **[VERIFIED]** (confirmed against official source code/docs or +multiple corroborating sources), **[REPORTED]** (secondary sources), or +**[INFERENCE]** (analyst deduction). Snapshot: June 2026 — auth client IDs, +endpoints, and quotas change over time, so verify against source before building. + +## CLIProxyAPI — the engine ProxyPal wraps + +- GitHub: · docs: + +- **[VERIFIED]** Tagline: *"Wrap Gemini CLI, Antigravity, ChatGPT Codex, Claude + Code, Grok Build as an OpenAI/Gemini/Claude/Codex compatible API service."* +- **[VERIFIED]** Language **Go**, current major **v6** + (`github.com/router-for-me/CLIProxyAPI/v6`), license **MIT**, org + **router-for-me** (author `luispater`). +- **[VERIFIED]** Client-facing surfaces: OpenAI Chat Completions + (`/v1/chat/completions`), OpenAI Responses (`/v1/responses`), Anthropic + Messages (`/v1/messages`), Gemini native + (`/v1beta/models/{model}:generateContent` / `:streamGenerateContent`), + `/v1/models`, `/healthz`. Supports streaming, tool calls, multimodal. +- **[VERIFIED]** Config (`config.example.yaml`): `port: 8317`, + `auth-dir: "~/.cli-proxy-api"`, `api-keys` (client keys), `remote-management` + (`secret-key`, `disable-control-panel`), provider blocks (`claude-api-key`, + `codex-api-key`, `openai-compatibility`, `vertex-api-key`), `quota-exceeded` + (`switch-project`, `switch-preview-model`), `routing.strategy: round-robin`. +- **[VERIFIED]** Management API base `http://localhost:8317/v0/management`, auth + via `Authorization: Bearer ` or `X-Management-Key`; 5 consecutive auth + failures → ~30-min ban. +- **[REPORTED]** Per-provider login flags with local callback ports: Gemini CLI + `--login` (8085), Codex `--codex-login` (1455), Claude `--claude-login` + (54545); `--no-browser` prints the URL. +- **[VERIFIED]** Reusable as a Go library via `sdk/cliproxy` + (`cliproxy.NewBuilder()` → `Service.Run(ctx)`); translation registry in + `sdk/translator`. + +## Per-provider subscription OAuth + +### Anthropic Claude (Pro/Max via Claude Code) — **[VERIFIED]** + +- Grant: OAuth 2.0 Authorization Code + PKCE (S256). +- Public `client_id`: `9d1c250a-e61b-44d9-88ed-5944d1962f5e`. +- Authorize: `https://claude.ai/oauth/authorize` (subscription) — a separate + `https://console.anthropic.com/oauth/authorize` exists for API-billing orgs. +- Token: `https://console.anthropic.com/v1/oauth/token`. +- Scopes: `org:create_api_key user:profile user:inference` (`user:inference` is + required for `/v1/messages`). +- Tokens: access `sk-ant-oat01-...`, refresh `sk-ant-ort01-...`. +- Storage: macOS Keychain; Linux/Windows `~/.claude/.credentials.json` (0600), + nested `{ "claudeAiOauth": { accessToken, refreshToken, expiresAt(ms), + scopes } }`. +- Refresh: `POST grant_type=refresh_token` to the token endpoint. +- Upstream: `https://api.anthropic.com/v1/messages`, **critical header** + `anthropic-beta: oauth-2025-04-20,claude-code-20250219` (also `x-app: cli`, + `anthropic-dangerous-direct-browser-access: true`). +- Sources: · + +- **This is what the router already does** (file read + beta header injection), + so Claude is our baseline, not a gap. + +### OpenAI Codex / ChatGPT subscription (Codex CLI) — **[VERIFIED] core, [INFERRED] noted** + +- Grant: OAuth 2.0 Authorization Code + PKCE (S256). +- Public `client_id`: `app_EMoamEEZ73f0CkXaXp7hrann`. +- Issuer `https://auth.openai.com` (authorize `/oauth/authorize`, token + `/oauth/token`). Redirect `http://localhost:1455/auth/callback` (fallback 1457). +- Scopes: `openid profile email offline_access api.connectors.read + api.connectors.invoke`. Authorize adds `codex_cli_simplified_flow=true`, + `id_token_add_organizations=true`, `originator=...`. +- Storage: `~/.codex/auth.json` (`CODEX_HOME`); `tokens` object + (`id_token`, `access_token`, `refresh_token`, `account_id`), `last_refresh`. +- Upstream (subscription): base `https://chatgpt.com/backend-api/codex`, + responses at `.../codex/responses` (wire protocol = **Responses API**, + `wire_api="responses"`). Usage: `https://chatgpt.com/backend-api/wham/usage`. +- Required header `chatgpt-account-id: ` (bills the right account); + `originator: codex_cli_rs` and `OpenAI-Beta: responses=experimental` are + **[INFERRED]** — confirm against source. +- Refresh: `grant_type=refresh_token`; sessions stale after ~8 days. +- Sources: + · · + + +### Google Gemini (Gemini CLI / Code Assist) — **[VERIFIED]** + +- Grant: Google OAuth 2.0 Authorization Code, loopback redirect + `http://127.0.0.1:${port}/oauth2callback`, `access_type=offline`. +- Public installed-app client (hardcoded in `oauth2.ts`): client_id + `681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com`, + plus a `GOCSPX-…` installed-app client_secret (not confidential — shipped in + the open-source gemini-cli; redacted here, read it from + [`oauth2.ts`](https://raw.githubusercontent.com/google-gemini/gemini-cli/main/packages/core/src/code_assist/oauth2.ts)). +- Scopes: `cloud-platform`, `userinfo.email`, `userinfo.profile`. +- Upstream: `CODE_ASSIST_ENDPOINT = https://cloudcode-pa.googleapis.com`, + `v1internal`, methods appended with a colon + (`...:streamGenerateContent`). Free accounts send + `cloudaicompanionProject: undefined` (server assigns a managed project); + Workspace accounts need `GOOGLE_CLOUD_PROJECT`. +- Storage: `~/.gemini/oauth_creds.json` (`access_token`, `refresh_token`, + `expiry_date` ms). Refresh via google-auth-library against + `https://oauth2.googleapis.com/token`. +- Sources: + +- **Antigravity** (agentic IDE, launched 2025-11-20) uses the same Code Assist + backend with a different OAuth client — community-reverse-engineered, treat as + **[REPORTED]**: . + +### Alibaba Qwen (qwen-code CLI) — **[VERIFIED]** + +- **Important [VERIFIED]:** the Qwen OAuth **free tier was discontinued + 2026-04-15** (wound down ~1000 → 100 req/day around 2026-04-13). The flow + still works but free quota is gone. + +- Grant: OAuth 2.0 **Device Authorization Grant** + PKCE (S256). Device code + `https://chat.qwen.ai/api/v1/oauth2/device/code`, token + `https://chat.qwen.ai/api/v1/oauth2/token`. +- Public `client_id`: `f0304373b74a44d2b584a3fb70ca9e56`. Scopes + `openid profile email model.completion`. +- Storage: `~/.qwen/oauth_creds.json` (0600). DashScope base + `https://dashscope.aliyuncs.com/compatible-mode/v1` (OpenAI-compatible); + per-token `resource_url` overrides the base. +- Sources: + + +### GitHub Copilot (token exchange) — **[VERIFIED]** + +- Two-token: GitHub OAuth **device flow** (client_id `Iv1.b507a08c87ecfe98`, + scope `read:user`) → exchange at + `GET https://api.github.com/copilot_internal/v2/token` + (header `authorization: token `) → call chat at + `https://api.githubcopilot.com` (`Authorization: Bearer `, + `copilot-integration-id: vscode-chat`). +- Sources: · + + +### iFlow / Vertex AI — **[VERIFIED] brief** + +- **iFlow:** OpenAI-compatible base `https://apis.iflow.cn/v1`; browser login + returns a token/key. Integrates as a plain OpenAI-compatible upstream. +- **Vertex AI:** IAM, not API keys — ADC / service-account; regional base + `https://{LOCATION}-aiplatform.googleapis.com`, + `.../publishers/google/models/{MODEL}:generateContent`. + +## Model / API dialect translation + +| Dialect | Endpoint | System | Messages | Output | +| --- | --- | --- | --- | --- | +| OpenAI | `/v1/chat/completions` | `role:system` in `messages[]` | flat `messages[]` | `choices[].message` | +| Anthropic | `/v1/messages` | top-level `system` | typed content **blocks** | `content[]` + `stop_reason` | +| Gemini | `...:generateContent` | `systemInstruction` | `contents[]` (`user`/`model`) | `candidates[].content.parts[]` | + +**Streaming SSE differs hardest** — **[VERIFIED]**: OpenAI emits +`choices[].delta` chunks ending in literal `data: [DONE]`; Anthropic emits +**named events** (`message_start`, `content_block_delta`, `message_delta`, +`message_stop`) with **no `[DONE]`**; Gemini streams full-shaped partial JSON. + +Known translation projects — **[VERIFIED]**: +- LiteLLM unified `/v1/messages` — + (⚠ avoid PyPI `litellm` 1.82.7/1.82.8 — shipped malware). +- claude-code-router — +- copilot-api (dual OpenAI+Anthropic surface) — +- y-router (Anthropic→OpenAI on Cloudflare) — + +Known pitfalls — **[VERIFIED]** (LiteLLM issues): `{"type":"input_text"}` blocks +silently dropped if an adapter checks only `"text"` (#23841); first non-empty +delta dropped in streaming (#30014); OpenAI's 64-char function-name limit breaks +long Anthropic tool names. + +## Rate limits / quotas & 429 handling + +- **Claude** **[VERIFIED]**: Max 5x $100, Max 20x $200; **5-hour rolling + window** shared across chat + Claude Code, plus weekly caps. + +- **ChatGPT/Codex** **[VERIFIED]**: token/credit-based over a shared 5-hour + window (Plus 15–80 local msgs/5h, Pro 5x 75–400). + +- **Gemini Code Assist** **[VERIFIED]** (req/user/day): Individuals 1000, AI Pro + 1500, AI Ultra 2000. + +- **Qwen** **[VERIFIED]**: free OAuth closed 2026-04-15. +- **Copilot** **[VERIFIED]**: Pro 300 premium req/mo; usage-based billing from + 2026-06-01. +- **CLIProxyAPI 429 handling** **[VERIFIED, source-read]**: `round-robin` or + `fill-first` rotation; session affinity (Claude `metadata.user_id`, Codex + `Session_id`, TTL 1h); per-auth cooldown with `nextRetryAt`; parses both + `Retry-After` and `Retry-After-Ms`; synthesizes a `model_cooldown` 429 with + computed `Retry-After` when all auths are cool. **Our router already does a + fixed 60s cooldown** (`src/accounts.rs`) — this is the upgrade path. + +## Existing Rust building blocks (for solution plans) + +- **oauth2** v5 — typed OAuth2 client, **device-code (RFC 8628) via + `exchange_device_code()`** and **PKCE + (`PkceCodeChallenge::new_random_sha256`)**. +- **openidconnect** v4 — OIDC on top of `oauth2`; warns to disable + redirect-following (SSRF) — relevant to a proxy. +- Stack we already use: **axum** (server + SSE), **reqwest** (upstream), + **tower** (retry/timeout/rate-limit middleware), **hyper**. +- Reference designs: LiteLLM, OpenRouter, claude-code-router, copilot-api; + Rust analogs anthropic-proxy-rs, litellm-rs, modelmux. + +## Confidence flags + +1. CLIProxyAPI literal endpoint paths / login ports are **[REPORTED]** + (corroborated, not all on one official page). +2. Mainline vs "Plus" status of Copilot/Kiro/Qwen/iFlow varies by version. +3. Codex `originator`/`OpenAI-Beta` header values and exact `auth.json` nesting + are **[INFERRED]** — confirm against `openai/codex` source before building. +4. Antigravity OAuth client/scopes are from an unofficial repo. +5. All third-party message-count estimates are unaudited; quotas change. diff --git a/docs/case-studies/issue-37/proxypal-analysis.md b/docs/case-studies/issue-37/proxypal-analysis.md new file mode 100644 index 0000000..33c9016 --- /dev/null +++ b/docs/case-studies/issue-37/proxypal-analysis.md @@ -0,0 +1,157 @@ +# ProxyPal & CLIProxyAPI — Reference Inventory + +The issue asks us to "use all the best experience from +[heyhuynhgiabuu/proxypal](https://github.com/heyhuynhgiabuu/proxypal)". This file +is the deep inventory of what ProxyPal (and the engine it wraps) actually does, +so the requirement list and solution plans have a concrete, evidence-backed +basis. + +> **Snapshot:** ProxyPal `v0.4.42` +> (commit `3c0f0cf5704aff82426e73a7b95a87d05b2b25c6`, 2026-06-16), bundling +> **CLIProxyAPI** sidecar `v7.2.7`. License: MIT. Cloned to `/tmp/proxypal-ref` +> for this analysis; key metadata preserved in [`raw/proxypal/`](./raw/proxypal/). + +## 1. What ProxyPal actually is + +ProxyPal is **not a proxy**. It is a native desktop GUI (Tauri v2 + SolidJS) that +**wraps the Go-based [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) +binary as a sidecar**. CLIProxyAPI is the engine that does all the real work: +OAuth, multi-account rotation, model/API translation, and proxying. ProxyPal's +value is entirely in the *experience layer*: + +- one-click OAuth/device-code login per provider, +- auto-configuring installed coding tools to point at the local proxy, +- usage analytics, request monitoring, and per-provider quota widgets, +- lifecycle management (start/stop, health, tray, updater, tunnels). + +ProxyPal's own README states the stack verbatim: *"SolidJS + TypeScript + +Tailwind (frontend), Rust + Tauri v2 (backend), CLIProxyAPI (proxy)."* This is +**directly relevant prior art** for us: it proves the "thin Rust shell around a +multi-provider proxy engine" pattern — except we already *own* a Rust proxy +engine (`link-assistant/router`), so our task is to grow the engine, not wrap +someone else's. + +``` +ProxyPal architecture Our position +───────────────────── ──────────── +Tauri shell (Rust) We are the engine, in Rust. + └─ SolidJS UI (login, analytics, config) ← the "experience" to adopt + └─ CLIProxyAPI sidecar (Go) ← the multi-provider engine + ├─ Claude / Codex / Gemini / ← the providers to support + │ Qwen / Copilot OAuth + ├─ model/API translation + └─ account rotation + 429 +``` + +## 2. Provider support matrix (ProxyPal `AuthStatus`) + +The authoritative provider list is the Rust `AuthStatus` struct in +`src-tauri/src/types/auth.rs` plus Copilot (a separate subprocess): + +| Provider | Subscription wrapped | Auth method(s) | Notes | +| --- | --- | --- | --- | +| **Claude** | Anthropic Claude Pro/Max | OAuth **or** API key | quota via `api.anthropic.com/api/oauth/usage` | +| **OpenAI (Codex/ChatGPT)** | OpenAI ChatGPT/Codex | OAuth **or** device code **or** API key | quota via `chatgpt.com/backend-api/wham/usage` | +| **Gemini** | Google Gemini (Code Assist) | OAuth **or** API key | thinking-token injection | +| **Qwen** | Alibaba Qwen | OAuth **or** device code | OAuth needs "Plus" sidecar channel | +| **iFlow** | iFlow | OAuth | OpenAI-dialect upstream | +| **Vertex AI** | Google Cloud Vertex | service-account JSON import / API key | IAM, not OAuth | +| **GitHub Copilot** | GitHub Copilot | separate `copilot-api` subprocess (token exchange) | exposed as OpenAI-compatible | +| **Antigravity** | Google Antigravity (thinking) | OAuth | quota via `*-cloudcode-pa.googleapis.com` | +| **Kimi** | Moonshot Kimi | OAuth | model-mapping source | +| **Kiro** | Kiro | OAuth (web UI) | quota via shelling out to `kiro-cli` | +| **Custom** | any OpenAI-compatible | API key + base URL | model aliases | + +Config defaults: port `8317`, client key `proxypal-local`, management key +`proxypal-mgmt-key`, routing `round-robin` (or `fill-first`). + +## 3. OAuth / device-code flows + +**ProxyPal implements no OAuth itself** — it orchestrates UX and delegates every +PKCE/token-exchange to CLIProxyAPI's Management API +(`http://127.0.0.1:{port}/v0/management/*`, header `X-Management-Key`). + +- `get_oauth_url(provider)` / `open_oauth(provider)` → fetch the provider auth + URL (`.../anthropic-auth-url`, `.../codex-auth-url`, + `.../gemini-cli-auth-url`, `.../qwen-auth-url`, ...), open the browser. +- Completion detected three ways: **polling** + (`get-auth-status?state=...` → `status == "ok"`), a **deep link** + (`proxypal://oauth/callback` registered in `tauri.conf.json`), or **manual + code paste**. +- **Source of truth** for "is a provider connected" is a filesystem scan of + `~/.cli-proxy-api/*.json` credential files, counted by filename prefix. +- **Device-code** flow (`DeviceCodeModal.tsx`) for OpenAI and Qwen: shows + `userCode` + `verificationUri`, polls every `interval`s with an `mm:ss` + countdown. + +The actual OAuth endpoints, client IDs, scopes, and token storage formats for +each provider — the part **we** would have to implement in Rust — are documented +in [`online-research.md`](./online-research.md). + +## 4. Auto-configure coding agents + +ProxyPal detects installed CLI/IDE coding tools and rewrites their config to +point at the local proxy. Shared values: endpoint `http://127.0.0.1:{port}/v1`, +key `proxypal-local`. + +| Tool | Detect | Config it writes | +| --- | --- | --- | +| Claude Code | `which claude` | `~/.claude/settings.json` env (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, model-tier maps) | +| Codex | `which codex` | `~/.codex/config.toml` (`base_url`, `wire_api="responses"`) + `~/.codex/auth.json` | +| Gemini CLI | `which gemini` | shell profile `export CODE_ASSIST_ENDPOINT=...` | +| OpenCode | `which opencode` | `~/.config/opencode/opencode.json` provider block | +| Factory Droid | `which droid` | `~/.factory/config.json` custom_models | +| Continue | `~/.continue/` | `~/.continue/config.yaml` model entry | +| Cursor / Cline / GitLab Duo | app/ext | copyable manual instructions only | + +This is **the single most reusable idea for us**: a `router configure ` +command (and `router doctor` detection) that writes the same env/config so a +user points Claude Code / Codex / etc. at the router with one command. + +## 5. Usage analytics, request monitoring, quota widgets + +- **Metrics** (`types/usage.rs`): total/success/failure counts, requests-today, + input/output/cached tokens, per-model and per-provider breakdowns, and + time-series (`requestsByDay`/`Hour`). Each `RequestLog` has status code + + `durationMs` + token counts. +- **Request monitor**: a **log-file watcher** tails `logs/main.log`, parses + `[GIN]` lines, emits a `request-log` event per request (history capped at + 500); token counts back-filled from `/v0/management/usage`. +- **Savings**: `estimate_request_cost(model, in, out)` uses a per-model USD/1M + table (claude opus 15/75, sonnet 3/15, gpt-5 15/45, gemini flash 0.075/0.30, + ...) and shows "money saved vs public API pricing". +- **Per-provider quota widgets**, each through a 5-min TTL cache: Claude + (`api.anthropic.com/api/oauth/usage`, `anthropic-beta: oauth-2025-04-20`), + Codex (`chatgpt.com/backend-api/wham/usage`), Copilot + (`api.github.com/copilot_internal/user`), Antigravity, Kiro. + +## 6. UX features worth noting + +Command palette (⌘K), 3-step setup wizard / onboarding checklist, per-provider +health dots (60s poll), light/dark/system themes, i18n (en/vi/zh-CN), native +notifications, Tauri auto-updater, system tray + single-instance, SSH-tunnel and +Cloudflare-tunnel managers for remote exposure. + +## 7. The 96 Tauri commands (backend surface) + +Grouped by module: proxy lifecycle (4), copilot subprocess (6), auth/OAuth (9), +quota (7), config (5), agents/tools (7), usage (7), models (6), api-keys (~16), +settings (~12), auth-files (8), logs (2), ssh (4), cloudflare (4), updater (1). +The full list is in the research notes; the important ones for us are the +**auth/OAuth**, **agents/tools**, **usage**, and **quota** groups — they encode +the experience the issue wants us to match. + +## 8. Caveats discovered (so we don't copy mistakes) + +1. ProxyPal's `complete_oauth` is a **stub** — real persistence is the sidecar + writing a credential file; the GUI only counts files. Our engine must + actually persist tokens. +2. **Two divergent cost formulas** exist (a per-model Rust table vs a flat + $3/$15 TS estimate). If we add savings accounting, use one source of truth. +3. There is **no live `/v0/management/logs` request feed** — monitoring is a log + tail. We already have structured metrics, so we can do better natively. +4. **Sidecar channel matters**: the mainline CLIProxyAPI build blocks + Qwen/iFlow/Kiro OAuth; the "Plus" channel unlocks them. A reminder that some + providers are moving targets. +5. Qwen's free OAuth tier **was discontinued 2026-04-15** (see research) — Qwen + support is lower priority than Codex/Gemini for real subscription value. diff --git a/docs/case-studies/issue-37/raw/issue-37-comments.json b/docs/case-studies/issue-37/raw/issue-37-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-37/raw/issue-37-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-37/raw/issue-37.json b/docs/case-studies/issue-37/raw/issue-37.json new file mode 100644 index 0000000..ef8174d --- /dev/null +++ b/docs/case-studies/issue-37/raw/issue-37.json @@ -0,0 +1 @@ +{"author":{"id":"MDQ6VXNlcjE0MzE5MDQ=","is_bot":false,"login":"konard","name":"Konstantin Diachenko"},"body":"We should fully support claude, codex, gemini, qwen, and their subscriptions with all our features and more.\n\nWe need to collect data related about the issue to this repository, make sure we compile that data to `./docs/case-studies/issue-{id}` folder, and use it to do deep case study analysis (also make sure to search online for additional facts and data), list of each and all requirements from the issue, and propose possible solutions and solution plans for each requirement (we should also check known existing components/libraries, that solve similar problem or can help in solutions).\n\nPlease plan and execute everything in this single pull request, you have unlimited time and context, as context auto-compacts and you can continue indefinitely, until it is each and every requirement fully addressed, and everything is totally done.","createdAt":"2026-06-17T17:08:04Z","labels":[{"id":"LA_kwDORq-Bqc8AAAACb8uKvw","name":"documentation","description":"Improvements or additions to documentation","color":"0075ca"},{"id":"LA_kwDORq-Bqc8AAAACb8uKxA","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":37,"state":"OPEN","title":"Make sure we use all the best experience from https://github.com/heyhuynhgiabuu/proxypal","url":"https://github.com/link-assistant/router/issues/37"} diff --git a/docs/case-studies/issue-37/raw/proxypal/HEAD-commit.txt b/docs/case-studies/issue-37/raw/proxypal/HEAD-commit.txt new file mode 100644 index 0000000..a907d87 --- /dev/null +++ b/docs/case-studies/issue-37/raw/proxypal/HEAD-commit.txt @@ -0,0 +1 @@ +3c0f0cf5704aff82426e73a7b95a87d05b2b25c6 diff --git a/docs/case-studies/issue-37/raw/proxypal/README.md b/docs/case-studies/issue-37/raw/proxypal/README.md new file mode 100644 index 0000000..94dbbbe --- /dev/null +++ b/docs/case-studies/issue-37/raw/proxypal/README.md @@ -0,0 +1,112 @@ +# ProxyPal + +Use your AI subscriptions (Claude, ChatGPT, Gemini, GitHub Copilot) with any coding tool. Native desktop app wrapping [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). + +[![Release](https://img.shields.io/github/v/release/heyhuynhgiabuu/proxypal?style=flat-square)](https://github.com/heyhuynhgiabuu/proxypal/releases) +[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) +[![Downloads](https://img.shields.io/github/downloads/heyhuynhgiabuu/proxypal/total?style=flat-square)](https://github.com/heyhuynhgiabuu/proxypal/releases) + +![ProxyPal Dashboard](src/assets/dashboard.png) + +## Why ProxyPal? + +You're paying for Claude, ChatGPT, or GitHub Copilot. Why can't you use them in your favorite coding tool? + +ProxyPal bridges that gap. One proxy, all your AI subscriptions, any client. + +## Features + +- **Multiple AI Providers** - Connect Claude, ChatGPT, Gemini, Qwen, iFlow, Vertex AI, and custom OpenAI-compatible endpoints +- **GitHub Copilot Bridge** - Use Copilot models via OpenAI-compatible API +- **Antigravity Support** - Access thinking models through Antigravity proxy +- **Works Everywhere** - Cursor, Cline, Continue, Claude Code, OpenCode, and any OpenAI-compatible client +- **Usage Analytics** - Track requests, tokens, success rates, and estimated savings +- **Request Monitoring** - View all API requests with response times and status codes +- **Auto-Configure** - Detects installed CLI agents and sets them up automatically + +## Quick Start + +1. Download from [Releases](https://github.com/heyhuynhgiabuu/proxypal/releases) +2. Launch ProxyPal and start the proxy +3. Connect your AI accounts (OAuth or auth files) +4. Point your coding tool to `http://localhost:8317/v1` + +### macOS Users + +The app is not signed with an Apple Developer certificate yet. If macOS blocks the app: + +```bash +xattr -cr /Applications/ProxyPal.app +``` + +## Supported Platforms + +| Platform | Architecture | Status | +| -------- | --------------------- | ------ | +| macOS | Apple Silicon (ARM64) | ✅ | +| macOS | Intel (x64) | ✅ | +| Windows | x64 | ✅ | +| Linux | x64 (.deb) | ✅ | + +## Supported Clients + +Works with Cursor, Claude Code, OpenCode, Cline, Continue, GitHub Copilot, and any OpenAI-compatible client. + +## Development + +```bash +pnpm install +pnpm tauri dev +``` + +### Checks + +```bash +pnpm check:ts # tsgo when installed, otherwise tsc --noEmit +pnpm check:parallel # check:ts + lint + format:check (parallel) +cd src-tauri && cargo check +``` + +Optional tsgo setup: + +```bash +pnpm add -D @typescript/native-preview +``` + +Optional VS Code setting: + +```json +{ + "typescript.experimental.useTsgo": true +} +``` + +**Tech Stack**: SolidJS + TypeScript + Tailwind (frontend), Rust + Tauri v2 (backend), CLIProxyAPI (proxy) + +## Contributing + +1. **One feature per PR** - Keep changes focused +2. **Clean commits** - No unrelated changes +3. **Test your changes** - Run `pnpm tauri dev` and verify +4. **Follow existing patterns** - Check similar implementations + +### Adding a New Agent + +- Add detection logic in `src-tauri/src/lib.rs` +- Add logo to `public/logos/` (use `currentColor` for dark mode) +- Update agents array in relevant components +- Test auto-configuration flow + +## Support + +If ProxyPal saves you time, consider [buying me a coffee](https://buymeacoffee.com/heyhuynhgiabuu). + +## License + +MIT + +--- + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=heyhuynhgiabuu/proxypal&type=Date)](https://star-history.com/#heyhuynhgiabuu/proxypal&Date) diff --git a/docs/case-studies/issue-37/raw/proxypal/package.json b/docs/case-studies/issue-37/raw/proxypal/package.json new file mode 100644 index 0000000..ee4d00e --- /dev/null +++ b/docs/case-studies/issue-37/raw/proxypal/package.json @@ -0,0 +1,63 @@ +{ + "name": "proxypal", + "version": "0.4.42", + "description": "", + "license": "MIT", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest", + "lint": "oxlint -c oxlint.config.mjs .", + "lint:fix": "oxlint -c oxlint.config.mjs --fix .", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check:ts": "node scripts/check-ts.mjs", + "check:parallel": "node scripts/check-parallel.mjs", + "check": "pnpm tsc --noEmit && pnpm lint && pnpm format:check", + "update-sidecar": "node scripts/update-sidecar.mjs --force" + }, + "dependencies": { + "@kobalte/core": "^0.13.11", + "@kobalte/tailwindcss": "^0.9.0", + "@solid-primitives/i18n": "^2.2.1", + "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-notification": "^2.3.3", + "@tauri-apps/plugin-opener": "^2.5.3", + "@tauri-apps/plugin-os": "^2.3.2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.0", + "chart.js": "^4.5.1", + "echarts": "^6.0.0", + "solid-echarts": "^0.0.4", + "solid-js": "^1.9.11" + }, + "devDependencies": { + "@nkzw/eslint-plugin": "^2.0.0", + "@nkzw/oxlint-config": "^1.0.1", + "@solidjs/router": "^0.15.4", + "@solidjs/testing-library": "^0.8.10", + "@tauri-apps/cli": "^2.10.0", + "@testing-library/jest-dom": "^6.9.1", + "autoprefixer": "^10.4.24", + "eslint-plugin-no-only-tests": "^3.3.0", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-unused-imports": "^4.4.1", + "jsdom": "^28.1.0", + "oxfmt": "^0.34.0", + "oxlint": "^1.49.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "~5.6.3", + "vite": "^6.4.1", + "vite-plugin-solid": "^2.11.10", + "vitest": "^4.0.18" + } +} diff --git a/docs/case-studies/issue-37/raw/proxypal/sidecar-version.txt b/docs/case-studies/issue-37/raw/proxypal/sidecar-version.txt new file mode 100644 index 0000000..4afc54e --- /dev/null +++ b/docs/case-studies/issue-37/raw/proxypal/sidecar-version.txt @@ -0,0 +1 @@ +7.2.7 diff --git a/docs/case-studies/issue-37/requirements.md b/docs/case-studies/issue-37/requirements.md new file mode 100644 index 0000000..16f6877 --- /dev/null +++ b/docs/case-studies/issue-37/requirements.md @@ -0,0 +1,61 @@ +# Issue 37 Requirements Trace + +The issue has two layers: + +1. **Process requirements** — the explicit deliverables the issue spells out + (collect data, analyze, list requirements, propose plans, survey components, + do it all in PR #38). These are *completed by this case study*. +2. **Functional requirements** — the super-goal it states: *"fully support + claude, codex, gemini, qwen, and their subscriptions with all our features and + more."* Fully shipping all of these is a multi-PR roadmap; this case study's + job (per the process requirements) is to enumerate them and propose a + solution/plan for each. They are tracked here and detailed in + [`solution-plans.md`](./solution-plans.md). + +## Layer 1 — Process requirements (delivered in this PR) + +| # | Requirement (from the issue) | Solution | Status | Evidence | +| --- | --- | --- | --- | --- | +| P1 | Collect data related to the issue into `docs/case-studies/issue-37/`. | This folder: issue JSON/comments + ProxyPal snapshot metadata captured under `raw/`. | Done | `raw/issue-37.json`, `raw/issue-37-comments.json`, `raw/proxypal/` | +| P2 | Deep case-study analysis, **searching online for additional facts and data**. | `README.md` (analysis), `proxypal-analysis.md` (reference inventory), `online-research.md` (cited primary sources for every provider's OAuth/quotas). | Done | `README.md`, `proxypal-analysis.md`, `online-research.md` | +| P3 | List **each and all** requirements from the issue. | This file (`requirements.md`) — both process and functional requirements traced. | Done | `requirements.md` | +| P4 | Propose possible solutions and solution **plans for each requirement**. | `solution-plans.md` — phased plan per provider/feature, with file-level touch points and acceptance criteria. | Done | `solution-plans.md` | +| P5 | Check known existing components/libraries that solve a similar problem or can help. | `components-survey.md` — surveys CLIProxyAPI, ProxyPal, LiteLLM, `oauth2`/`openidconnect` crates, etc., with build-vs-borrow decisions. | Done | `components-survey.md` | +| P6 | Do everything in the single PR #38 (update the existing draft, don't open a new one). | All commits land on `issue-37-69d3f0803294`; PR #38 updated. | Tracked in GitHub | PR #38 | + +## Layer 2 — Functional requirements (the super-goal, planned here) + +Derived by decomposing *"fully support claude, codex, gemini, qwen, and their +subscriptions with all our features and more"* against the current router +([`README.md` §gap analysis](./README.md)) and the ProxyPal/CLIProxyAPI feature +set. Each has a plan in [`solution-plans.md`](./solution-plans.md); "Status" is +the state **as of this PR**, which implements the subscription engine +(credential reading, API routing, dialect translation, in-memory refresh) with +**no UI**, per the governing directive on PR #38. + +| # | Functional requirement | Proposed solution (summary) | Status | Plan | +| --- | --- | --- | --- | --- | +| F1 | Support **Claude** subscription (Pro/Max OAuth). | Shipped: `src/oauth.rs` reads nested `~/.claude` creds; beta header injected. | **Done** (baseline) | Plan 1 | +| F2 | Support **OpenAI Codex / ChatGPT** subscription. | Implemented: `src/subscription.rs` reads `~/.codex/auth.json`; `src/subscription_proxy.rs` translates Chat Completions → Responses and routes to `chatgpt.com/backend-api/codex/responses` with `chatgpt-account-id`. | **Done** | Plan 2 | +| F3 | Support **Google Gemini** subscription (Code Assist). | Implemented: `src/gemini.rs` reads `~/.gemini/oauth_creds.json`, translates OpenAI ↔ Code Assist `generateContent`, synthesizes SSE for streaming. | **Done** | Plan 3 | +| F4 | Support **Qwen** subscription (qwen-code). | Implemented: reads `~/.qwen/oauth_creds.json` → DashScope OpenAI-compatible base (per-token `resource_url` override). | **Done** | Plan 4 | +| F5 | **Generalize the provider abstraction** so each provider has its own auth + model map + dialect translation. | Implemented: `SubscriptionProvider` enum (auth/home/base-url) + `UpstreamProvider::{Codex,Gemini,Qwen}` dispatch in `src/proxy.rs`. | **Done** | Plan 5 | +| F6 | **Native login flows** so the router stands alone without vendor CLIs. | Deferred by design: best practice is to delegate login to each vendor CLI (avoids duplicating OAuth flows / storing secrets); the router reads the resulting credential files. | Deferred (design choice) | Plan 6 | +| F7 | **Token refresh & expiry** across providers. | Implemented: `src/refresh.rs` exchanges refresh tokens via each vendor's public OAuth client and caches in memory; vendor files stay read-only. | **Done** | Plan 6 | +| F8 | **Cross-provider multi-account pool** with smart routing/cooldown ("all our features"). | Partial: `Retry-After`/`x-ratelimit-*` headers relayed to clients; Claude multi-account pool retained in `src/accounts.rs`. Single-credential subscription providers expose one account each. | **Partial** | Plan 7 | +| F9 | **Auto-configure client tools**. | Deferred: out of scope for the no-UI engine deliverable; vendor CLIs configure themselves. | Deferred | Plan 8 | +| F10 | **Per-provider usage/quota & savings** observability ("and more"). | `router doctor` reports per-provider credential/token validity; full per-provider cost accounting tracked as follow-up. | Partial | Plan 9 | +| F11 | **Dialect translation matrix** (OpenAI ↔ Anthropic ↔ Gemini, incl. SSE). | Implemented: `src/openai.rs` (`chat_completion_to_responses`) + `src/gemini.rs` translators with SSE synthesis. | **Done** | Plan 5 | +| F12 | **GUI / dashboard** — optional. | Out of scope: the governing directive explicitly excludes UI support. | Out of scope (per directive) | Plan 10 | + +## Out of scope / explicitly deferred + +- Building a Tauri desktop app (F12) — the router is a single-binary engine; a + GUI is a separate deliverable. We document the option, not build it. +- Providers beyond the four named (iFlow, Vertex, Copilot, Antigravity, Kimi, + Kiro) — covered as "and more" in the survey and reachable via the same + generalized abstraction (F5), but not required by the issue's core list. +- Shipping every provider's production-tested OAuth in this PR — real + subscription credentials are required to verify each end-to-end, so + implementation is sequenced as follow-up PRs per Plan, each with its own live + test evidence (mirroring how issue #35 verified Claude against a real session). diff --git a/docs/case-studies/issue-37/solution-plans.md b/docs/case-studies/issue-37/solution-plans.md new file mode 100644 index 0000000..0435b53 --- /dev/null +++ b/docs/case-studies/issue-37/solution-plans.md @@ -0,0 +1,270 @@ +# Solution Plans + +One plan per functional requirement from [`requirements.md`](./requirements.md). +Each plan states the goal, the concrete approach (with file-level touch points in +this repo), the existing components reused (see +[`components-survey.md`](./components-survey.md)), acceptance criteria, and how to +verify it against a real subscription. + +> **Sequencing principle — "read before login".** For every new provider, ship +> **Phase 1 (read credentials produced by the vendor CLI)** first. It mirrors how +> the router already supports Claude (`src/oauth.rs` reads `~/.claude`), needs +> *no* OAuth code, and immediately delivers subscription access. **Phase 2 +> (native `router login`)** removes the vendor-CLI dependency afterward. + +> **Verification principle.** Each provider PR must include live evidence against +> a real subscription (a redacted request/response, like issue #35's +> `raw/count_tokens-200.json`). This planning PR cannot verify Codex/Gemini/Qwen +> because those subscriptions are not available in this environment; that is why +> implementation is sequenced as follow-up PRs. + +--- + +## Plan 5 (foundational) — Generalize the provider abstraction (F5, F11) + +**Why first:** F2–F4 all depend on a provider abstraction richer than today's +single-variant `ProviderKind { OpenAICompatible }` (`src/providers.rs`). + +**Approach.** Introduce a provider descriptor that captures the four things that +differ per provider: + +```rust +// sketch — src/providers.rs +enum ProviderKind { + Anthropic, // OAuth, /v1/messages, beta header (today's behavior) + OpenAICodex, // ChatGPT subscription, Responses wire API + GeminiCodeAssist, // Google OAuth, cloudcode-pa v1internal + QwenCode, // DashScope OpenAI-compatible + OpenAICompatible, // existing generic upstream +} + +trait UpstreamProvider { + fn base_url(&self) -> Url; + fn auth_headers(&self, cred: &Credential) -> HeaderMap; // bearer + provider-specific headers + fn translate_request(&self, body: Value, from: Dialect) -> Value; + fn translate_response(&self, body: Value, to: Dialect) -> Value; // incl. SSE + fn usage_endpoint(&self) -> Option; // for Plan 9 +} +``` + +- Centralize dialect conversion in a small **translation registry** (per + CLIProxyAPI's `sdk/translator` design) so `src/openai.rs`'s existing + OpenAI↔Anthropic logic and the new Gemini logic live behind one interface. +- Keep `UpstreamProvider` config enum (`src/config.rs`) but let a single router + instance host multiple provider-typed accounts (ties into Plan 7). + +**Reuse:** existing `src/openai.rs` translation; CLIProxyAPI architecture as the +blueprint. + +**Acceptance:** existing Anthropic + OpenAI-compatible paths unchanged (all +current tests green); new provider kinds compile behind the trait with unit +tests for each translation direction. + +--- + +## Plan 1 — Claude subscription hardening (F1) + +**Status:** baseline already works (issue #35). **Approach:** add native token +**refresh** (Plan 6) so an expired `~/.claude` token is refreshed via +`https://console.anthropic.com/v1/oauth/token` instead of only logging a warning +(`src/oauth.rs::refresh_token()` today is a file re-read). Add `router login +claude` (PKCE, client_id `9d1c250a-...`) so the router can authenticate without +the `claude` CLI. **Acceptance:** expired token auto-refreshes; `router login +claude` produces a working credential; live `/v1/messages` 200. + +--- + +## Plan 2 — OpenAI Codex / ChatGPT subscription (F2) + +**Phase 1 (read).** Add a credential reader for `~/.codex/auth.json` (the +`tokens` object: `access_token`, `refresh_token`, `account_id`). Add provider +kind `OpenAICodex`: + +- base `https://chatgpt.com/backend-api/codex`, requests to `.../responses` + (wire API = **OpenAI Responses**, which `src/openai.rs` already translates to + via `response_to_anthropic` / `anthropic_to_response`). +- headers: `Authorization: Bearer `, + `chatgpt-account-id: `; confirm `originator` / `OpenAI-Beta` + against `openai/codex` source before shipping (flagged **[INFERRED]** in + research). + +**Phase 2 (login).** `router login codex` — OAuth Auth-Code + PKCE, client_id +`app_EMoamEEZ73f0CkXaXp7hrann`, issuer `https://auth.openai.com`, local callback +`http://localhost:1455/auth/callback`, scopes incl. `offline_access`; parse +`account_id` from the id-token. + +**Reuse:** `oauth2` crate (PKCE), existing Responses translation. +**Acceptance:** a ChatGPT/Codex subscription answers `/v1/chat/completions` and +`/v1/messages` through the router; 401 triggers a refresh; live evidence saved to +a future `raw/`. **Risk:** ChatGPT backend headers are partly inferred — verify +first. + +--- + +## Plan 3 — Google Gemini subscription / Code Assist (F3) + +**Phase 1 (read).** Read `~/.gemini/oauth_creds.json` (`access_token`, +`refresh_token`, `expiry_date`). Provider kind `GeminiCodeAssist`: + +- base `https://cloudcode-pa.googleapis.com`, `v1internal`, method appended with + a colon (`...:streamGenerateContent`). +- free accounts: omit project (`cloudaicompanionProject: undefined`); Workspace + accounts: read `GOOGLE_CLOUD_PROJECT`. +- **new translation:** OpenAI/Anthropic ↔ Gemini (`systemInstruction`, + `contents[]` with `user`/`model` roles, `parts[]`; SSE streams full-shaped + partial JSON — no `[DONE]`). This is the largest net-new translation work + (Plan 5 registry). + +**Phase 2 (login).** `router login gemini` — Google OAuth Auth-Code, loopback +`http://127.0.0.1:{port}/oauth2callback`, the public installed-app client from +`gemini-cli`, scopes `cloud-platform`/`userinfo.*`, `access_type=offline`; +refresh via `https://oauth2.googleapis.com/token`. + +**Reuse:** `oauth2` crate; google-auth refresh pattern. +**Acceptance:** a Gemini subscription answers via OpenAI- and Anthropic-dialect +requests through the router; streaming works; live evidence saved. + +--- + +## Plan 4 — Qwen subscription (F4, lower priority) + +**Note:** the Qwen **free OAuth tier was discontinued 2026-04-15** — value is now +limited to paid Coding-Plan accounts, so this ranks below Codex/Gemini. + +**Phase 1 (read).** Read `~/.qwen/oauth_creds.json`; route to the per-token +`resource_url` (or DashScope `https://dashscope.aliyuncs.com/compatible-mode/v1`) +which is **OpenAI-compatible** — so it largely reuses the existing +`openai-compatible` path with `Authorization: Bearer `. + +**Phase 2 (login).** `router login qwen` — OAuth **device-code** + PKCE +(`oauth2::exchange_device_code`), client_id `f0304373...`, device endpoint +`https://chat.qwen.ai/api/v1/oauth2/device/code`; show user-code + verification +URI; poll respecting `slow_down`. + +**Reuse:** existing OpenAI-compatible upstream; `oauth2` device-code. +**Acceptance:** a Qwen account answers through the router; device-code login +completes. + +--- + +## Plan 6 — Native login + refresh framework (F6, F7) + +**Approach.** A single `router login ` subcommand (extend +`src/cli.rs` `Command`) backed by a small `auth/login.rs` module using the +**`oauth2`** crate: + +- Authorization-Code + PKCE for Claude/Codex/Gemini (spin a one-shot localhost + callback server on the provider's expected port). +- Device-code (RFC 8628) for Qwen/Copilot (poll with countdown). +- `--no-browser` prints the URL (match CLIProxyAPI ergonomics). +- Persist credentials in a provider-scoped store (extend the credential model so + refresh tokens + `expires_at` are saved and AES-GCM-encrypted, reusing the + `aes-gcm` machinery already in `src/providers.rs`). +- Replace `src/oauth.rs::refresh_token()` (no-op file re-read) with real + `exchange_refresh_token()` per provider, refreshing proactively before + `expires_at`. + +**Reuse:** `oauth2` v5 (PKCE + device-code + refresh), `aes-gcm` (already a dep). +**Acceptance:** `router login ` works headless and interactively; +tokens auto-refresh; `router doctor` reports each provider's credential status +(extends the existing doctor probe). + +--- + +## Plan 7 — Cross-provider account pool + smart cooldown (F8) + +**Approach.** Generalize `src/accounts.rs::AccountRouter` so an `AccountState` +carries a provider kind + credential, not just a Claude `OAuthProvider`. Then: + +- selection respects provider (route a request to an account that can serve the + requested model/dialect); +- adopt **`fill-first`** in addition to round-robin/priority/least-used; +- replace the fixed 60s cooldown with **`Retry-After` / `Retry-After-Ms` + parsing** and a per-auth `next_retry_at`; synthesize a `model_cooldown` 429 + with computed `Retry-After` when all candidates are cool (CLIProxyAPI design); +- optional **session affinity** (pin a conversation to one account) and inbound + per-token rate limiting via `tower` (a known CLIProxyAPI gap). + +**Reuse:** existing `AccountRouter`, `SelectionStrategy`, `tower`. +**Acceptance:** mixed-provider pool serves requests; 429 from one account fails +over and honors the server's retry hint; `/v1/accounts` shows per-provider +health. + +--- + +## Plan 8 — Auto-configure client tools (F9) + +**Approach.** New `router configure ` plus detection in `router doctor` +(extend `src/cli.rs`), porting ProxyPal's `configure_cli_agent` logic as pure +file I/O: + +| Tool | Writes | +| --- | --- | +| `claude-code` | `~/.claude/settings.json` env: `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN=`, model-tier maps | +| `codex` | `~/.codex/config.toml` (`base_url`, `wire_api="responses"`) + `auth.json` | +| `gemini-cli` | shell profile `export CODE_ASSIST_ENDPOINT=...` | +| `opencode` | `~/.config/opencode/opencode.json` provider block | +| `continue` | `~/.continue/config.yaml` model entry | +| others | print copyable manual instructions (`router configure --show `) | + +Detection mirrors ProxyPal's `which_exists()` over PATH/known dirs. Always +idempotent (guard markers, like ProxyPal's `# ProxyPal` → our `# link-assistant-router`). + +**Reuse:** existing `doctor` subcommand; std fs. +**Acceptance:** after `router configure claude-code`, Claude Code talks to the +router with one command; re-running is idempotent; `router doctor` lists detected +tools and their config status. + +--- + +## Plan 9 — Per-provider usage, quota & savings (F10) + +**Approach.** Extend `src/metrics.rs`: + +- add per-provider and per-token token-count + estimated-cost accounting (single + source-of-truth cost table — avoid ProxyPal's two-formula bug); +- `router quota` / `GET /v1/quota` polling vendor usage endpoints with a short + TTL cache: Claude `api.anthropic.com/api/oauth/usage` + (`anthropic-beta: oauth-2025-04-20`), Codex `chatgpt.com/backend-api/wham/usage`, + Copilot `api.github.com/copilot_internal/user`; +- surface in `/v1/usage` (already exists) and Prometheus. + +**Reuse:** existing `Metrics`, `/v1/usage`, `/metrics`. +**Acceptance:** `/v1/usage` shows per-provider tokens + cost; `router quota` +reports remaining subscription quota per connected provider. + +--- + +## Plan 10 — GUI / dashboard (F12, optional / future) + +**Approach.** The engine should expose everything via CLI + JSON endpoints +(Plans 6–9) so a UI is optional. Two documented options, neither required by this +issue: + +1. **Thin web dashboard** served by the router (static SPA hitting the existing + admin/usage/quota JSON endpoints) — single binary, no extra runtime. +2. **Desktop wrapper** à la ProxyPal (Tauri) pointing at the router instead of + bundling a Go sidecar — only if a native app is desired. + +**Recommendation:** defer; revisit after Plans 2–9 land, since the CLI delivers +the issue's functional goals without it. + +--- + +## Suggested execution order (follow-up PRs) + +1. **Plan 5** — provider abstraction + translation registry (foundational, no + behavior change). +2. **Plan 2 Phase 1** — Codex via `~/.codex` read (highest subscription value, + reuses Responses translation). +3. **Plan 3 Phase 1** — Gemini via `~/.gemini` read (adds Gemini translation). +4. **Plan 6** — native `router login` + refresh (removes vendor-CLI dependency + for Claude/Codex/Gemini). +5. **Plan 7** — cross-provider pool + smart cooldown. +6. **Plan 8** — `router configure` auto-setup. +7. **Plan 9** — per-provider usage/quota. +8. **Plan 4** — Qwen (lower priority, free tier gone). +9. **Plan 10** — optional GUI. + +Each PR: extend the matching gap in the engine, add unit tests, and attach live +evidence against a real subscription (per the verification principle). diff --git a/src/activitypub.rs b/src/activitypub.rs index 527c1e4..ed59bbe 100644 --- a/src/activitypub.rs +++ b/src/activitypub.rs @@ -4,9 +4,9 @@ //! a ForgeFed-capable problem source can discover and address it. use axum::extract::State; -use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::proxy::AppState; @@ -208,11 +208,13 @@ mod tests { ); assert_eq!(doc["publicKey"]["owner"], doc["id"]); assert_eq!(doc["publicKey"]["publicKeyPem"], KEY); - assert!(doc["@context"] - .as_array() - .expect("context array") - .iter() - .any(|item| item == "https://forgefed.org/ns")); + assert!( + doc["@context"] + .as_array() + .expect("context array") + .iter() + .any(|item| item == "https://forgefed.org/ns") + ); assert!(doc["aliases"].as_array().expect("aliases").len() >= 2); } diff --git a/src/cli.rs b/src/cli.rs index d24f812..cf41317 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,8 +26,8 @@ use clap::Subcommand; use lino_arguments::Parser as LinoParser; use crate::config::{ - default_activitypub_public_key_pem, default_data_dir, ApiFormat, BuildArgs, Config, - ConfigError, RoutingMode, StoragePolicy, UpstreamProvider, + ApiFormat, BuildArgs, Config, ConfigError, RoutingMode, StoragePolicy, UpstreamProvider, + default_activitypub_public_key_pem, default_data_dir, }; /// Top-level CLI parser. diff --git a/src/config.rs b/src/config.rs index 71744d0..4a2514c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,12 @@ pub enum UpstreamProvider { Gonka, /// Crater `ForgeFed` task provider. Crater, + /// `OpenAI` Codex / `ChatGPT` subscription via `~/.codex` OAuth credentials. + Codex, + /// Google Gemini Code Assist subscription via `~/.gemini` OAuth credentials. + Gemini, + /// Alibaba Qwen subscription via `~/.qwen` OAuth credentials (`DashScope`). + Qwen, /// Generic OpenAI-compatible inference provider, including `LiteLLM` proxy. OpenAICompatible, } @@ -37,12 +43,29 @@ impl UpstreamProvider { "anthropic" | "claude" => Some(Self::Anthropic), "gonka" => Some(Self::Gonka), "crater" | "forgefed" => Some(Self::Crater), + "codex" | "chatgpt" | "openai-codex" => Some(Self::Codex), + "gemini" | "google" | "code-assist" => Some(Self::Gemini), + "qwen" | "qwen-code" | "dashscope" => Some(Self::Qwen), "openai" | "openai-compatible" | "openai_like" | "litellm" => { Some(Self::OpenAICompatible) } _ => None, } } + + /// The subscription provider backing this upstream, when it is one of the + /// vendor-subscription providers (Claude/Codex/Gemini/Qwen). + #[must_use] + pub const fn subscription_provider(self) -> Option { + use crate::subscription::SubscriptionProvider as S; + match self { + Self::Anthropic => Some(S::Claude), + Self::Codex => Some(S::Codex), + Self::Gemini => Some(S::Gemini), + Self::Qwen => Some(S::Qwen), + Self::Gonka | Self::Crater | Self::OpenAICompatible => None, + } + } } /// Supported upstream API formats accepted by the router. diff --git a/src/crater.rs b/src/crater.rs index fde5db4..d5193b9 100644 --- a/src/crater.rs +++ b/src/crater.rs @@ -10,7 +10,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use reqwest::Client; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::sync::Arc; use std::time::{Duration, Instant}; diff --git a/src/gemini.rs b/src/gemini.rs new file mode 100644 index 0000000..db4c33d --- /dev/null +++ b/src/gemini.rs @@ -0,0 +1,577 @@ +//! Google Gemini (Code Assist) subscription upstream. +//! +//! Gemini speaks neither the Anthropic nor the `OpenAI` wire format, so requests +//! are translated `OpenAI` ↔ Gemini `generateContent` and forwarded to the Code +//! Assist endpoint (`cloudcode-pa.googleapis.com`, `v1internal`) using the +//! subscription OAuth token read by [`crate::subscription`]. +//! +//! The Code Assist API wraps a standard `GenerateContentRequest` in an envelope +//! that also carries the `model` and (optionally) a Cloud project id. We build +//! that envelope here. Streaming clients receive a synthesized single-delta SSE +//! sequence: the upstream is called non-streaming and the result re-emitted in +//! `OpenAI`'s `chat.completion.chunk` shape, which keeps the translation simple +//! and fully deterministic without a Gemini SSE parser. + +#![allow(clippy::unused_async)] + +use axum::body::Body; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Response; +use serde_json::{Value, json}; + +use crate::metrics::Surface; +use crate::proxy::{AppState, error_response, extract_client_token, maybe_mpp_challenge}; + +/// Environment variable carrying the Google Cloud project id for Code Assist. +pub const PROJECT_ENV: &str = "GEMINI_PROJECT"; + +/// Default Gemini model used when a request omits `model`. +pub const DEFAULT_MODEL: &str = "gemini-2.5-pro"; + +/// `GET /v1/models` listing for the Gemini subscription upstream. +#[must_use] +pub fn list_models() -> Value { + let now = chrono::Utc::now().timestamp(); + let entries = [ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + ]; + let data: Vec = entries + .iter() + .map(|id| { + json!({ + "id": id, + "object": "model", + "created": now, + "owned_by": "google", + }) + }) + .collect(); + json!({"object": "list", "data": data}) +} + +/// Translate an `OpenAI` Chat Completions request body to a Gemini +/// `GenerateContentRequest`. +#[must_use] +pub fn chat_to_gemini_request(body: &Value) -> Value { + let mut contents: Vec = Vec::new(); + let mut system_parts: Vec = Vec::new(); + + if let Some(messages) = body.get("messages").and_then(Value::as_array) { + for msg in messages { + let role = msg.get("role").and_then(Value::as_str).unwrap_or("user"); + let text = extract_message_text(msg.get("content")); + match role { + "system" | "developer" => { + system_parts.push(json!({ "text": text })); + } + "assistant" => contents.push(json!({ + "role": "model", + "parts": [{ "text": text }], + })), + // user, tool, and anything else map to a user turn. + _ => contents.push(json!({ + "role": "user", + "parts": [{ "text": text }], + })), + } + } + } + + let mut generation_config = json!({}); + if let Some(max) = body + .get("max_completion_tokens") + .or_else(|| body.get("max_tokens")) + .and_then(Value::as_u64) + { + generation_config["maxOutputTokens"] = json!(max); + } + if let Some(t) = body.get("temperature").and_then(Value::as_f64) { + generation_config["temperature"] = json!(t); + } + if let Some(t) = body.get("top_p").and_then(Value::as_f64) { + generation_config["topP"] = json!(t); + } + + let mut request = json!({ "contents": contents }); + if !system_parts.is_empty() { + request["systemInstruction"] = json!({ "parts": system_parts }); + } + if generation_config.as_object().is_some_and(|o| !o.is_empty()) { + request["generationConfig"] = generation_config; + } + request +} + +/// Wrap a `GenerateContentRequest` in the Code Assist envelope. +#[must_use] +pub fn code_assist_envelope(model: &str, request: &Value) -> Value { + let mut envelope = json!({ + "model": model, + "request": request, + }); + if let Ok(project) = std::env::var(PROJECT_ENV) { + if !project.is_empty() { + envelope["project"] = Value::String(project); + } + } + envelope +} + +/// Translate a Gemini `GenerateContentResponse` to an `OpenAI` Chat Completion. +#[must_use] +pub fn gemini_response_to_chat(resp: &Value, model: &str) -> Value { + // Code Assist nests the real response under `response`; standard Gemini + // returns it at the top level. Accept both. + let inner = resp.get("response").unwrap_or(resp); + let mut text = String::new(); + let mut finish_reason = "stop"; + if let Some(candidate) = inner + .get("candidates") + .and_then(Value::as_array) + .and_then(|c| c.first()) + { + if let Some(parts) = candidate + .get("content") + .and_then(|c| c.get("parts")) + .and_then(Value::as_array) + { + for part in parts { + if let Some(t) = part.get("text").and_then(Value::as_str) { + text.push_str(t); + } + } + } + if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) { + finish_reason = map_finish_reason(reason); + } + } + + let usage = inner.get("usageMetadata"); + let prompt_tokens = usage + .and_then(|u| u.get("promptTokenCount")) + .and_then(Value::as_u64) + .unwrap_or(0); + let completion_tokens = usage + .and_then(|u| u.get("candidatesTokenCount")) + .and_then(Value::as_u64) + .unwrap_or(0); + + json!({ + "id": format!("chatcmpl-{}", uuid::Uuid::new_v4()), + "object": "chat.completion", + "created": chrono::Utc::now().timestamp(), + "model": model, + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": text }, + "finish_reason": finish_reason, + }], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + }) +} + +fn map_finish_reason(gemini: &str) -> &'static str { + match gemini { + "MAX_TOKENS" => "length", + "SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" => "content_filter", + _ => "stop", + } +} + +fn extract_message_text(content: Option<&Value>) -> String { + match content { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => { + let mut buf = String::new(); + for part in parts { + if let Some(t) = part.get("text").and_then(Value::as_str) { + buf.push_str(t); + } else if let Some(s) = part.as_str() { + buf.push_str(s); + } + } + buf + } + _ => String::new(), + } +} + +/// `POST /v1/chat/completions` for the Gemini subscription upstream. +pub async fn forward_chat_completions( + state: &AppState, + headers: &HeaderMap, + body: Value, +) -> Response { + forward(state, headers, body, Surface::OpenAIChat, ShapeIn::Chat).await +} + +/// `POST /v1/responses` for the Gemini subscription upstream. +pub async fn forward_responses(state: &AppState, headers: &HeaderMap, body: Value) -> Response { + forward( + state, + headers, + body, + Surface::OpenAIResponses, + ShapeIn::Responses, + ) + .await +} + +#[derive(Clone, Copy)] +enum ShapeIn { + Chat, + Responses, +} + +async fn forward( + state: &AppState, + headers: &HeaderMap, + body: Value, + surface: Surface, + shape: ShapeIn, +) -> Response { + if let Some(resp) = maybe_mpp_challenge(state, headers, "/v1/chat/completions") { + return resp; + } + let Some(token) = extract_client_token(headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "authentication_error", + "Missing Authorization Bearer token or x-api-key", + ); + }; + if let Err(e) = state.token_manager.validate_token(token) { + let status = match &e { + crate::token::TokenError::Revoked => StatusCode::FORBIDDEN, + _ => StatusCode::UNAUTHORIZED, + }; + return error_response(status, "authentication_error", &format!("{e}")); + } + + let Some(reader) = state.subscription_reader.as_ref() else { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "api_error", + "subscription credentials reader is not configured", + ); + }; + let disk_token = match reader.read_token() { + Ok(token) => token, + Err(e) => { + return error_response( + StatusCode::BAD_GATEWAY, + "authentication_error", + &format!("failed to read Gemini subscription credentials: {e}"), + ); + } + }; + // Refresh in memory if the on-disk token has expired; vendor files stay + // read-only. + let now_ms = chrono::Utc::now().timestamp_millis(); + let sub_token = state + .subscription_cache + .get_fresh( + &state.client, + crate::subscription::SubscriptionProvider::Gemini, + disk_token, + now_ms, + ) + .await; + + // Normalize Responses input into the Chat `messages` shape so a single + // translator handles both surfaces. + let chat_body = match shape { + ShapeIn::Chat => body, + ShapeIn::Responses => responses_to_chat(&body), + }; + + let model = chat_body + .get("model") + .and_then(Value::as_str) + .map_or_else(|| DEFAULT_MODEL.to_string(), map_model); + let stream_requested = chat_body + .get("stream") + .and_then(Value::as_bool) + .unwrap_or(false); + + let gemini_request = chat_to_gemini_request(&chat_body); + let envelope = code_assist_envelope(&model, &gemini_request); + let serialized = match serde_json::to_vec(&envelope) { + Ok(v) => v, + Err(e) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "api_error", + &format!("failed to serialize Gemini request: {e}"), + ); + } + }; + let bytes_sent = serialized.len() as u64; + + let base = sub_token + .base_url(crate::subscription::SubscriptionProvider::Gemini) + .trim_end_matches('/') + .to_string(); + // Non-streaming upstream call keeps the translation deterministic; we + // synthesize `OpenAI` SSE below when the client asked to stream. + let upstream_url = format!("{base}/v1internal:generateContent"); + + let upstream_resp = match state + .client + .post(upstream_url) + .header("content-type", "application/json") + .header( + "authorization", + format!("Bearer {}", sub_token.access_token), + ) + .body(serialized) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + state.metrics.record_request(surface, 502, None); + return error_response( + StatusCode::BAD_GATEWAY, + "api_error", + &format!("Gemini subscription upstream request failed: {e}"), + ); + } + }; + let status = StatusCode::from_u16(upstream_resp.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + state.metrics.record_request(surface, status.as_u16(), None); + + let upstream_body = match upstream_resp.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + state.metrics.record_request(surface, 502, None); + return error_response( + StatusCode::BAD_GATEWAY, + "api_error", + &format!("Gemini subscription upstream body read failed: {e}"), + ); + } + }; + state + .metrics + .record_bytes(bytes_sent, upstream_body.len() as u64); + + if !status.is_success() { + // Pass upstream errors through verbatim for diagnosability. + let mut response = Response::new(Body::from(upstream_body)); + *response.status_mut() = status; + response.headers_mut().insert( + "content-type", + axum::http::HeaderValue::from_static("application/json"), + ); + return response; + } + + let gemini_json: Value = match serde_json::from_slice(&upstream_body) { + Ok(v) => v, + Err(e) => { + return error_response( + StatusCode::BAD_GATEWAY, + "api_error", + &format!("failed to parse Gemini response: {e}"), + ); + } + }; + let chat = gemini_response_to_chat(&gemini_json, &model); + + if stream_requested { + return sse_from_chat_completion(&chat, &model); + } + let mut response = Response::new(Body::from(chat.to_string())); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + "content-type", + axum::http::HeaderValue::from_static("application/json"), + ); + response +} + +/// Re-emit a non-streamed chat completion as an `OpenAI` SSE stream +/// (`chat.completion.chunk` deltas followed by `[DONE]`). +fn sse_from_chat_completion(chat: &Value, model: &str) -> Response { + let id = chat + .get("id") + .and_then(Value::as_str) + .unwrap_or("chatcmpl-gemini"); + let content = chat + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(Value::as_str) + .unwrap_or(""); + let created = chat + .get("created") + .and_then(Value::as_i64) + .unwrap_or_default(); + + let role_chunk = json!({ + "id": id, "object": "chat.completion.chunk", "created": created, "model": model, + "choices": [{ "index": 0, "delta": { "role": "assistant" }, "finish_reason": null }], + }); + let content_chunk = json!({ + "id": id, "object": "chat.completion.chunk", "created": created, "model": model, + "choices": [{ "index": 0, "delta": { "content": content }, "finish_reason": null }], + }); + let stop_chunk = json!({ + "id": id, "object": "chat.completion.chunk", "created": created, "model": model, + "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }], + }); + let payload = format!( + "data: {role_chunk}\n\ndata: {content_chunk}\n\ndata: {stop_chunk}\n\ndata: [DONE]\n\n" + ); + let mut response = Response::new(Body::from(payload)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + "content-type", + axum::http::HeaderValue::from_static("text/event-stream"), + ); + response +} + +/// Project an `OpenAI` Responses request onto the Chat Completions shape. +fn responses_to_chat(body: &Value) -> Value { + let mut messages: Vec = Vec::new(); + if let Some(instructions) = body.get("instructions").and_then(Value::as_str) { + messages.push(json!({ "role": "system", "content": instructions })); + } + match body.get("input") { + Some(Value::String(s)) => messages.push(json!({ "role": "user", "content": s })), + Some(Value::Array(items)) => { + for item in items { + if let Some(role) = item.get("role").and_then(Value::as_str) { + let content = item.get("content").cloned().unwrap_or(Value::Null); + messages.push(json!({ "role": role, "content": content })); + } else if let Some(text) = item.as_str() { + messages.push(json!({ "role": "user", "content": text })); + } + } + } + _ => {} + } + let mut out = json!({ "messages": messages }); + for key in [ + "model", + "max_output_tokens", + "temperature", + "top_p", + "stream", + ] { + if let Some(v) = body.get(key) { + let mapped = if key == "max_output_tokens" { + "max_tokens" + } else { + key + }; + out[mapped] = v.clone(); + } + } + out +} + +/// Map a requested model name to a Gemini model id. +fn map_model(requested: &str) -> String { + if requested.starts_with("gemini") { + return requested.to_string(); + } + match requested { + "gpt-4o-mini" | "gpt-4-mini" | "haiku" => "gemini-2.5-flash".to_string(), + _ => DEFAULT_MODEL.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translates_chat_to_gemini_contents_and_system() { + let body = json!({ + "model": "gemini-2.5-pro", + "messages": [ + {"role": "system", "content": "be terse"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "more"} + ], + "temperature": 0.5, + "max_tokens": 256 + }); + let g = chat_to_gemini_request(&body); + let contents = g["contents"].as_array().unwrap(); + assert_eq!(contents.len(), 3); + assert_eq!(contents[0]["role"], "user"); + assert_eq!(contents[1]["role"], "model"); + assert_eq!(g["systemInstruction"]["parts"][0]["text"], "be terse"); + assert_eq!(g["generationConfig"]["maxOutputTokens"], 256); + assert_eq!(g["generationConfig"]["temperature"], 0.5); + } + + #[test] + fn translates_gemini_response_to_chat() { + let resp = json!({ + "candidates": [{ + "content": { "role": "model", "parts": [{"text": "answer"}] }, + "finishReason": "STOP" + }], + "usageMetadata": { "promptTokenCount": 3, "candidatesTokenCount": 5 } + }); + let chat = gemini_response_to_chat(&resp, "gemini-2.5-pro"); + assert_eq!(chat["choices"][0]["message"]["content"], "answer"); + assert_eq!(chat["choices"][0]["finish_reason"], "stop"); + assert_eq!(chat["usage"]["total_tokens"], 8); + } + + #[test] + fn unwraps_code_assist_response_envelope() { + let resp = json!({ + "response": { + "candidates": [{ "content": { "parts": [{"text": "x"}] }, "finishReason": "MAX_TOKENS" }] + } + }); + let chat = gemini_response_to_chat(&resp, "gemini-2.5-pro"); + assert_eq!(chat["choices"][0]["message"]["content"], "x"); + assert_eq!(chat["choices"][0]["finish_reason"], "length"); + } + + #[test] + fn envelope_includes_model() { + let env = code_assist_envelope("gemini-2.5-pro", &json!({"contents": []})); + assert_eq!(env["model"], "gemini-2.5-pro"); + assert!(env.get("request").is_some()); + } + + #[test] + fn responses_input_projects_to_messages() { + let body = json!({ + "model": "gemini-2.5-pro", + "instructions": "sys", + "input": [{"role": "user", "content": "hi"}], + "max_output_tokens": 100 + }); + let chat = responses_to_chat(&body); + let messages = chat["messages"].as_array().unwrap(); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(chat["max_tokens"], 100); + } + + #[test] + fn map_model_passes_gemini_through() { + assert_eq!(map_model("gemini-2.5-flash"), "gemini-2.5-flash"); + assert_eq!(map_model("gpt-4o"), DEFAULT_MODEL); + } +} diff --git a/src/gonka.rs b/src/gonka.rs index 98b3914..b0c2f67 100644 --- a/src/gonka.rs +++ b/src/gonka.rs @@ -6,7 +6,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; /// Error shown when Gonka is selected without a private key. diff --git a/src/lib.rs b/src/lib.rs index c8d1365..4574077 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod activitypub; pub mod cli; pub mod config; pub mod crater; +pub mod gemini; pub mod gonka; pub mod metrics; pub mod mpp; @@ -17,7 +18,11 @@ pub mod openai; pub mod provider_proxy; pub mod providers; pub mod proxy; +pub mod refresh; +pub mod responses; pub mod storage; +pub mod subscription; +pub mod subscription_proxy; pub mod token; pub mod token_admin; diff --git a/src/main.rs b/src/main.rs index bc5f51d..c701556 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,8 @@ use std::process::ExitCode; use std::sync::Arc; use std::time::Duration; -use axum::routing::{get, post}; use axum::Router; +use axum::routing::{get, post}; use link_assistant_router::accounts::{AccountRouter, SelectionStrategy}; use link_assistant_router::activitypub; use link_assistant_router::cli::{AccountOp, Cli, Command, ProviderOp, TokenOp}; @@ -26,10 +26,10 @@ use link_assistant_router::oauth::OAuthProvider; use link_assistant_router::provider_proxy; use link_assistant_router::providers::{ProviderStore, ProviderUpsert}; use link_assistant_router::proxy::{self, AppState}; -use link_assistant_router::storage::{build_token_store, TokenStore}; +use link_assistant_router::storage::{TokenStore, build_token_store}; use link_assistant_router::token::TokenManager; use link_assistant_router::token_admin; -use log_lazy::{levels, LogLazy}; +use log_lazy::{LogLazy, levels}; use tower_http::trace::TraceLayer; use tracing_subscriber::EnvFilter; @@ -155,11 +155,32 @@ async fn run_server(config: Config, logger: LogLazy) -> Result<(), Box + { + let user_home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + let reader = link_assistant_router::subscription::SubscriptionReader::from_user_home( + provider, &user_home, + ); + tracing::info!( + "Subscription provider {provider}: reading credentials from {}", + reader.home().display() + ); + Some(reader) + } + _ => None, + }; + let state = AppState { client, token_manager, oauth_provider, account_router, + subscription_reader, + subscription_cache: Arc::new(link_assistant_router::refresh::TokenCache::new()), upstream_base_url: config.upstream_base_url.clone(), upstream_provider: config.upstream_provider, gonka: link_assistant_router::gonka::GonkaConfig::new( @@ -541,6 +562,35 @@ fn run_doctor(config: &Config) -> ExitCode { } } + // Probe vendor-subscription credentials (Codex/Gemini/Qwen). These are the + // OAuth files written by each vendor's CLI; the router reads them read-only + // when the matching upstream provider is active. + let user_home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + for provider in link_assistant_router::subscription::SubscriptionProvider::ALL { + use link_assistant_router::subscription::SubscriptionProvider; + if provider == SubscriptionProvider::Claude { + continue; // covered by the primary Claude probe above + } + let reader = link_assistant_router::subscription::SubscriptionReader::from_user_home( + provider, &user_home, + ); + let label = format!("{provider} subscription"); + match reader.discover_credential_path() { + Some(path) => { + let status = reader.read_token().map_or("found, NO TOKEN", |token| { + let now_ms = chrono::Utc::now().timestamp_millis(); + if token.is_expired(now_ms) { + "found, token EXPIRED" + } else { + "found, token OK" + } + }); + println!("{label:<23}: {} ({status})", path.display()); + } + None => println!("{label:<23}: {} (MISSING)", reader.home().display()), + } + } + // Probe data dir. if config.data_dir.exists() { println!("data_dir : present"); diff --git a/src/metrics.rs b/src/metrics.rs index 58c2d48..87f7919 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -19,8 +19,8 @@ use std::collections::HashMap; use std::fmt::Write as _; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; use serde::Serialize; diff --git a/src/openai.rs b/src/openai.rs index e5e4d85..ab42b14 100644 --- a/src/openai.rs +++ b/src/openai.rs @@ -22,7 +22,7 @@ //! matching `OpenAI` Chat Completions or Responses SSE event shape. use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; /// One chat message in the `OpenAI` request. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -59,25 +59,6 @@ pub struct OpenAIChatCompletionRequest { pub tool_choice: Option, } -/// `OpenAI` `POST /v1/responses` request body. We accept the superset and -/// project to Anthropic Messages, so unknown keys are ignored. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenAIResponseRequest { - pub model: String, - /// Either a single string or a structured input list. - pub input: Value, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub instructions: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_output_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub temperature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stream: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, -} - /// Translate an `OpenAI` Chat Completions request to an Anthropic Messages /// request body (returned as a JSON value). #[must_use] @@ -154,48 +135,6 @@ pub fn chat_completion_to_anthropic(req: &OpenAIChatCompletionRequest) -> Value body } -/// Translate an `OpenAI` Responses-API request to Anthropic Messages. -#[must_use] -pub fn response_to_anthropic(req: &OpenAIResponseRequest) -> Value { - let mut messages: Vec = Vec::new(); - match &req.input { - Value::String(s) => { - messages.push(json!({"role": "user", "content": s})); - } - Value::Array(items) => { - for item in items { - if let Some(role) = item.get("role").and_then(Value::as_str) { - let content = item.get("content").cloned().unwrap_or(Value::Null); - messages.push(json!({"role": role, "content": content})); - } else if let Some(text) = item.as_str() { - messages.push(json!({"role": "user", "content": text})); - } - } - } - _ => {} - } - - let max_tokens = req.max_output_tokens.unwrap_or(4096); - let mut body = json!({ - "model": map_model(&req.model), - "max_tokens": max_tokens, - "messages": messages, - }); - if let Some(instructions) = &req.instructions { - body["system"] = Value::String(instructions.clone()); - } - if let Some(t) = req.temperature { - body["temperature"] = json!(t); - } - if req.stream == Some(true) { - body["stream"] = json!(true); - } - if let Some(tools) = &req.tools { - body["tools"] = translate_tools(tools); - } - body -} - /// Translate the upstream Anthropic JSON response to an `OpenAI` Chat /// Completions response. #[must_use] @@ -279,42 +218,6 @@ pub fn anthropic_to_chat_completion(anthropic: &Value, requested_model: &str) -> }) } -/// Translate an Anthropic JSON response to an `OpenAI` Responses-API response. -#[must_use] -pub fn anthropic_to_response(anthropic: &Value, requested_model: &str) -> Value { - let id = anthropic - .get("id") - .and_then(Value::as_str) - .map_or_else(|| format!("resp-{}", uuid::Uuid::new_v4()), String::from); - let mut text = String::new(); - if let Some(blocks) = anthropic.get("content").and_then(Value::as_array) { - for block in blocks { - if block.get("type").and_then(Value::as_str) == Some("text") { - if let Some(t) = block.get("text").and_then(Value::as_str) { - text.push_str(t); - } - } - } - } - json!({ - "id": id, - "object": "response", - "created_at": chrono::Utc::now().timestamp(), - "model": requested_model, - "status": "completed", - "output": [ - { - "type": "message", - "role": "assistant", - "content": [ - { "type": "output_text", "text": text } - ] - } - ], - "usage": anthropic.get("usage").cloned().unwrap_or(Value::Null), - }) -} - /// OpenAI-compatible stream response shape to emit while translating /// Anthropic SSE events. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -630,7 +533,7 @@ pub fn list_models() -> Value { json!({"object": "list", "data": data}) } -fn extract_text(content: &Value) -> Option { +pub(crate) fn extract_text(content: &Value) -> Option { match content { Value::String(s) => Some(s.clone()), Value::Array(parts) => { @@ -642,11 +545,7 @@ fn extract_text(content: &Value) -> Option { buf.push_str(s); } } - if buf.is_empty() { - None - } else { - Some(buf) - } + if buf.is_empty() { None } else { Some(buf) } } _ => None, } @@ -679,7 +578,7 @@ fn translate_parts(parts: &[Value]) -> Vec { .collect() } -fn translate_tools(tools: &Value) -> Value { +pub(crate) fn translate_tools(tools: &Value) -> Value { match tools { Value::Array(arr) => { let mapped: Vec = arr @@ -888,36 +787,15 @@ mod tests { .unwrap(); assert_eq!(calls[0]["id"], "t1"); assert_eq!(calls[0]["function"]["name"], "lookup"); - assert!(calls[0]["function"]["arguments"] - .as_str() - .unwrap() - .contains("rust")); + assert!( + calls[0]["function"]["arguments"] + .as_str() + .unwrap() + .contains("rust") + ); assert_eq!(out["choices"][0]["finish_reason"], "tool_calls"); } - #[test] - fn responses_api_translation() { - let req = OpenAIResponseRequest { - model: "gpt-4o".into(), - input: Value::String("write a haiku".into()), - instructions: Some("be poetic".into()), - max_output_tokens: Some(128), - temperature: Some(0.9), - stream: None, - tools: None, - }; - let body = response_to_anthropic(&req); - assert_eq!(body["model"], "claude-sonnet-4-5-20250929"); - assert_eq!(body["system"], "be poetic"); - assert_eq!(body["max_tokens"], 128); - assert_eq!(body["messages"][0]["content"], "write a haiku"); - - let resp = json!({"id": "msg_1", "content": [{"type":"text","text":"line1"}]}); - let out = anthropic_to_response(&resp, "gpt-4o"); - assert_eq!(out["object"], "response"); - assert_eq!(out["output"][0]["content"][0]["text"], "line1"); - } - #[test] fn translates_anthropic_text_stream_to_openai_chat_chunks() { let mut translator = diff --git a/src/provider_proxy.rs b/src/provider_proxy.rs index 8cc28dc..a720705 100644 --- a/src/provider_proxy.rs +++ b/src/provider_proxy.rs @@ -11,7 +11,7 @@ use futures_util::StreamExt; use crate::metrics::Surface; use crate::providers::{ProviderError, ProviderUpsert, ResolvedProvider}; use crate::proxy::{ - error_response, extract_client_token, is_admin_authorised, maybe_mpp_challenge, AppState, + AppState, error_response, extract_client_token, is_admin_authorised, maybe_mpp_challenge, }; /// List configured upstream providers with secrets redacted. @@ -220,7 +220,7 @@ pub async fn forward_openai_compatible( if stream_requested || is_event_stream(&content_type) { let stream = upstream_resp .bytes_stream() - .map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + .map(|chunk| chunk.map_err(std::io::Error::other)); let mut response = Response::new(Body::from_stream(stream)); *response.status_mut() = status; response.headers_mut().insert("content-type", content_type); diff --git a/src/providers.rs b/src/providers.rs index 3ca36d1..d7409f6 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -7,8 +7,8 @@ use aes_gcm::aead::{Aead, KeyInit, OsRng}; use aes_gcm::{Aes256Gcm, Nonce}; -use base64::engine::general_purpose::STANDARD; use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; diff --git a/src/proxy.rs b/src/proxy.rs index 437759f..7dc221b 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -29,6 +29,7 @@ use crate::gonka::GonkaConfig; use crate::oauth::OAuthProvider; use crate::openai; use crate::providers::{OpenAICompatibleConfig, ProviderStore}; +use crate::responses; use crate::token::TokenManager; /// Shared application state accessible by all route handlers. @@ -43,6 +44,11 @@ pub struct AppState { /// Multi-account router (when configured). When `None`, the legacy /// `oauth_provider` is used directly. pub account_router: Option, + /// Subscription credential reader for vendor OAuth providers + /// (Codex/Gemini/Qwen). `None` for non-subscription upstreams. + pub subscription_reader: Option, + /// In-memory cache of refreshed subscription tokens (Codex/Gemini/Qwen). + pub subscription_cache: std::sync::Arc, /// Base URL for the upstream Anthropic API. pub upstream_base_url: String, /// Selected upstream inference provider. @@ -306,7 +312,7 @@ pub async fn proxy_handler(State(state): State, req: Request) -> impl // Stream the response body let stream = upstream_resp .bytes_stream() - .map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + .map(|chunk| chunk.map_err(std::io::Error::other)); let body = Body::from_stream(stream); @@ -423,6 +429,10 @@ pub async fn openai_models(State(state): State) -> impl IntoResponse { |gonka| crate::gonka::list_models(&gonka.model), ), UpstreamProvider::Crater => crate::crater::list_models(), + UpstreamProvider::Codex | UpstreamProvider::Qwen => { + crate::subscription_proxy::subscription_models(&state) + } + UpstreamProvider::Gemini => crate::gemini::list_models(), UpstreamProvider::OpenAICompatible => { crate::provider_proxy::openai_compatible_models(&state) } @@ -472,6 +482,32 @@ pub async fn openai_chat_completions( ) .await; } + if state.upstream_provider == UpstreamProvider::Qwen { + return crate::subscription_proxy::forward_subscription_openai( + &state, + &headers, + body, + "/v1/chat/completions", + crate::metrics::Surface::OpenAIChat, + ) + .await; + } + if state.upstream_provider == UpstreamProvider::Gemini { + return crate::gemini::forward_chat_completions(&state, &headers, body).await; + } + if state.upstream_provider == UpstreamProvider::Codex { + // The ChatGPT backend speaks only the Responses API; translate the + // Chat Completions request before forwarding. + let responses_body = responses::chat_completion_to_responses(&body); + return crate::subscription_proxy::forward_subscription_openai( + &state, + &headers, + responses_body, + "/v1/responses", + crate::metrics::Surface::OpenAIChat, + ) + .await; + } let req = match serde_json::from_value::(body) { Ok(req) => req, Err(e) => { @@ -529,7 +565,23 @@ pub async fn openai_responses( ) .await; } - let req = match serde_json::from_value::(body) { + if matches!( + state.upstream_provider, + UpstreamProvider::Codex | UpstreamProvider::Qwen + ) { + return crate::subscription_proxy::forward_subscription_openai( + &state, + &headers, + body, + "/v1/responses", + crate::metrics::Surface::OpenAIResponses, + ) + .await; + } + if state.upstream_provider == UpstreamProvider::Gemini { + return crate::gemini::forward_responses(&state, &headers, body).await; + } + let req = match serde_json::from_value::(body) { Ok(req) => req, Err(e) => { return error_response( @@ -541,7 +593,7 @@ pub async fn openai_responses( }; let requested_model = req.model.clone(); let stream_requested = req.stream.unwrap_or(false); - let body = openai::response_to_anthropic(&req); + let body = responses::response_to_anthropic(&req); forward_openai( &state, &headers, @@ -790,7 +842,7 @@ async fn forward_openai( Ok(bytes) => Ok::(bytes::Bytes::from( translator.push(&bytes).join(""), )), - Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e)), + Err(e) => Err(std::io::Error::other(e)), }); let mut response = Response::new(Body::from_stream(stream)); *response.status_mut() = StatusCode::OK; @@ -857,7 +909,7 @@ async fn forward_openai( let translated = match shape { OpenAIShape::Chat => openai::anthropic_to_chat_completion(&anthropic, requested_model), - OpenAIShape::Response => openai::anthropic_to_response(&anthropic, requested_model), + OpenAIShape::Response => responses::anthropic_to_response(&anthropic, requested_model), }; state diff --git a/src/proxy_tests.rs b/src/proxy_tests.rs index b29a516..e1363f0 100644 --- a/src/proxy_tests.rs +++ b/src/proxy_tests.rs @@ -1,8 +1,8 @@ use axum::http::{HeaderMap, HeaderValue}; -use log_lazy::{levels, LogLazy}; +use log_lazy::{LogLazy, levels}; use crate::proxy::{ - build_upstream_headers, extract_client_token, merge_oauth_beta, OAUTH_BETA_FLAG, + OAUTH_BETA_FLAG, build_upstream_headers, extract_client_token, merge_oauth_beta, }; #[test] diff --git a/src/refresh.rs b/src/refresh.rs new file mode 100644 index 0000000..b761d37 --- /dev/null +++ b/src/refresh.rs @@ -0,0 +1,411 @@ +//! In-memory OAuth refresh for vendor subscription tokens. +//! +//! Vendor credential files are treated as read-only: the router never writes +//! back to `~/.codex`, `~/.gemini`, or `~/.qwen`. When a token read from disk +//! has expired, this module exchanges its `refresh_token` for a fresh access +//! token using the vendor's public OAuth client (the same client ids embedded +//! in each vendor's open-source CLI) and caches the result **in memory only**. +//! +//! This is the same behavior `ProxyPal` relies on so the proxy keeps working even +//! when the vendor CLI is not running to refresh its own credential file. Claude +//! is intentionally excluded — it is served by [`crate::oauth`], which already +//! re-reads the credential file the Claude CLI keeps current. +//! +//! Secrets (access/refresh tokens) are never logged. + +use std::collections::HashMap; +use std::sync::Mutex; + +use serde::Deserialize; + +use crate::subscription::{SubscriptionProvider, SubscriptionToken}; + +/// How a provider's token endpoint expects the refresh request body encoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BodyStyle { + /// `application/json` body (Codex / `ChatGPT`). + Json, + /// `application/x-www-form-urlencoded` body (Google, Qwen). + Form, +} + +/// Public OAuth refresh parameters for one provider. +#[derive(Debug, Clone, Copy)] +struct RefreshConfig { + token_url: &'static str, + client_id: &'static str, + /// Environment variable holding the OAuth client secret, when the provider + /// requires one. The secret is never hardcoded: Google's installed-app + /// flow needs a `client_secret`, so set `GEMINI_OAUTH_CLIENT_SECRET` to the + /// value the gemini-cli ships if you want the router to refresh Gemini + /// tokens standalone. When unset, the router relies on the vendor CLI to + /// keep the credential file current. + client_secret_env: Option<&'static str>, + style: BodyStyle, +} + +/// Environment variable for the Gemini (Google) OAuth client secret. +pub const GEMINI_CLIENT_SECRET_ENV: &str = "GEMINI_OAUTH_CLIENT_SECRET"; + +/// Refresh parameters for a provider, or `None` when the router does not drive +/// the OAuth refresh itself (Claude). +const fn refresh_config(provider: SubscriptionProvider) -> Option { + match provider { + // The Codex CLI's public OAuth client (no client secret). + SubscriptionProvider::Codex => Some(RefreshConfig { + token_url: "https://auth.openai.com/oauth/token", + client_id: "app_EMoamEEZ73f0CkXaXp7hrann", + client_secret_env: None, + style: BodyStyle::Json, + }), + // The gemini-cli public OAuth client. Google requires a client secret; + // it is read from the environment rather than embedded in the binary. + SubscriptionProvider::Gemini => Some(RefreshConfig { + token_url: "https://oauth2.googleapis.com/token", + client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + client_secret_env: Some(GEMINI_CLIENT_SECRET_ENV), + style: BodyStyle::Form, + }), + // The qwen-code CLI's public OAuth client (no client secret). + SubscriptionProvider::Qwen => Some(RefreshConfig { + token_url: "https://chat.qwen.ai/api/v1/oauth2/token", + client_id: "f0304373b74a44d2b584a3fb70ca9e56", + client_secret_env: None, + style: BodyStyle::Form, + }), + SubscriptionProvider::Claude => None, + } +} + +/// Encode key/value pairs as an `application/x-www-form-urlencoded` body. +/// +/// Percent-encodes every byte that is not an unreserved character so OAuth +/// tokens containing `+`, `/`, `=`, or other reserved bytes survive transit. +fn encode_form(pairs: &[(&str, &str)]) -> String { + fn encode(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push('%'); + out.push( + char::from_digit(u32::from(byte >> 4), 16) + .unwrap() + .to_ascii_uppercase(), + ); + out.push( + char::from_digit(u32::from(byte & 0x0f), 16) + .unwrap() + .to_ascii_uppercase(), + ); + } + } + out + } + pairs + .iter() + .map(|(k, v)| format!("{}={}", encode(k), encode(v))) + .collect::>() + .join("&") +} + +/// The subset of an OAuth token-endpoint response the router consumes. +#[derive(Debug, Deserialize, Default)] +struct RefreshResponse { + access_token: Option, + refresh_token: Option, + expires_in: Option, +} + +/// Errors that can occur while refreshing a subscription token. +#[derive(Debug)] +pub enum RefreshError { + /// The provider does not support router-driven refresh (Claude). + Unsupported, + /// The token had no `refresh_token` to exchange. + NoRefreshToken, + /// The HTTP request to the token endpoint failed. + Request(String), + /// The token endpoint returned a non-success status. + Status(u16, String), + /// The response body could not be parsed or lacked an access token. + Parse(String), +} + +impl std::fmt::Display for RefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported => write!(f, "provider does not support router-driven refresh"), + Self::NoRefreshToken => write!(f, "no refresh token available"), + Self::Request(m) => write!(f, "refresh request failed: {m}"), + Self::Status(code, m) => write!(f, "refresh endpoint returned {code}: {m}"), + Self::Parse(m) => write!(f, "refresh response parse error: {m}"), + } + } +} + +impl std::error::Error for RefreshError {} + +/// Merge a refresh-endpoint response into a fresh [`SubscriptionToken`], +/// carrying over routing metadata (`account_id`, `resource_url`) and reusing +/// the previous refresh token when the endpoint did not rotate it. +fn merge_refresh_response( + prev: &SubscriptionToken, + resp: &RefreshResponse, + now_ms: i64, +) -> Option { + let access_token = resp.access_token.clone().filter(|s| !s.is_empty())?; + Some(SubscriptionToken { + access_token, + refresh_token: resp + .refresh_token + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| prev.refresh_token.clone()), + expires_at_ms: resp.expires_in.map(|secs| now_ms + secs * 1000), + account_id: prev.account_id.clone(), + resource_url: prev.resource_url.clone(), + }) +} + +/// Exchange a token's `refresh_token` for a fresh access token via the +/// provider's public OAuth client. Returns the refreshed token on success. +/// +/// # Errors +/// +/// Returns [`RefreshError`] when the provider is unsupported, no refresh token +/// is present, the HTTP request fails, the endpoint reports an error status, or +/// the response cannot be parsed. +pub async fn refresh( + client: &reqwest::Client, + provider: SubscriptionProvider, + prev: &SubscriptionToken, + now_ms: i64, +) -> Result { + let config = refresh_config(provider).ok_or(RefreshError::Unsupported)?; + let refresh_token = prev + .refresh_token + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or(RefreshError::NoRefreshToken)?; + + // Resolve the optional client secret from the environment (never embedded). + let client_secret = config + .client_secret_env + .and_then(|key| std::env::var(key).ok()) + .filter(|s| !s.is_empty()); + + let request = match config.style { + BodyStyle::Json => { + let mut body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": config.client_id, + }); + if let Some(secret) = client_secret.as_deref() { + body["client_secret"] = serde_json::Value::String(secret.to_string()); + } + client.post(config.token_url).json(&body) + } + BodyStyle::Form => { + let mut form = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", config.client_id), + ]; + if let Some(secret) = client_secret.as_deref() { + form.push(("client_secret", secret)); + } + client + .post(config.token_url) + .header("content-type", "application/x-www-form-urlencoded") + .body(encode_form(&form)) + } + }; + + let response = request + .send() + .await + .map_err(|e| RefreshError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(RefreshError::Status(status.as_u16(), body)); + } + let parsed: RefreshResponse = response + .json() + .await + .map_err(|e| RefreshError::Parse(e.to_string()))?; + merge_refresh_response(prev, &parsed, now_ms) + .ok_or_else(|| RefreshError::Parse("response contained no access_token".to_string())) +} + +/// Process-wide cache of refreshed subscription tokens, keyed by provider. +/// +/// Holds only in-memory copies obtained via OAuth refresh; vendor credential +/// files on disk are never modified. +#[derive(Debug, Default)] +pub struct TokenCache { + inner: Mutex>, +} + +impl TokenCache { + /// Create an empty cache. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Return a non-expired token for `provider`, refreshing if needed. + /// + /// Resolution order: + /// 1. If the on-disk token is still valid, use it (the vendor CLI may have + /// refreshed it more recently than our cache). + /// 2. Otherwise reuse a cached refreshed token while it remains valid. + /// 3. Otherwise exchange the refresh token and cache the result. + /// + /// On refresh failure the (expired) disk token is returned unchanged so the + /// caller can still attempt the upstream call and surface its error. + pub async fn get_fresh( + &self, + client: &reqwest::Client, + provider: SubscriptionProvider, + disk_token: SubscriptionToken, + now_ms: i64, + ) -> SubscriptionToken { + if !disk_token.is_expired(now_ms) { + return disk_token; + } + if let Some(cached) = self.cached_valid(provider, now_ms) { + return cached; + } + match refresh(client, provider, &disk_token, now_ms).await { + Ok(fresh) => { + self.store(provider, fresh.clone()); + fresh + } + Err(e) => { + tracing::warn!("subscription token refresh for {provider} failed: {e}"); + disk_token + } + } + } + + fn cached_valid( + &self, + provider: SubscriptionProvider, + now_ms: i64, + ) -> Option { + let guard = self.inner.lock().ok()?; + guard + .get(&provider) + .filter(|token| !token.is_expired(now_ms)) + .cloned() + } + + fn store(&self, provider: SubscriptionProvider, token: SubscriptionToken) { + if let Ok(mut guard) = self.inner.lock() { + guard.insert(provider, token); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn token(refresh: Option<&str>, exp: Option) -> SubscriptionToken { + SubscriptionToken { + access_token: "old-access".into(), + refresh_token: refresh.map(ToString::to_string), + expires_at_ms: exp, + account_id: Some("acct_1".into()), + resource_url: Some("portal.qwen.ai".into()), + } + } + + #[test] + fn config_present_for_subscription_providers() { + assert!(refresh_config(SubscriptionProvider::Codex).is_some()); + assert!(refresh_config(SubscriptionProvider::Gemini).is_some()); + assert!(refresh_config(SubscriptionProvider::Qwen).is_some()); + assert!(refresh_config(SubscriptionProvider::Claude).is_none()); + } + + #[test] + fn encode_form_percent_encodes_reserved_bytes() { + let body = encode_form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", "a/b+c=d"), + ]); + assert_eq!(body, "grant_type=refresh_token&refresh_token=a%2Fb%2Bc%3Dd"); + } + + #[test] + fn merge_carries_metadata_and_computes_expiry() { + let prev = token(Some("r1"), Some(0)); + let resp = RefreshResponse { + access_token: Some("new-access".into()), + refresh_token: None, + expires_in: Some(3600), + }; + let merged = merge_refresh_response(&prev, &resp, 1_000).unwrap(); + assert_eq!(merged.access_token, "new-access"); + // refresh_token not rotated -> reuse previous. + assert_eq!(merged.refresh_token.as_deref(), Some("r1")); + assert_eq!(merged.expires_at_ms, Some(1_000 + 3_600_000)); + assert_eq!(merged.account_id.as_deref(), Some("acct_1")); + assert_eq!(merged.resource_url.as_deref(), Some("portal.qwen.ai")); + } + + #[test] + fn merge_rotates_refresh_token_when_present() { + let prev = token(Some("r1"), Some(0)); + let resp = RefreshResponse { + access_token: Some("new-access".into()), + refresh_token: Some("r2".into()), + expires_in: None, + }; + let merged = merge_refresh_response(&prev, &resp, 1_000).unwrap(); + assert_eq!(merged.refresh_token.as_deref(), Some("r2")); + assert_eq!(merged.expires_at_ms, None); + } + + #[test] + fn merge_requires_access_token() { + let prev = token(Some("r1"), Some(0)); + let resp = RefreshResponse::default(); + assert!(merge_refresh_response(&prev, &resp, 1_000).is_none()); + } + + #[tokio::test] + async fn get_fresh_returns_valid_disk_token_unchanged() { + let cache = TokenCache::new(); + let client = reqwest::Client::new(); + let valid = token(Some("r1"), Some(10_000)); + let out = cache + .get_fresh(&client, SubscriptionProvider::Qwen, valid.clone(), 1_000) + .await; + assert_eq!(out.access_token, valid.access_token); + } + + #[tokio::test] + async fn get_fresh_prefers_cached_valid_token() { + let cache = TokenCache::new(); + let client = reqwest::Client::new(); + let cached = SubscriptionToken { + access_token: "cached-access".into(), + refresh_token: Some("r1".into()), + expires_at_ms: Some(10_000), + account_id: None, + resource_url: None, + }; + cache.store(SubscriptionProvider::Qwen, cached); + let expired_disk = token(Some("r1"), Some(0)); + let out = cache + .get_fresh(&client, SubscriptionProvider::Qwen, expired_disk, 1_000) + .await; + assert_eq!(out.access_token, "cached-access"); + } +} diff --git a/src/responses.rs b/src/responses.rs new file mode 100644 index 0000000..6d2a3ef --- /dev/null +++ b/src/responses.rs @@ -0,0 +1,229 @@ +//! `OpenAI` Responses-API (`POST /v1/responses`) request/response translation. +//! +//! The newer agentic Responses API is a superset of Chat Completions. The +//! `ChatGPT` backend used by Codex subscriptions speaks *only* this dialect, so +//! the router both accepts Responses requests (projecting them to Anthropic +//! Messages) and projects Chat Completions requests onto the Responses shape +//! when forwarding to Codex. Shared field-shaping helpers live in +//! [`crate::openai`]. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::openai::{extract_text, map_model, translate_tools}; + +/// `OpenAI` `POST /v1/responses` request body. We accept the superset and +/// project to Anthropic Messages, so unknown keys are ignored. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIResponseRequest { + pub model: String, + /// Either a single string or a structured input list. + pub input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, +} + +/// Translate an `OpenAI` Responses-API request to Anthropic Messages. +#[must_use] +pub fn response_to_anthropic(req: &OpenAIResponseRequest) -> Value { + let mut messages: Vec = Vec::new(); + match &req.input { + Value::String(s) => { + messages.push(json!({"role": "user", "content": s})); + } + Value::Array(items) => { + for item in items { + if let Some(role) = item.get("role").and_then(Value::as_str) { + let content = item.get("content").cloned().unwrap_or(Value::Null); + messages.push(json!({"role": role, "content": content})); + } else if let Some(text) = item.as_str() { + messages.push(json!({"role": "user", "content": text})); + } + } + } + _ => {} + } + + let max_tokens = req.max_output_tokens.unwrap_or(4096); + let mut body = json!({ + "model": map_model(&req.model), + "max_tokens": max_tokens, + "messages": messages, + }); + if let Some(instructions) = &req.instructions { + body["system"] = Value::String(instructions.clone()); + } + if let Some(t) = req.temperature { + body["temperature"] = json!(t); + } + if req.stream == Some(true) { + body["stream"] = json!(true); + } + if let Some(tools) = &req.tools { + body["tools"] = translate_tools(tools); + } + body +} + +/// Translate an `OpenAI` Chat Completions request body to an `OpenAI` +/// Responses-API request body. +/// +/// The `ChatGPT` backend used by Codex subscriptions speaks only the Responses +/// API, so Chat Completions requests are projected onto it: `system`/`developer` +/// turns become `instructions`, remaining turns become typed `input` items, and +/// the token/sampling knobs are renamed to their Responses equivalents. The +/// caller's `model` is preserved verbatim (Codex expects e.g. `gpt-5-codex`). +#[must_use] +pub fn chat_completion_to_responses(body: &Value) -> Value { + let model = body + .get("model") + .and_then(Value::as_str) + .unwrap_or("gpt-5-codex"); + + let mut instructions: Vec = Vec::new(); + let mut input: Vec = Vec::new(); + if let Some(messages) = body.get("messages").and_then(Value::as_array) { + for msg in messages { + let role = msg.get("role").and_then(Value::as_str).unwrap_or("user"); + let content = msg.get("content").cloned().unwrap_or(Value::Null); + match role { + "system" | "developer" => { + if let Some(text) = extract_text(&content) { + instructions.push(text); + } + } + _ => { + let text = extract_text(&content).unwrap_or_default(); + // Responses input uses `input_text` for user-side content + // and `output_text` for prior assistant turns. + let part_type = if role == "assistant" { + "output_text" + } else { + "input_text" + }; + input.push(json!({ + "role": role, + "content": [{ "type": part_type, "text": text }], + })); + } + } + } + } + + let mut out = json!({ + "model": model, + "input": input, + }); + if !instructions.is_empty() { + out["instructions"] = Value::String(instructions.join("\n\n")); + } + if let Some(max) = body + .get("max_completion_tokens") + .or_else(|| body.get("max_tokens")) + .and_then(Value::as_u64) + { + out["max_output_tokens"] = json!(max); + } + if let Some(t) = body.get("temperature").and_then(Value::as_f64) { + out["temperature"] = json!(t); + } + if let Some(t) = body.get("top_p").and_then(Value::as_f64) { + out["top_p"] = json!(t); + } + if let Some(tools) = body.get("tools") { + out["tools"] = tools.clone(); + } + out +} + +/// Translate an Anthropic JSON response to an `OpenAI` Responses-API response. +#[must_use] +pub fn anthropic_to_response(anthropic: &Value, requested_model: &str) -> Value { + let id = anthropic + .get("id") + .and_then(Value::as_str) + .map_or_else(|| format!("resp-{}", uuid::Uuid::new_v4()), String::from); + let mut text = String::new(); + if let Some(blocks) = anthropic.get("content").and_then(Value::as_array) { + for block in blocks { + if block.get("type").and_then(Value::as_str) == Some("text") { + if let Some(t) = block.get("text").and_then(Value::as_str) { + text.push_str(t); + } + } + } + } + json!({ + "id": id, + "object": "response", + "created_at": chrono::Utc::now().timestamp(), + "model": requested_model, + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { "type": "output_text", "text": text } + ] + } + ], + "usage": anthropic.get("usage").cloned().unwrap_or(Value::Null), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn responses_api_translation() { + let req = OpenAIResponseRequest { + model: "gpt-4o".into(), + input: Value::String("write a haiku".into()), + instructions: Some("be poetic".into()), + max_output_tokens: Some(128), + temperature: Some(0.9), + stream: None, + tools: None, + }; + let body = response_to_anthropic(&req); + assert_eq!(body["model"], "claude-sonnet-4-5-20250929"); + assert_eq!(body["system"], "be poetic"); + assert_eq!(body["max_tokens"], 128); + assert_eq!(body["messages"][0]["content"], "write a haiku"); + + let resp = json!({"id": "msg_1", "content": [{"type":"text","text":"line1"}]}); + let out = anthropic_to_response(&resp, "gpt-4o"); + assert_eq!(out["object"], "response"); + assert_eq!(out["output"][0]["content"][0]["text"], "line1"); + } + + #[test] + fn chat_completion_projects_to_responses_input() { + let body = json!({ + "model": "gpt-5-codex", + "messages": [ + {"role": "system", "content": "be terse"}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"} + ], + "max_tokens": 256, + }); + let out = chat_completion_to_responses(&body); + assert_eq!(out["model"], "gpt-5-codex"); + assert_eq!(out["instructions"], "be terse"); + assert_eq!(out["max_output_tokens"], 256); + assert_eq!(out["input"][0]["role"], "user"); + assert_eq!(out["input"][0]["content"][0]["type"], "input_text"); + assert_eq!(out["input"][1]["content"][0]["type"], "output_text"); + } +} diff --git a/src/subscription.rs b/src/subscription.rs new file mode 100644 index 0000000..dd9b10d --- /dev/null +++ b/src/subscription.rs @@ -0,0 +1,599 @@ +//! Subscription OAuth credential readers for vendor coding CLIs. +//! +//! Several coding assistants (Claude Code, `OpenAI` Codex, Gemini CLI, qwen-code) +//! authenticate the user's *subscription* via OAuth and cache the resulting +//! bearer token in a well-known file under the user's home directory. Reading +//! that file is the fastest, most reliable way to let the router forward +//! requests against a real subscription — exactly how Claude works today via +//! [`crate::oauth`]. This module generalizes that idea to all four vendors so +//! each provider has a single, well-tested credential reader. +//! +//! The on-disk layouts are vendor specific (documented per provider below and +//! in `docs/case-studies/issue-37/online-research.md`); this module normalizes +//! them into a single [`SubscriptionToken`] the proxy can route with. + +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// A subscription-backed upstream that authenticates with vendor OAuth tokens. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SubscriptionProvider { + /// Anthropic Claude (Pro/Max) via Claude Code — `~/.claude`. + Claude, + /// `OpenAI` Codex / `ChatGPT` subscription via the Codex CLI — `~/.codex`. + Codex, + /// Google Gemini Code Assist via the Gemini CLI — `~/.gemini`. + Gemini, + /// Alibaba Qwen via the qwen-code CLI — `~/.qwen`. + Qwen, +} + +impl SubscriptionProvider { + /// All known subscription providers, in priority order. + pub const ALL: [Self; 4] = [Self::Claude, Self::Codex, Self::Gemini, Self::Qwen]; + + /// Stable lowercase identifier (used in CLI args, env vars, logs). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Gemini => "gemini", + Self::Qwen => "qwen", + } + } + + /// Parse a provider from a free-form string (aliases included). + #[must_use] + pub fn from_str_opt(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "claude" | "anthropic" | "claude-code" => Some(Self::Claude), + "codex" | "chatgpt" | "openai-codex" => Some(Self::Codex), + "gemini" | "google" | "code-assist" => Some(Self::Gemini), + "qwen" | "qwen-code" | "dashscope" => Some(Self::Qwen), + _ => None, + } + } + + /// The home subdirectory the vendor CLI writes credentials into, relative + /// to the user's home directory (e.g. `.codex`). + #[must_use] + pub const fn home_subdir(self) -> &'static str { + match self { + Self::Claude => ".claude", + Self::Codex => ".codex", + Self::Gemini => ".gemini", + Self::Qwen => ".qwen", + } + } + + /// Environment variable that overrides the credential home directory, if any. + #[must_use] + pub const fn home_env(self) -> &'static str { + match self { + Self::Claude => "CLAUDE_CODE_HOME", + Self::Codex => "CODEX_HOME", + Self::Gemini => "GEMINI_HOME", + Self::Qwen => "QWEN_HOME", + } + } + + /// Candidate credential filenames within the home directory, most specific + /// first. + #[must_use] + pub const fn credential_filenames(self) -> &'static [&'static str] { + match self { + Self::Claude => &[".credentials.json", "credentials.json", "auth.json"], + Self::Codex => &["auth.json"], + Self::Gemini | Self::Qwen => &["oauth_creds.json"], + } + } + + /// Default upstream base URL for the provider's subscription endpoint. + /// + /// Qwen's per-token `resource_url` overrides this at request time. + #[must_use] + pub const fn default_base_url(self) -> &'static str { + match self { + Self::Claude => "https://api.anthropic.com", + Self::Codex => "https://chatgpt.com/backend-api/codex", + Self::Gemini => "https://cloudcode-pa.googleapis.com", + Self::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1", + } + } + + /// Resolve the credential home directory, honoring the provider's override + /// env var, then falling back to `/`. + #[must_use] + pub fn resolve_home(self, home: &str) -> PathBuf { + if let Ok(dir) = std::env::var(self.home_env()) { + if !dir.is_empty() { + return PathBuf::from(dir); + } + } + PathBuf::from(home).join(self.home_subdir()) + } +} + +impl std::fmt::Display for SubscriptionProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A normalized subscription token plus the metadata the proxy needs to route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionToken { + /// OAuth bearer access token sent as `Authorization: Bearer `. + pub access_token: String, + /// OAuth refresh token, when the vendor file stores one. + pub refresh_token: Option, + /// Expiry as Unix epoch milliseconds, when known. + pub expires_at_ms: Option, + /// `ChatGPT` account id (`chatgpt-account-id` header) for Codex billing. + pub account_id: Option, + /// Per-token base URL override (Qwen `resource_url`). + pub resource_url: Option, +} + +impl SubscriptionToken { + /// Whether the token is expired relative to `now_ms` (with no skew). + #[must_use] + pub fn is_expired(&self, now_ms: i64) -> bool { + self.expires_at_ms.is_some_and(|exp| exp <= now_ms) + } + + /// Effective base URL for this token: `resource_url` override or the + /// provider default. Qwen returns a bare host in `resource_url`, so a + /// scheme and the OpenAI-compatible suffix are added when missing. + #[must_use] + pub fn base_url(&self, provider: SubscriptionProvider) -> String { + let Some(resource) = self.resource_url.as_deref().filter(|s| !s.is_empty()) else { + return provider.default_base_url().to_string(); + }; + let with_scheme = if resource.starts_with("http://") || resource.starts_with("https://") { + resource.to_string() + } else { + format!("https://{resource}") + }; + if provider == SubscriptionProvider::Qwen && !with_scheme.contains("/compatible-mode") { + format!("{}/compatible-mode/v1", with_scheme.trim_end_matches('/')) + } else { + with_scheme + } + } +} + +/// Errors raised while reading subscription credentials. +#[derive(Debug)] +pub enum SubscriptionError { + /// No credential file existed in any candidate location. + NoCredentials(String), + /// A credential file existed but could not be read. + ReadError(String), + /// A credential file existed but could not be parsed. + ParseError(String), + /// A credential file parsed but contained no usable access token. + NoToken(String), +} + +impl std::fmt::Display for SubscriptionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoCredentials(m) + | Self::ReadError(m) + | Self::ParseError(m) + | Self::NoToken(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for SubscriptionError {} + +/// Reads and normalizes a single provider's subscription credentials. +#[derive(Debug, Clone)] +pub struct SubscriptionReader { + provider: SubscriptionProvider, + home: PathBuf, +} + +impl SubscriptionReader { + /// Create a reader for `provider` rooted at an explicit home directory. + #[must_use] + pub fn new(provider: SubscriptionProvider, home: impl Into) -> Self { + Self { + provider, + home: home.into(), + } + } + + /// Create a reader using the provider's default/overridden home directory. + #[must_use] + pub fn from_user_home(provider: SubscriptionProvider, user_home: &str) -> Self { + Self::new(provider, provider.resolve_home(user_home)) + } + + /// The provider this reader serves. + #[must_use] + pub const fn provider(&self) -> SubscriptionProvider { + self.provider + } + + /// The credential home directory this reader searches. + #[must_use] + pub fn home(&self) -> &Path { + &self.home + } + + /// Candidate credential file paths, most specific first. + #[must_use] + pub fn credential_paths(&self) -> Vec { + self.provider + .credential_filenames() + .iter() + .map(|name| self.home.join(name)) + .collect() + } + + /// First existing credential file, if any (for diagnostics). + #[must_use] + pub fn discover_credential_path(&self) -> Option { + self.credential_paths().into_iter().find(|p| p.exists()) + } + + /// Read and normalize the subscription token. + pub fn read_token(&self) -> Result { + let mut last_err: Option = None; + for path in self.credential_paths() { + if !path.exists() { + continue; + } + let content = std::fs::read_to_string(&path).map_err(|e| { + SubscriptionError::ReadError(format!("Failed to read {}: {e}", path.display())) + })?; + let raw: RawCredentials = serde_json::from_str(&content).map_err(|e| { + SubscriptionError::ParseError(format!("Failed to parse {}: {e}", path.display())) + })?; + match raw.into_token(self.provider) { + Some(token) => return Ok(token), + None => { + last_err = Some(SubscriptionError::NoToken(format!( + "No {} access token in {}", + self.provider, + path.display() + ))); + } + } + } + Err(last_err.unwrap_or_else(|| { + SubscriptionError::NoCredentials(format!( + "No {} credential file found in {}", + self.provider, + self.home.display() + )) + })) + } +} + +/// Superset of every vendor credential layout. Each provider reads only the +/// fields it uses; serde `alias` covers `camelCase`/`snake_case` variants. +#[derive(Debug, Default, Deserialize)] +struct RawCredentials { + // Flat layout (Gemini / Qwen / hand-written): top-level token fields. + #[serde(alias = "accessToken")] + access_token: Option, + #[serde(alias = "oauthToken", alias = "oauth_token")] + token: Option, + #[serde(alias = "refreshToken")] + refresh_token: Option, + /// Gemini/Qwen store expiry as `expiry_date` (ms); others use `expiresAt`. + #[serde(alias = "expiryDate", alias = "expiresAt", alias = "expires_at")] + expiry_date: Option, + /// Qwen per-token base URL override. + #[serde(alias = "resourceUrl")] + resource_url: Option, + /// `ChatGPT` account id when stored at the top level. + #[serde(alias = "accountId", alias = "chatgpt_account_id")] + account_id: Option, + // Codex nested layout: `{ "tokens": { ... }, "last_refresh": ... }`. + tokens: Option, + // Claude nested layout: `{ "claudeAiOauth": { ... } }`. + #[serde(alias = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct CodexTokens { + #[serde(alias = "accessToken")] + access_token: Option, + #[serde(alias = "refreshToken")] + refresh_token: Option, + #[serde(alias = "accountId")] + account_id: Option, + #[serde(alias = "idToken")] + id_token: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct ClaudeBlock { + #[serde(alias = "accessToken")] + access_token: Option, + #[serde(alias = "oauthToken", alias = "oauth_token")] + token: Option, + #[serde(alias = "refreshToken")] + refresh_token: Option, + #[serde(alias = "expiresAt", alias = "expires_at")] + expires_at: Option, +} + +fn non_empty(s: Option) -> Option { + s.filter(|v| !v.is_empty()) +} + +impl RawCredentials { + /// Resolve the provider-specific access token and routing metadata. + fn into_token(self, provider: SubscriptionProvider) -> Option { + match provider { + SubscriptionProvider::Claude => self.claude_token(), + SubscriptionProvider::Codex => self.codex_token(), + // Gemini and Qwen both use the flat layout; Qwen adds resource_url. + SubscriptionProvider::Gemini | SubscriptionProvider::Qwen => self.flat_token(), + } + } + + fn claude_token(self) -> Option { + // Prefer the nested `claudeAiOauth` block (real Claude Code layout), + // then fall back to flat fields. + if let Some(block) = self.claude_ai_oauth { + if let Some(access) = non_empty(block.access_token).or_else(|| non_empty(block.token)) { + return Some(SubscriptionToken { + access_token: access, + refresh_token: non_empty(block.refresh_token), + expires_at_ms: block.expires_at, + account_id: None, + resource_url: None, + }); + } + } + let access = non_empty(self.access_token).or_else(|| non_empty(self.token))?; + Some(SubscriptionToken { + access_token: access, + refresh_token: non_empty(self.refresh_token), + expires_at_ms: self.expiry_date, + account_id: None, + resource_url: None, + }) + } + + fn codex_token(self) -> Option { + let tokens = self.tokens.unwrap_or_default(); + let access = non_empty(tokens.access_token)?; + let account_id = non_empty(tokens.account_id) + .or_else(|| non_empty(self.account_id)) + .or_else(|| { + tokens + .id_token + .as_deref() + .and_then(account_id_from_id_token) + }); + Some(SubscriptionToken { + access_token: access, + refresh_token: non_empty(tokens.refresh_token), + expires_at_ms: self.expiry_date, + account_id, + resource_url: None, + }) + } + + fn flat_token(self) -> Option { + let access = non_empty(self.access_token).or_else(|| non_empty(self.token))?; + Some(SubscriptionToken { + access_token: access, + refresh_token: non_empty(self.refresh_token), + expires_at_ms: self.expiry_date, + account_id: non_empty(self.account_id), + resource_url: non_empty(self.resource_url), + }) + } +} + +/// Extract the `ChatGPT` account id from a Codex `id_token` JWT. +/// +/// Codex stores the account id directly, but older/edge auth files only carry +/// the `id_token`; its payload nests the id under +/// `https://api.openai.com/auth.chatgpt_account_id` (or `chatgpt_account_id`). +fn account_id_from_id_token(id_token: &str) -> Option { + use base64::Engine as _; + let payload_b64 = id_token.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let auth = claims.get("https://api.openai.com/auth"); + let candidate = auth + .and_then(|a| a.get("chatgpt_account_id")) + .or_else(|| claims.get("chatgpt_account_id")) + .or_else(|| auth.and_then(|a| a.get("account_id"))); + candidate + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tempdir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("router-sub-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn provider_roundtrip_strings() { + for p in SubscriptionProvider::ALL { + assert_eq!(SubscriptionProvider::from_str_opt(p.as_str()), Some(p)); + } + assert_eq!( + SubscriptionProvider::from_str_opt("ChatGPT"), + Some(SubscriptionProvider::Codex) + ); + assert_eq!( + SubscriptionProvider::from_str_opt("dashscope"), + Some(SubscriptionProvider::Qwen) + ); + assert!(SubscriptionProvider::from_str_opt("unknown").is_none()); + } + + #[test] + fn reads_codex_auth_json() { + let dir = tempdir(); + fs::write( + dir.join("auth.json"), + r#"{"tokens":{"id_token":"x","access_token":"codex-access","refresh_token":"codex-refresh","account_id":"acct_123"},"last_refresh":"2026-06-01T00:00:00Z"}"#, + ) + .unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Codex, &dir); + let token = reader.read_token().expect("codex token"); + assert_eq!(token.access_token, "codex-access"); + assert_eq!(token.refresh_token.as_deref(), Some("codex-refresh")); + assert_eq!(token.account_id.as_deref(), Some("acct_123")); + assert_eq!( + token.base_url(SubscriptionProvider::Codex), + "https://chatgpt.com/backend-api/codex" + ); + } + + #[test] + fn reads_codex_account_id_from_id_token() { + use base64::Engine as _; + // header.payload.signature with payload carrying the nested account id. + let payload = serde_json::json!({ + "https://api.openai.com/auth": { "chatgpt_account_id": "acct_from_jwt" } + }); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + let id_token = format!("aGVhZGVy.{payload_b64}.sig"); + let dir = tempdir(); + fs::write( + dir.join("auth.json"), + format!(r#"{{"tokens":{{"id_token":"{id_token}","access_token":"a"}}}}"#), + ) + .unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Codex, &dir); + let token = reader.read_token().expect("codex token"); + assert_eq!(token.account_id.as_deref(), Some("acct_from_jwt")); + } + + #[test] + fn reads_gemini_oauth_creds() { + let dir = tempdir(); + fs::write( + dir.join("oauth_creds.json"), + r#"{"access_token":"gem-access","refresh_token":"gem-refresh","expiry_date":9999999999999,"token_type":"Bearer","scope":"https://www.googleapis.com/auth/cloud-platform"}"#, + ) + .unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Gemini, &dir); + let token = reader.read_token().expect("gemini token"); + assert_eq!(token.access_token, "gem-access"); + assert_eq!(token.refresh_token.as_deref(), Some("gem-refresh")); + assert_eq!(token.expires_at_ms, Some(9_999_999_999_999)); + assert_eq!( + token.base_url(SubscriptionProvider::Gemini), + "https://cloudcode-pa.googleapis.com" + ); + } + + #[test] + fn reads_qwen_oauth_creds_with_resource_url() { + let dir = tempdir(); + fs::write( + dir.join("oauth_creds.json"), + r#"{"access_token":"qwen-access","refresh_token":"qwen-refresh","token_type":"Bearer","resource_url":"portal.qwen.ai","expiry_date":9999999999999}"#, + ) + .unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Qwen, &dir); + let token = reader.read_token().expect("qwen token"); + assert_eq!(token.access_token, "qwen-access"); + assert_eq!(token.resource_url.as_deref(), Some("portal.qwen.ai")); + // resource_url overrides the default DashScope base and gets the + // OpenAI-compatible suffix + scheme added. + assert_eq!( + token.base_url(SubscriptionProvider::Qwen), + "https://portal.qwen.ai/compatible-mode/v1" + ); + } + + #[test] + fn qwen_without_resource_url_uses_default_base() { + let dir = tempdir(); + fs::write( + dir.join("oauth_creds.json"), + r#"{"access_token":"qwen-access"}"#, + ) + .unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Qwen, &dir); + let token = reader.read_token().expect("qwen token"); + assert_eq!( + token.base_url(SubscriptionProvider::Qwen), + "https://dashscope.aliyuncs.com/compatible-mode/v1" + ); + } + + #[test] + fn reads_claude_nested_credentials() { + let dir = tempdir(); + fs::write( + dir.join(".credentials.json"), + r#"{"claudeAiOauth":{"accessToken":"sk-ant-oat-nested","refreshToken":"sk-ant-ort-x","expiresAt":9999999999999}}"#, + ) + .unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Claude, &dir); + let token = reader.read_token().expect("claude token"); + assert_eq!(token.access_token, "sk-ant-oat-nested"); + assert_eq!(token.refresh_token.as_deref(), Some("sk-ant-ort-x")); + assert_eq!(token.expires_at_ms, Some(9_999_999_999_999)); + } + + #[test] + fn missing_credentials_errors() { + let reader = SubscriptionReader::new( + SubscriptionProvider::Gemini, + "/tmp/router-nonexistent-sub-dir", + ); + let err = reader.read_token().unwrap_err(); + assert!(matches!(err, SubscriptionError::NoCredentials(_))); + } + + #[test] + fn expiry_detection() { + let token = SubscriptionToken { + access_token: "a".into(), + refresh_token: None, + expires_at_ms: Some(1000), + account_id: None, + resource_url: None, + }; + assert!(token.is_expired(2000)); + assert!(!token.is_expired(500)); + } + + #[test] + fn discover_credential_path_finds_existing() { + let dir = tempdir(); + fs::write(dir.join("oauth_creds.json"), r#"{"access_token":"x"}"#).unwrap(); + let reader = SubscriptionReader::new(SubscriptionProvider::Qwen, &dir); + assert_eq!( + reader.discover_credential_path(), + Some(dir.join("oauth_creds.json")) + ); + } + + #[test] + fn resolve_home_uses_subdir() { + // Without the override env var set, falls back to /. + let home = SubscriptionProvider::Codex.resolve_home("/home/alice"); + assert!(home.ends_with(".codex")); + } +} diff --git a/src/subscription_proxy.rs b/src/subscription_proxy.rs new file mode 100644 index 0000000..1b29b43 --- /dev/null +++ b/src/subscription_proxy.rs @@ -0,0 +1,358 @@ +//! Forward `OpenAI`-style requests to vendor *subscription* upstreams. +//! +//! Codex (`ChatGPT`) and Qwen authenticate with the user's subscription OAuth +//! token (read by [`crate::subscription`]) and speak `OpenAI`-shaped wire +//! formats — Qwen via `DashScope`'s `OpenAI`-compatible API, Codex via the +//! `ChatGPT` backend Responses API. This module substitutes the client's +//! router token for the subscription bearer token and forwards the request, +//! streaming SSE through untouched, exactly like [`crate::provider_proxy`] does +//! for configured `OpenAI`-compatible providers. +//! +//! Gemini speaks a different dialect and is handled separately in +//! [`crate::gemini`]. + +#![allow(clippy::unused_async)] + +use axum::body::Body; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::Response; +use futures_util::StreamExt; + +use crate::config::UpstreamProvider; +use crate::metrics::Surface; +use crate::proxy::{AppState, error_response, extract_client_token, maybe_mpp_challenge}; +use crate::subscription::{SubscriptionProvider, SubscriptionToken}; + +/// Forward one `OpenAI`-shaped request to the active subscription upstream. +/// +/// `path` is the router's own route (e.g. `/v1/chat/completions` or +/// `/v1/responses`); it is rewritten to the provider's upstream path. +pub async fn forward_subscription_openai( + state: &AppState, + headers: &HeaderMap, + mut body: serde_json::Value, + path: &str, + surface: Surface, +) -> Response { + if let Some(resp) = maybe_mpp_challenge(state, headers, path) { + return resp; + } + + let Some(token) = extract_client_token(headers) else { + return error_response( + StatusCode::UNAUTHORIZED, + "authentication_error", + "Missing Authorization Bearer token or x-api-key", + ); + }; + if let Err(e) = state.token_manager.validate_token(token) { + let status = match &e { + crate::token::TokenError::Revoked => StatusCode::FORBIDDEN, + _ => StatusCode::UNAUTHORIZED, + }; + return error_response(status, "authentication_error", &format!("{e}")); + } + + let Some(provider) = state.upstream_provider.subscription_provider() else { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "api_error", + "active upstream is not a subscription provider", + ); + }; + let Some(reader) = state.subscription_reader.as_ref() else { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "api_error", + "subscription credentials reader is not configured", + ); + }; + let disk_token = match reader.read_token() { + Ok(token) => token, + Err(e) => { + return error_response( + StatusCode::BAD_GATEWAY, + "authentication_error", + &format!("failed to read {provider} subscription credentials: {e}"), + ); + } + }; + // Refresh in memory if the on-disk token has expired; vendor files stay + // read-only. + let now_ms = chrono::Utc::now().timestamp_millis(); + let sub_token = state + .subscription_cache + .get_fresh(&state.client, provider, disk_token, now_ms) + .await; + + let stream_requested = body + .get("stream") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + // Codex always streams from the ChatGPT backend; reflect that into the body + // so the upstream emits SSE we pass straight through. + if provider == SubscriptionProvider::Codex { + body["stream"] = serde_json::Value::Bool(true); + } + + let serialized = match serde_json::to_vec(&body) { + Ok(v) => v, + Err(e) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "api_error", + &format!("failed to serialize subscription request body: {e}"), + ); + } + }; + let bytes_sent = serialized.len() as u64; + + let base_url = sub_token.base_url(provider); + let upstream_url = join_subscription_url(provider, &base_url, path); + + let mut upstream_req = state + .client + .post(upstream_url) + .header("content-type", "application/json") + .header( + "authorization", + format!("Bearer {}", sub_token.access_token), + ) + .body(serialized); + for (name, value) in subscription_headers(provider, &sub_token) { + upstream_req = upstream_req.header(name, value); + } + + let upstream_resp = match upstream_req.send().await { + Ok(resp) => resp, + Err(e) => { + state.metrics.record_request(surface, 502, None); + return error_response( + StatusCode::BAD_GATEWAY, + "api_error", + &format!("{provider} subscription upstream request failed: {e}"), + ); + } + }; + let status = StatusCode::from_u16(upstream_resp.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + state.metrics.record_request(surface, status.as_u16(), None); + + let content_type = upstream_resp + .headers() + .get("content-type") + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("application/json")); + // Preserve rate-limit signals (Retry-After, x-ratelimit-*) so clients can + // back off intelligently when a subscription upstream throttles us. + let rate_limit_headers = rate_limit_headers(upstream_resp.headers()); + + if stream_requested || is_event_stream(&content_type) { + let stream = upstream_resp + .bytes_stream() + .map(|chunk| chunk.map_err(std::io::Error::other)); + let mut response = Response::new(Body::from_stream(stream)); + *response.status_mut() = status; + response.headers_mut().insert("content-type", content_type); + apply_headers(response.headers_mut(), rate_limit_headers); + return response; + } + + let upstream_body = match upstream_resp.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + state.metrics.record_request(surface, 502, None); + return error_response( + StatusCode::BAD_GATEWAY, + "api_error", + &format!("{provider} subscription upstream body read failed: {e}"), + ); + } + }; + state + .metrics + .record_bytes(bytes_sent, upstream_body.len() as u64); + + let mut response = Response::new(Body::from(upstream_body)); + *response.status_mut() = status; + response.headers_mut().insert("content-type", content_type); + apply_headers(response.headers_mut(), rate_limit_headers); + response +} + +/// Collect rate-limit-related headers (`retry-after`, `x-ratelimit-*`) from an +/// upstream response so they can be relayed to the client. +fn rate_limit_headers(headers: &HeaderMap) -> Vec<(axum::http::HeaderName, HeaderValue)> { + headers + .iter() + .filter(|(name, _)| { + let n = name.as_str(); + n == "retry-after" || n.starts_with("x-ratelimit") + }) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() +} + +/// Insert collected headers into an outgoing response header map. +fn apply_headers(out: &mut HeaderMap, headers: Vec<(axum::http::HeaderName, HeaderValue)>) { + for (name, value) in headers { + out.insert(name, value); + } +} + +/// Provider-specific extra headers required by the upstream. +fn subscription_headers( + provider: SubscriptionProvider, + token: &SubscriptionToken, +) -> Vec<(&'static str, String)> { + let mut out = Vec::new(); + if provider == SubscriptionProvider::Codex { + if let Some(account_id) = token.account_id.as_deref() { + out.push(("chatgpt-account-id", account_id.to_string())); + } + // The Codex backend gates the Responses API behind a beta opt-in and + // identifies the originating client. + out.push(("openai-beta", "responses=experimental".to_string())); + out.push(("originator", "codex_cli_rs".to_string())); + } + out +} + +/// Map a router route to the provider's upstream path. +/// +/// Qwen mirrors the `OpenAI`-compatible scheme (base already ends in `/v1`), so +/// the router's `/v1/...` prefix is stripped. Codex exposes a flat +/// `.../codex/responses` endpoint, so `/v1/responses` collapses to +/// `/responses`. +fn join_subscription_url(provider: SubscriptionProvider, base_url: &str, path: &str) -> String { + let base = base_url.trim_end_matches('/'); + match provider { + SubscriptionProvider::Codex => { + let suffix = path.strip_prefix("/v1").unwrap_or(path); + format!("{base}{suffix}") + } + _ => { + if base.ends_with("/v1") { + let suffix = path.strip_prefix("/v1").unwrap_or(path); + format!("{base}{suffix}") + } else { + format!("{base}{path}") + } + } + } +} + +/// `OpenAI`-shaped model listing for a subscription provider. +#[must_use] +pub fn subscription_models(state: &AppState) -> serde_json::Value { + let provider = state.upstream_provider; + let now = chrono::Utc::now().timestamp(); + let (owner, ids): (&str, &[&str]) = match provider { + UpstreamProvider::Codex => ("openai", &["gpt-5-codex", "gpt-5", "codex-mini-latest"]), + UpstreamProvider::Qwen => ( + "qwen", + &[ + "qwen3-coder-plus", + "qwen3-coder-flash", + "qwen-max", + "qwen-plus", + ], + ), + _ => ("subscription", &["default"]), + }; + let data: Vec = ids + .iter() + .map(|id| { + serde_json::json!({ + "id": id, + "object": "model", + "created": now, + "owned_by": owner, + }) + }) + .collect(); + serde_json::json!({"object": "list", "data": data}) +} + +fn is_event_stream(content_type: &HeaderValue) -> bool { + content_type + .to_str() + .is_ok_and(|value| value.to_ascii_lowercase().contains("text/event-stream")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_url_collapses_v1_responses() { + let url = join_subscription_url( + SubscriptionProvider::Codex, + "https://chatgpt.com/backend-api/codex", + "/v1/responses", + ); + assert_eq!(url, "https://chatgpt.com/backend-api/codex/responses"); + } + + #[test] + fn qwen_url_strips_v1_against_compatible_base() { + let url = join_subscription_url( + SubscriptionProvider::Qwen, + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "/v1/chat/completions", + ); + assert_eq!( + url, + "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + ); + } + + #[test] + fn codex_headers_include_account_id() { + let token = SubscriptionToken { + access_token: "a".into(), + refresh_token: None, + expires_at_ms: None, + account_id: Some("acct_9".into()), + resource_url: None, + }; + let headers = subscription_headers(SubscriptionProvider::Codex, &token); + assert!( + headers + .iter() + .any(|(k, v)| *k == "chatgpt-account-id" && v == "acct_9") + ); + } + + #[test] + fn rate_limit_headers_are_selected() { + let mut headers = HeaderMap::new(); + headers.insert("retry-after", HeaderValue::from_static("30")); + headers.insert( + "x-ratelimit-remaining-requests", + HeaderValue::from_static("0"), + ); + headers.insert("content-type", HeaderValue::from_static("application/json")); + let selected = rate_limit_headers(&headers); + assert_eq!(selected.len(), 2); + assert!(selected.iter().any(|(n, _)| n.as_str() == "retry-after")); + assert!( + selected + .iter() + .any(|(n, _)| n.as_str() == "x-ratelimit-remaining-requests") + ); + } + + #[test] + fn qwen_has_no_extra_headers() { + let token = SubscriptionToken { + access_token: "a".into(), + refresh_token: None, + expires_at_ms: None, + account_id: None, + resource_url: None, + }; + assert!(subscription_headers(SubscriptionProvider::Qwen, &token).is_empty()); + } +} diff --git a/src/token.rs b/src/token.rs index 910ea2a..1a9a80d 100644 --- a/src/token.rs +++ b/src/token.rs @@ -9,7 +9,7 @@ //! for backwards compatibility with the legacy server boot path. use chrono::{Duration, Utc}; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/token_admin.rs b/src/token_admin.rs index 8d2790d..a353709 100644 --- a/src/token_admin.rs +++ b/src/token_admin.rs @@ -16,7 +16,7 @@ use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; -use crate::proxy::{error_response, is_admin_authorised, AppState}; +use crate::proxy::{AppState, error_response, is_admin_authorised}; /// Token issuance endpoint. /// diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c5b5349..486752e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2,11 +2,11 @@ //! //! Tests the token system, proxy behavior, API format routing, and header forwarding. +use link_assistant_router::VERSION; use link_assistant_router::config::ApiFormat; use link_assistant_router::oauth::OAuthProvider; -use link_assistant_router::proxy::{resolve_upstream_path, REQUIRED_FORWARD_HEADERS}; -use link_assistant_router::token::{TokenManager, TOKEN_PREFIX}; -use link_assistant_router::VERSION; +use link_assistant_router::proxy::{REQUIRED_FORWARD_HEADERS, resolve_upstream_path}; +use link_assistant_router::token::{TOKEN_PREFIX, TokenManager}; mod token_integration_tests { use super::*; @@ -280,9 +280,11 @@ mod activitypub_tests { let actor = actor_document(BASE, KEY); let context = actor["@context"].as_array().expect("context array"); - assert!(context - .iter() - .any(|item| item == "https://www.w3.org/ns/activitystreams")); + assert!( + context + .iter() + .any(|item| item == "https://www.w3.org/ns/activitystreams") + ); assert!(context.iter().any(|item| item == "https://forgefed.org/ns")); assert!(context.iter().any(|item| item["aliases"] == "fep:aliases")); } @@ -326,8 +328,9 @@ mod activitypub_tests { mod config_verbose_tests { use link_assistant_router::config::{ + BuildArgs, Config, RoutingMode, StoragePolicy, UpstreamProvider, default_activitypub_public_key_pem, default_crater_config, default_gonka_model, - default_gonka_source_url, BuildArgs, Config, RoutingMode, StoragePolicy, UpstreamProvider, + default_gonka_source_url, }; use std::path::PathBuf; @@ -377,8 +380,8 @@ mod config_verbose_tests { mod openai_translation_tests { use link_assistant_router::openai::{ - anthropic_to_chat_completion, chat_completion_to_anthropic, list_models, map_model, - ChatMessage, OpenAIChatCompletionRequest, + ChatMessage, OpenAIChatCompletionRequest, anthropic_to_chat_completion, + chat_completion_to_anthropic, list_models, map_model, }; use serde_json::json; @@ -451,7 +454,7 @@ mod openai_translation_tests { mod mpp_tests { use axum::http::{HeaderMap, HeaderValue, StatusCode}; use link_assistant_router::mpp::{ - has_payment_credential, payment_required, unsupported_payment_verification, MppConfig, + MppConfig, has_payment_credential, payment_required, unsupported_payment_verification, }; #[test] @@ -496,7 +499,7 @@ mod mpp_tests { } mod metrics_rendering_tests { - use link_assistant_router::metrics::{render_prometheus, usage_snapshot, Metrics, Surface}; + use link_assistant_router::metrics::{Metrics, Surface, render_prometheus, usage_snapshot}; #[test] fn prometheus_output_contains_all_required_counters() {