Compare commits
725 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76edfbceee | |||
| 2bc135983b | |||
| 605adaa3c2 | |||
| 9ef99aa766 | |||
| 2007a89594 | |||
| 26d13af28f | |||
| be841b88ee | |||
| 4b13f9c255 | |||
| 74cb53dee1 | |||
| 2607888a97 | |||
| 8b3670b8dd | |||
| 96754f5a33 | |||
| ddd10539ad | |||
| 088ab33df8 | |||
| 53d1fd6c5c | |||
| 2ff9e33e26 | |||
| e4c957078c | |||
| cd0cea393c | |||
| c66fa37665 | |||
| f4c6c41f0b | |||
| 644d5ea618 | |||
| 48104abf51 | |||
| 7a1a3408bf | |||
| 82c9e0de58 | |||
| e7a185962d | |||
| 30a8824b64 | |||
| 9d01c80d33 | |||
| 1d529c3ce4 | |||
| 229d03a690 | |||
| 3a9fb3780e | |||
| 437aa87c9b | |||
| bcbb94906c | |||
| 6bfcb0ce79 | |||
| bd8c05a830 | |||
| 041ccf0195 | |||
| 1fca942b9c | |||
| c5596e0925 | |||
| 5e5b1bce35 | |||
| 15ac7fb932 | |||
| 8300ee8bbe | |||
| dc1cc7f115 | |||
| 28dfcae350 | |||
| a44e5eb1ab | |||
| 67fb4eb98e | |||
| 66c3dae06b | |||
| 1abf8625d8 | |||
| 3cbd587b2c | |||
| 41f4ee7c7d | |||
| c69ff49758 | |||
| 68fddaa319 | |||
| 09ac8a1165 | |||
| 0dcb8bd714 | |||
| 0f0fcd2304 | |||
| c67096b687 | |||
| 1721994111 | |||
| 4cbe172934 | |||
| 4071fdef84 | |||
| c883114a4d | |||
| e50cabac4b | |||
| 785b00c312 | |||
| a034cf8b8d | |||
| 01171742a6 | |||
| fb08b92402 | |||
| a09a16e8f6 | |||
| ad3f4f2ce5 | |||
| 17a1f53c47 | |||
| ed1458aa6d | |||
| 99dfbaef61 | |||
| da6c599efd | |||
| 61b39d49bd | |||
| ba5d8ca733 | |||
| 28b4b19e7e | |||
| bdc424007e | |||
| e4a93c02c5 | |||
| 8262a03f29 | |||
| ecf1c2590c | |||
| 162897e02a | |||
| c1caa454b3 | |||
| bf6fa402e2 | |||
| 85c0150653 | |||
| 89d80bfff4 | |||
| a1eba112f3 | |||
| 6b4bc0a9a8 | |||
| 08b0fe6816 | |||
| c19ae1d5be | |||
| 17be6442a8 | |||
| 38dad2afdf | |||
| 8e6ef3fa64 | |||
| a1487b0958 | |||
| 82ebe24b9e | |||
| 2753d9fb71 | |||
| bf0e5c23f7 | |||
| 672fdd14ed | |||
| af65908cb0 | |||
| 756b600b7a | |||
| 054d0dee1d | |||
| d2386a3114 | |||
| 7972130513 | |||
| 81db7fdc1e | |||
| 5fc6f662e1 | |||
| 593995a404 | |||
| 101b59cfe8 | |||
| 56df36895a | |||
| 85124f098b | |||
| 2efa56dbb8 | |||
| 79579c34bf | |||
| 18bb9c315f | |||
| b8bba053fc | |||
| 8c2f1a80d3 | |||
| e37f3be0bf | |||
| b0dc9df887 | |||
| 6187919000 | |||
| 4035abc0cd | |||
| 8b286e8fb3 | |||
| aa70d13f60 | |||
| 05ecfb6241 | |||
| 243c582159 | |||
| 6ba7c810a7 | |||
| f56a19e5b8 | |||
| 46018417ad | |||
| e3e60f914b | |||
| 359ec30d0c | |||
| a1f0ed9575 | |||
| b3b92f334e | |||
| 2c1539ead7 | |||
| 0d107dd566 | |||
| 1c0c426b85 | |||
| 4982512da2 | |||
| 2ea8f77efb | |||
| f95ab4cdf1 | |||
| c4965befe7 | |||
| 0e1235122e | |||
| e78d45acc9 | |||
| b34f3be13e | |||
| 9fb6a49260 | |||
| a992dee4e8 | |||
| 3ac39dcc7d | |||
| 34027da7f1 | |||
| c523101439 | |||
| d85e13b044 | |||
| 01e16a8509 | |||
| 04a336f7df | |||
| a325533f20 | |||
| 6fc23568df | |||
| 736ae61e4a | |||
| c5bea6f6f8 | |||
| c7b28ba058 | |||
| 38573050aa | |||
| 32ef1588e8 | |||
| 80eb03709a | |||
| d36e70e9dc | |||
| fd45dece7f | |||
| 95318ad46d | |||
| fc1ddf365f | |||
| 03ea4e569f | |||
| e707cf7d46 | |||
| 0a7c6b0a4a | |||
| ea670ef8c0 | |||
| 2c626efc59 | |||
| 28d78273e4 | |||
| be0fe6fab3 | |||
| cf043f6c07 | |||
| faad8e30dd | |||
| a7e92e2639 | |||
| 4056c2590b | |||
| 36cc762fc9 | |||
| 5f5d5936fa | |||
| f1ba6151a9 | |||
| 7b89583cf8 | |||
| 1576d14137 | |||
| 736018a0b0 | |||
| 7d5f6d9382 | |||
| 12195a276e | |||
| 25137b1984 | |||
| 7ad1900041 | |||
| 8eb56e5602 | |||
| f130846ec1 | |||
| 920b6efffa | |||
| e75daa299b | |||
| 8e49c795f5 | |||
| 4de5c29f86 | |||
| 4d6457e6ec | |||
| 14d46a0a5d | |||
| 31934ae04c | |||
| a188159632 | |||
| fd71960c3e | |||
| e935196df4 | |||
| 4fc2c619fb | |||
| 8ced7a548f | |||
| 3444820958 | |||
| 1716a845eb | |||
| b6781d69be | |||
| bb8408cef5 | |||
| e6866ff19c | |||
| 8f4a4eabfc | |||
| e05abec01f | |||
| f4eb16102b | |||
| 86c856f56f | |||
| c6baa64b4e | |||
| a64141a9a6 | |||
| c26936e2e6 | |||
| 894baad829 | |||
| eba561bf6f | |||
| da43f63735 | |||
| d9a3b3e5f3 | |||
| 5dcca69e8c | |||
| f5dc6483d5 | |||
| d949921143 | |||
| 7b03f04670 | |||
| 8f9e6622b0 | |||
| 1267fddf61 | |||
| ba454dbfbf | |||
| d1508ca030 | |||
| d4a6a5ae15 | |||
| 7c24d54ca8 | |||
| a4c1e32ff6 | |||
| f56cf42461 | |||
| 3dea1da249 | |||
| 8fac29631d | |||
| 8fecd625d2 | |||
| 10b55b5ddd | |||
| 41ae2c81e7 | |||
| 278a89824c | |||
| c4459c4346 | |||
| 61e0447f92 | |||
| 1dc3018fd6 | |||
| 26fd3eff03 | |||
| 5bfaf8086b | |||
| 6c0a1efd71 | |||
| f5ed5c7453 | |||
| 65158cce46 | |||
| a583463d60 | |||
| 8ed290c1c4 | |||
| 727221df2e | |||
| 0ab1f5412f | |||
| 9ded75d335 | |||
| f135fdf7fc | |||
| 828df80088 | |||
| c585caa0ce | |||
| 5bb69fa4ab | |||
| 5ab9afac83 | |||
| 65ce86338b | |||
| 2a97037d7b | |||
| d801393841 | |||
| b2c0cdfc88 | |||
| f32c8c9620 | |||
| 0f45d89255 | |||
| 96056d0137 | |||
| f780c289e8 | |||
| ac36119a02 | |||
| 39dc4557c1 | |||
| 30e94b6792 | |||
| 38f0ae5970 | |||
| cf249586a9 | |||
| 1dba2d0f81 | |||
| 730809d8ea | |||
| e8d1b79cb3 | |||
| 5e81b65f2f | |||
| 7e8e2226a6 | |||
| f0c20e852f | |||
| 7cdf8e9872 | |||
| e2e3c7dde0 | |||
| 9e0ab4d116 | |||
| 8783caf313 | |||
| f6f4640c5e | |||
| 613fe6768d | |||
| ad8e3964ff | |||
| 941334da79 | |||
| d54f816363 | |||
| 69b950db4c | |||
| 343a2fc2f7 | |||
| 12b967118b | |||
| 70efd4e016 | |||
| f5aa68ecda | |||
| d390b95b76 | |||
| d1f6224b70 | |||
| fcc59d606d | |||
| 91e7591955 | |||
| c8b7e2b8d6 | |||
| 4ca00f7983 | |||
| d2d0e6f6a1 | |||
| a0fe273081 | |||
| cad45ffa33 | |||
| 6a27bceec0 | |||
| 163d68318f | |||
| 0ea768011b | |||
| 8b9dbe10f0 | |||
| 29e32aaab9 | |||
| 6431cec7d3 | |||
| 9f5bdfaa31 | |||
| 9eabdd09db | |||
| c3f8dc362e | |||
| b85120873b | |||
| 6f58518c69 | |||
| 000fcb15fa | |||
| ea43361492 | |||
| c1818f197b | |||
| b0653cec7b | |||
| 22a1a24cf5 | |||
| ada8e2905e | |||
| 4ba10531da | |||
| 3774b56e9f | |||
| c2d4137fb9 | |||
| 2ee938acaf | |||
| 8d5e470e1f | |||
| 65e9e892a4 | |||
| 8430b28cfa | |||
| f3ab8f4bc5 | |||
| 0e4f189c2e | |||
| 754b126944 | |||
| ae37ccffbf | |||
| 42c062bb5b | |||
| f389667ec3 | |||
| 29dba0399b | |||
| a824e7cd0b | |||
| adb580b344 | |||
| 06405f2129 | |||
| d1fd2c4ad4 | |||
| b6c6379bfa | |||
| 8f0e66b72e | |||
| f63cf6ff7a | |||
| d2419ed49d | |||
| 9b5ce8c64f | |||
| 058793c73a | |||
| ab9ebea592 | |||
| 7ee37ee4b9 | |||
| 3171d524f0 | |||
| 3e78a8d500 | |||
| fcba912cc4 | |||
| 7170eeea5f | |||
| e3eb048c7a | |||
| a59e92435b | |||
| 108895fc04 | |||
| abc293c642 | |||
| da3a498a28 | |||
| bb44671845 | |||
| 09e480036a | |||
| 249f969110 | |||
| 4f8acec2d8 | |||
| 34339f61ee | |||
| 4045378cb4 | |||
| 4f99bc54f1 | |||
| 913f4a9c5f | |||
| 25d1c18a3f | |||
| d09dd4d0b2 | |||
| 474fb042da | |||
| 8435c3d7be | |||
| e783d0a62e | |||
| b05f575e9b | |||
| f5e9f01811 | |||
| ff7dbb5867 | |||
| e34b2b4f1d | |||
| 15c2f274ea | |||
| 37249339ac | |||
| c422d16beb | |||
| 66cd50f603 | |||
| caa529c282 | |||
| 51a4379bf4 | |||
| acf98ed10e | |||
| d1c07a091e | |||
| 105a21548f | |||
| 1734aa1664 | |||
| ca11b236a7 | |||
| 6fdff8227d | |||
| 330e12d3c2 | |||
| b468ca79c3 | |||
| d2c7e4e96a | |||
| 1c7003ff68 | |||
| 1b44364e78 | |||
| ec77f4a4f5 | |||
| f611dd6e96 | |||
| 07b7c1a1e0 | |||
| 51fd58d74f | |||
| faae9c2f7c | |||
| bc3a6e4646 | |||
| b09b03e35e | |||
| 16231947e7 | |||
| 39b9a38fbc | |||
| bd855abec9 | |||
| 7c3c2e9f64 | |||
| c10f8ae2e2 | |||
| a0bf33eca6 | |||
| 88dd9c715d | |||
| a3e21df814 | |||
| d3b94c9241 | |||
| c1d7599829 | |||
| d11936f292 | |||
| 17363edf25 | |||
| 279cbbbb8a | |||
| 91387ca247 | |||
| 486cd4c343 | |||
| 25feceb783 | |||
| d26752250d | |||
| b15453c369 | |||
| 04ba8c8bc3 | |||
| fccfb162b4 | |||
| 6570692291 | |||
| f73d55ddaa | |||
| 13aa5b3375 | |||
| 0fcc02fbea | |||
| c03883ccf0 | |||
| 134a9eac9d | |||
| 6d8de0ade4 | |||
| 1587ff5e74 | |||
| f033d3a6df | |||
| 145e0e0b5d | |||
| 9b7d7021af | |||
| e41c22ef44 | |||
| 5fc2bd393e | |||
| 55271403fb | |||
| 36fba66619 | |||
| 66eb12294a | |||
| 73b22ec29b | |||
| c31ae2f3b5 | |||
| 76b53d6b5b | |||
| a34dfed378 | |||
| b9b127a7ea | |||
| 2741e7b7b3 | |||
| 1767a56d4f | |||
| 779e6c2d2f | |||
| 73c831747b | |||
| 10b824fcac | |||
| e5d3541b5a | |||
| 79755e76ea | |||
| 35f158d526 | |||
| 6962e09dd9 | |||
| 4c4cbd44da | |||
| 26eca8b6ba | |||
| 62b17f40a1 | |||
| 511b8a992e | |||
| 7dccc7ba2f | |||
| 70c90687fd | |||
| 8144ffd5c8 | |||
| 0ab977c236 | |||
| 224f0de353 | |||
| 6b45d311ec | |||
| d54de441d3 | |||
| 1821bf7051 | |||
| d42b5d4e78 | |||
| 754f3bcbc3 | |||
| 36973d4a6f | |||
| c89d19b300 | |||
| 1e6bc81cfd | |||
| 1a149475e0 | |||
| e5166841db | |||
| bb9b2d1758 | |||
| 76c064c729 | |||
| d2f652f436 | |||
| 6a452a54d5 | |||
| 9e5693e74f | |||
| 528b1a2307 | |||
| 0cc978ec1d | |||
| d312422ab4 | |||
| fee736933b | |||
| 09c92aa0b5 | |||
| 8c67b3ae64 | |||
| 000e4ceb4e | |||
| 5c99846ecf | |||
| cc32f5ff61 | |||
| fbff68b9e0 | |||
| 7e1a543b79 | |||
| d475aaba96 | |||
| 96f55570f7 | |||
| 0906aeca87 | |||
| 7333619f15 | |||
| 97c0487add | |||
| 74b862d8b8 | |||
| 2db8df8e38 | |||
| a576088d5f | |||
| 66ff916838 | |||
| 7b0453074e | |||
| a000eb523d | |||
| 18a4fedc7f | |||
| 5d6cdccda0 | |||
| 1b7f4ac3e1 | |||
| afc1a5b814 | |||
| 7ed38db54f | |||
| 28c10f4e69 | |||
| 6e12441a3b | |||
| 65c439c18d | |||
| 0ed2d16596 | |||
| db335ac616 | |||
| e8bb350467 | |||
| 5331d51f27 | |||
| 755ca75879 | |||
| 2398ebad55 | |||
| c1bf298216 | |||
| e005208d76 | |||
| d1df70d02f | |||
| f81acd0760 | |||
| 636da4c932 | |||
| cccb77b552 | |||
| 2bd646ad70 | |||
| 52c1fa025e | |||
| 680105f84d | |||
| f7069e9548 | |||
| 793840cdb4 | |||
| 8f421de532 | |||
| be2dd60ee7 | |||
| ea3e0b713e | |||
| 8179d5a8a4 | |||
| 6fa7abe434 | |||
| 5135c22cd6 | |||
| 1e27990561 | |||
| e1e9fc43c1 | |||
| b2921518ac | |||
| dd64adbeeb | |||
| 616d41c06a | |||
| e0e337aeb9 | |||
| d52839fced | |||
| 56073ded69 | |||
| 9738a53f49 | |||
| be3f8dbf7e | |||
| 9c6c3612a8 | |||
| 19e1a4447a | |||
| 36efcc6e28 | |||
| a337ecf35c | |||
| fb95813fbf | |||
| db63f9b5d6 | |||
| 25f6c4a250 | |||
| b24ae74216 | |||
| 59ad8f40dc | |||
| ff03dc6a2c | |||
| dc7187ca5b | |||
| b1dcff778c | |||
| 198b3f4a40 | |||
| 9fee7f488e | |||
| c1241a98e2 | |||
| 8d8f5970ee | |||
| f90120f846 | |||
| 0b94d36c4a | |||
| 152c310bb7 | |||
| f6bbca35ab | |||
| c8cee6a209 | |||
| b5701f416b | |||
| 4b1a404fcb | |||
| 67669196ed | |||
| 5c817a9b42 | |||
| e08f68ed7c | |||
| f09ed25fd3 | |||
| 58fd9bf964 | |||
| 7b3dfc67bc | |||
| cdd24052d3 | |||
| 733fd8edab | |||
| af27f2b8bc | |||
| 2e1925d762 | |||
| 77254bd074 | |||
| 5b6342e6ac | |||
| e166e56249 | |||
| 560c020477 | |||
| aec65e3be3 | |||
| f44f0702f8 | |||
| b76b79068f | |||
| 1db23979e8 | |||
| c3d5dbe96f | |||
| 5484489406 | |||
| 0ac52da460 | |||
| 817cebb321 | |||
| 683f3709d6 | |||
| dbd42a42b2 | |||
| ec24baf757 | |||
| dea3e74d35 | |||
| a6c3042e34 | |||
| 861537c9bd | |||
| 8c92cb0883 | |||
| 89d7be9525 | |||
| 2b79d7f22f | |||
| 163fe287ce | |||
| 70988d387b | |||
| ddaa9d2436 | |||
| 7b7b258c38 | |||
| cf74ed2f0c | |||
| c3762328a5 | |||
| e333fbea3d | |||
| efbe36d1d4 | |||
| 8553cfa40e | |||
| 30d5c95b26 | |||
| d1e3195e6f | |||
| ce53d3a287 | |||
| 5f58248016 | |||
| 4cc99e7449 | |||
| 71773fe032 | |||
| a1e0fa0f39 | |||
| fc2f0b6983 | |||
| 5c9997cdac | |||
| f5941a411c | |||
| ba672bbd07 | |||
| d9c6627a53 | |||
| 2e9907c3ac | |||
| 90afb9cb73 | |||
| d0cc0cd9a5 | |||
| 338321e553 | |||
| 4f48e5254a | |||
| 15dd5db1d7 | |||
| 424711b718 | |||
| 2b134fc378 | |||
| b9153719b0 | |||
| 631e5c8331 | |||
| e9c60a0a67 | |||
| 98a1bb5a7f | |||
| ca90487a8c | |||
| 1042489f85 | |||
| 38277c1ea6 | |||
| 07d6689d87 | |||
| 3a18f6fcca | |||
| 099e734a02 | |||
| a52da26b5d | |||
| 522a68a4ea | |||
| a02eda54d0 | |||
| 97ef633c57 | |||
| dae8463ba1 | |||
| 7c1299922e | |||
| ddcf1f279d | |||
| 7e6bb8fdc5 | |||
| 9cee8ef87b | |||
| 93fb841bcb | |||
| 5ebc58fab4 | |||
| 2b609dd891 | |||
| a8cbc68c3e | |||
| 11a795a01c | |||
| 2695a99623 | |||
| 242aecd924 | |||
| ce8cc1ba33 | |||
| 97fdd2e088 | |||
| 9397f7049f | |||
| 8822f20d17 | |||
| 553d6f50ea | |||
| f0e5a5a367 | |||
| f6dfea9357 | |||
| cc8dc7f62c | |||
| a3846ea513 | |||
| 8d44be858e | |||
| 0e6bb076e9 | |||
| ac135fc7cb | |||
| 4e1d09809d | |||
| ac95e92829 | |||
| 8526c2da25 | |||
| 68a6cabf8b | |||
| ac0e387da1 | |||
| 5850492a93 | |||
| fdbd4041ca | |||
| ebef1fae2a | |||
| 419bf784ab | |||
| 4bbeb92e9a | |||
| b436dad8bc | |||
| 6ae15d6c44 | |||
| 0468bde0d6 | |||
| 1d7329e797 | |||
| 48ffc4dee7 | |||
| b680c146c1 | |||
| d26ad8224d | |||
| 5c84d69d42 | |||
| 527e4b7f26 | |||
| b48485b42b | |||
| 79009bb3d4 | |||
| 9f95b31158 | |||
| 5da07eae4c | |||
| 835ae178d4 | |||
| c80ab8bf0d | |||
| ce87714ef1 | |||
| 0452b869e8 | |||
| d2e5857b82 | |||
| f9b005f21f | |||
| 532107b4fa | |||
| c44793789b | |||
| 09fec34e1c | |||
| 9229708b6c | |||
| 914db94e79 | |||
| 660bd7eff5 | |||
| b907d21851 | |||
| dd44413ba5 | |||
| 10fa0f2062 | |||
| d6cc976d1f | |||
| 8aa2cce8c5 | |||
| 77b42c6165 | |||
| 1cbc4834e1 | |||
| 30338ecec4 | |||
| 9a37defed3 | |||
| c83a057996 | |||
| a8a5d03c33 | |||
| 76aa917882 | |||
| 6ac9b31e4e | |||
| 0ad3e8457f | |||
| 444a47ae63 | |||
| 725f4fdff4 | |||
| c23e46f45d | |||
| b148820c35 | |||
| 134f41496d | |||
| 1ae994b4aa | |||
| cc1d8f6629 | |||
| 5446cd2b02 | |||
| 8de0885b7d | |||
| a6ce5f36e6 | |||
| e73cf42e28 | |||
| b45343e812 | |||
| 8599b1560e | |||
| 8bde8c37c0 | |||
| 27c68f5bb2 | |||
| 68dd2bfe82 | |||
| 41b1cf2273 | |||
| 2baf35b3ef | |||
| 846e75b893 | |||
| fc0257d6d9 | |||
| f3c164d345 | |||
| 4040b1e766 | |||
| 3b4f9f43db | |||
| 0da34d3c2d | |||
| 74bf7eda8f | |||
| 0aaf177640 | |||
| b7588428c5 | |||
| 8f97a5f77c | |||
| 2a4d3e60f3 | |||
| 8b5af2ab84 | |||
| d887716ebd | |||
| 5dc1848466 | |||
| 9491517b26 | |||
| 9370b5bd04 | |||
| abb51a0d93 | |||
| c8d809131b | |||
| dd71c73a9f | |||
| a99522224f | |||
| f5d46b9ca2 | |||
| 2615f489d6 | |||
| 14cb2b95c6 | |||
| fdeef48498 |
@@ -31,6 +31,7 @@ bin/*
|
||||
.agent/*
|
||||
.agents/*
|
||||
.opencode/*
|
||||
.idea/*
|
||||
.bmad/*
|
||||
_bmad/*
|
||||
_bmad-output/*
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: agents-md-guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-when-agents-md-changed:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Detect AGENTS.md changes and close PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const touchesAgentsMd = (path) =>
|
||||
typeof path === "string" &&
|
||||
(path === "AGENTS.md" || path.endsWith("/AGENTS.md"));
|
||||
|
||||
const touched = files.filter(
|
||||
(f) => touchesAgentsMd(f.filename) || touchesAgentsMd(f.previous_filename),
|
||||
);
|
||||
|
||||
if (touched.length === 0) {
|
||||
core.info("No AGENTS.md changes detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const changedList = touched
|
||||
.map((f) =>
|
||||
f.previous_filename && f.previous_filename !== f.filename
|
||||
? `- ${f.previous_filename} -> ${f.filename}`
|
||||
: `- ${f.filename}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const body = [
|
||||
"This repository does not allow modifying `AGENTS.md` in pull requests.",
|
||||
"",
|
||||
"Detected changes:",
|
||||
changedList,
|
||||
"",
|
||||
"Please revert these changes and open a new PR without touching `AGENTS.md`.",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`);
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
core.setFailed("PR modifies AGENTS.md");
|
||||
@@ -0,0 +1,73 @@
|
||||
name: auto-retarget-main-pr-to-dev
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
retarget:
|
||||
if: github.actor != 'github-actions[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Retarget PR base to dev
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const baseRef = pr.base?.ref;
|
||||
const headRef = pr.head?.ref;
|
||||
const desiredBase = "dev";
|
||||
|
||||
if (baseRef !== "main") {
|
||||
core.info(`PR #${prNumber} base is ${baseRef}; nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (headRef === desiredBase) {
|
||||
core.info(`PR #${prNumber} is ${desiredBase} -> main; skipping retarget.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Retargeting PR #${prNumber} base from ${baseRef} to ${desiredBase}.`);
|
||||
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
base: desiredBase,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Failed to retarget PR #${prNumber} to ${desiredBase}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
`This pull request targeted \`${baseRef}\`.`,
|
||||
"",
|
||||
`The base branch has been automatically changed to \`${desiredBase}\`.`,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to comment on PR #${prNumber}: ${error.message}`);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Refresh models catalog
|
||||
run: |
|
||||
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to DockerHub
|
||||
@@ -24,7 +28,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Generate Build Metadata
|
||||
run: |
|
||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||
- name: Build and push (amd64)
|
||||
@@ -46,6 +50,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Refresh models catalog
|
||||
run: |
|
||||
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to DockerHub
|
||||
@@ -55,7 +63,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Generate Build Metadata
|
||||
run: |
|
||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||
- name: Build and push (arm64)
|
||||
@@ -89,7 +97,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Generate Build Metadata
|
||||
run: |
|
||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||
- name: Create and push multi-arch manifests
|
||||
|
||||
@@ -12,6 +12,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Refresh models catalog
|
||||
run: |
|
||||
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -16,6 +16,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Refresh models catalog
|
||||
run: |
|
||||
git fetch --depth 1 https://github.com/router-for-me/models.git main
|
||||
git show FETCH_HEAD:models.json > internal/registry/models/models.json
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
@@ -23,14 +27,14 @@ jobs:
|
||||
cache: true
|
||||
- name: Generate Build Metadata
|
||||
run: |
|
||||
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
|
||||
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
|
||||
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
|
||||
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
|
||||
- uses: goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
args: release --clean --skip=validate
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
+4
-1
@@ -33,14 +33,16 @@ GEMINI.md
|
||||
|
||||
# Tooling metadata
|
||||
.vscode/*
|
||||
.worktrees/
|
||||
.codex/*
|
||||
.claude/*
|
||||
.gemini/*
|
||||
.serena/*
|
||||
.agent/*
|
||||
.agents/*
|
||||
.agents/*
|
||||
.opencode/*
|
||||
.idea/*
|
||||
.beads/*
|
||||
.bmad/*
|
||||
_bmad/*
|
||||
_bmad-output/*
|
||||
@@ -48,3 +50,4 @@ _bmad-output/*
|
||||
# macOS
|
||||
.DS_Store
|
||||
._*
|
||||
.gocache/
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- id: "cli-proxy-api"
|
||||
env:
|
||||
@@ -6,6 +8,7 @@ builds:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
@@ -16,6 +19,8 @@ builds:
|
||||
archives:
|
||||
- id: "cli-proxy-api"
|
||||
format: tar.gz
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{- if eq .Arch "arm64" -}}aarch64{{- else -}}{{ .Arch }}{{- end -}}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# AGENTS.md
|
||||
|
||||
Go 1.26+ proxy server providing OpenAI/Gemini/Claude/Codex compatible APIs with OAuth and round-robin load balancing.
|
||||
|
||||
## Repository
|
||||
- GitHub: https://github.com/router-for-me/CLIProxyAPI
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
gofmt -w . # Format (required after Go changes)
|
||||
go build -o cli-proxy-api ./cmd/server # Build
|
||||
go run ./cmd/server # Run dev server
|
||||
go test ./... # Run all tests
|
||||
go test -v -run TestName ./path/to/pkg # Run single test
|
||||
go build -o test-output ./cmd/server && rm test-output # Verify compile (REQUIRED after changes)
|
||||
```
|
||||
- Common flags: `--config <path>`, `--tui`, `--standalone`, `--local-model`, `--no-browser`, `--oauth-callback-port <port>`
|
||||
|
||||
## Config
|
||||
- Default config: `config.yaml` (template: `config.example.yaml`)
|
||||
- `.env` is auto-loaded from the working directory
|
||||
- Auth material defaults under `auths/`
|
||||
- Storage backends: file-based default; optional Postgres/git/object store (`PGSTORE_*`, `GITSTORE_*`, `OBJECTSTORE_*`)
|
||||
|
||||
## Architecture
|
||||
- `cmd/server/` — Server entrypoint
|
||||
- `internal/api/` — Gin HTTP API (routes, middleware, modules)
|
||||
- `internal/api/modules/amp/` — Amp integration (Amp-style routes + reverse proxy)
|
||||
- `internal/thinking/` — Main thinking/reasoning pipeline. `ApplyThinking()` (apply.go) parses suffixes (`suffix.go`, suffix overrides body), normalizes config to canonical `ThinkingConfig` (`types.go`), normalizes and validates centrally (`validate.go`/`convert.go`), then applies provider-specific output via `ProviderApplier`. Do not break this "canonical representation → per-provider translation" architecture.
|
||||
- `internal/runtime/executor/` — Per-provider runtime executors (incl. Codex WebSocket)
|
||||
- `internal/translator/` — Provider protocol translators (and shared `common`)
|
||||
- `internal/registry/` — Model registry + remote updater (`StartModelsUpdater`); `--local-model` disables remote updates
|
||||
- `internal/store/` — Storage implementations and secret resolution
|
||||
- `internal/managementasset/` — Config snapshots and management assets
|
||||
- `internal/cache/` — Request signature caching
|
||||
- `internal/watcher/` — Config hot-reload and watchers
|
||||
- `internal/wsrelay/` — WebSocket relay sessions
|
||||
- `internal/usage/` — Usage and token accounting
|
||||
- `internal/tui/` — Bubbletea terminal UI (`--tui`, `--standalone`)
|
||||
- `sdk/cliproxy/` — Embeddable SDK entry (service/builder/watchers/pipeline)
|
||||
- `test/` — Cross-module integration tests
|
||||
|
||||
## Code Conventions
|
||||
- Keep changes small and simple (KISS)
|
||||
- Comments in English only
|
||||
- If editing code that already contains non-English comments, translate them to English (don’t add new non-English comments)
|
||||
- For user-visible strings, keep the existing language used in that file/area
|
||||
- New Markdown docs should be in English unless the file is explicitly language-specific (e.g. `README_CN.md`)
|
||||
- As a rule, do not make standalone changes to `internal/translator/`. You may modify it only as part of broader changes elsewhere.
|
||||
- If a task requires changing only `internal/translator/`, run `gh repo view --json viewerPermission -q .viewerPermission` to confirm you have `WRITE`, `MAINTAIN`, or `ADMIN`. If you do, you may proceed; otherwise, file a GitHub issue including the goal, rationale, and the intended implementation code, then stop further work.
|
||||
- `internal/runtime/executor/` should contain executors and their unit tests only. Place any helper/supporting files under `internal/runtime/executor/helps/`.
|
||||
- Follow `gofmt`; keep imports goimports-style; wrap errors with context where helpful
|
||||
- Do not use `log.Fatal`/`log.Fatalf` (terminates the process); prefer returning errors and logging via logrus
|
||||
- Shadowed variables: use method suffix (`errStart := server.Start()`)
|
||||
- Wrap defer errors: `defer func() { if err := f.Close(); err != nil { log.Errorf(...) } }()`
|
||||
- Use logrus structured logging; avoid leaking secrets/tokens in logs
|
||||
- Avoid panics in HTTP handlers; prefer logged errors and meaningful HTTP status codes
|
||||
- Timeouts are allowed only during credential acquisition; after an upstream connection is established, do not set timeouts for any subsequent network behavior. Intentional exceptions that must remain allowed are the Codex websocket liveness deadlines in `internal/runtime/executor/codex_websockets_executor.go`, the wsrelay session deadlines in `internal/wsrelay/session.go`, the management APICall timeout in `internal/api/handlers/management/api_tools.go`, and the `cmd/fetch_antigravity_models` utility timeouts
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
# CLIProxyAPI 호출 가이드
|
||||
|
||||
## 접속 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 외부 URL | `https://cliproxy.gru.farm` |
|
||||
| 내부 URL | `http://192.168.0.17:8317` |
|
||||
| API 키 | `Jinie4eva!` |
|
||||
| 인증 방식 | `Authorization: Bearer <API키>` |
|
||||
|
||||
## 엔드포인트
|
||||
|
||||
| 용도 | 경로 |
|
||||
|------|------|
|
||||
| Claude 네이티브 (권장) | `/api/provider/claude/v1/messages` |
|
||||
| OpenAI 호환 | `/v1/chat/completions` |
|
||||
| 모델 목록 | `/v1/models` |
|
||||
|
||||
## 사용 가능한 모델
|
||||
|
||||
| 모델 ID | 설명 |
|
||||
|---------|------|
|
||||
| `claude-sonnet-4-6` | Claude Sonnet 4.6 (최신, 권장) |
|
||||
| `claude-opus-4-6` | Claude Opus 4.6 (최고 성능) |
|
||||
| `claude-sonnet-4-5-20250929` | Claude Sonnet 4.5 |
|
||||
| `claude-opus-4-5-20251101` | Claude Opus 4.5 |
|
||||
| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 (경량/빠름) |
|
||||
| `claude-sonnet-4-20250514` | Claude Sonnet 4 |
|
||||
| `claude-opus-4-20250514` | Claude Opus 4 |
|
||||
| `claude-3-7-sonnet-20250219` | Claude 3.7 Sonnet |
|
||||
| `claude-3-5-haiku-20241022` | Claude 3.5 Haiku |
|
||||
|
||||
---
|
||||
|
||||
## 1. curl
|
||||
|
||||
### 기본 호출
|
||||
|
||||
```bash
|
||||
curl -X POST https://cliproxy.gru.farm/api/provider/claude/v1/messages \
|
||||
-H "Authorization: Bearer Jinie4eva!" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"max_tokens": 1024,
|
||||
"messages": [
|
||||
{"role": "user", "content": "안녕! 간단히 소개해줘"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 스트리밍
|
||||
|
||||
```bash
|
||||
curl -X POST https://cliproxy.gru.farm/api/provider/claude/v1/messages \
|
||||
-H "Authorization: Bearer Jinie4eva!" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"max_tokens": 1024,
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{"role": "user", "content": "안녕!"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 모델 목록 조회
|
||||
|
||||
```bash
|
||||
curl https://cliproxy.gru.farm/v1/models \
|
||||
-H "Authorization: Bearer Jinie4eva!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Python — Anthropic SDK
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
pip install anthropic
|
||||
```
|
||||
|
||||
### 기본 호출
|
||||
|
||||
```python
|
||||
from anthropic import Anthropic
|
||||
|
||||
client = Anthropic(
|
||||
base_url="https://cliproxy.gru.farm/api/provider/claude",
|
||||
api_key="Jinie4eva!"
|
||||
)
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=1024,
|
||||
messages=[
|
||||
{"role": "user", "content": "안녕! 간단히 소개해줘"}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.content[0].text)
|
||||
```
|
||||
|
||||
### 스트리밍
|
||||
|
||||
```python
|
||||
from anthropic import Anthropic
|
||||
|
||||
client = Anthropic(
|
||||
base_url="https://cliproxy.gru.farm/api/provider/claude",
|
||||
api_key="Jinie4eva!"
|
||||
)
|
||||
|
||||
with client.messages.stream(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=1024,
|
||||
messages=[
|
||||
{"role": "user", "content": "안녕! 간단히 소개해줘"}
|
||||
]
|
||||
) as stream:
|
||||
for text in stream.text_stream:
|
||||
print(text, end="", flush=True)
|
||||
```
|
||||
|
||||
### 시스템 프롬프트 + 멀티턴
|
||||
|
||||
```python
|
||||
from anthropic import Anthropic
|
||||
|
||||
client = Anthropic(
|
||||
base_url="https://cliproxy.gru.farm/api/provider/claude",
|
||||
api_key="Jinie4eva!"
|
||||
)
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=1024,
|
||||
system="당신은 친절한 한국어 AI 어시스턴트입니다.",
|
||||
messages=[
|
||||
{"role": "user", "content": "파이썬이 뭐야?"},
|
||||
{"role": "assistant", "content": "파이썬은 프로그래밍 언어입니다."},
|
||||
{"role": "user", "content": "그럼 자바스크립트는?"}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.content[0].text)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Python — OpenAI SDK (호환 모드)
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
pip install openai
|
||||
```
|
||||
|
||||
### 기본 호출
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://cliproxy.gru.farm/v1",
|
||||
api_key="Jinie4eva!"
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="claude-sonnet-4-6",
|
||||
messages=[
|
||||
{"role": "user", "content": "안녕!"}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### 스트리밍
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://cliproxy.gru.farm/v1",
|
||||
api_key="Jinie4eva!"
|
||||
)
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model="claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "안녕!"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="", flush=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Node.js — Anthropic SDK
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
npm install @anthropic-ai/sdk
|
||||
```
|
||||
|
||||
### 기본 호출
|
||||
|
||||
```javascript
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
const client = new Anthropic({
|
||||
baseURL: "https://cliproxy.gru.farm/api/provider/claude",
|
||||
apiKey: "Jinie4eva!",
|
||||
});
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: "user", content: "안녕!" }],
|
||||
});
|
||||
|
||||
console.log(response.content[0].text);
|
||||
```
|
||||
|
||||
### 스트리밍
|
||||
|
||||
```javascript
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
const client = new Anthropic({
|
||||
baseURL: "https://cliproxy.gru.farm/api/provider/claude",
|
||||
apiKey: "Jinie4eva!",
|
||||
});
|
||||
|
||||
const stream = client.messages.stream({
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: "user", content: "안녕!" }],
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (
|
||||
chunk.type === "content_block_delta" &&
|
||||
chunk.delta.type === "text_delta"
|
||||
) {
|
||||
process.stdout.write(chunk.delta.text);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Node.js — OpenAI SDK (호환 모드)
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
npm install openai
|
||||
```
|
||||
|
||||
### 기본 호출
|
||||
|
||||
```javascript
|
||||
import OpenAI from "openai";
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: "https://cliproxy.gru.farm/v1",
|
||||
apiKey: "Jinie4eva!",
|
||||
});
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: "안녕!" }],
|
||||
});
|
||||
|
||||
console.log(response.choices[0].message.content);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Claude Code CLI
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://cliproxy.gru.farm/api/provider/claude
|
||||
export ANTHROPIC_API_KEY=Jinie4eva!
|
||||
|
||||
claude
|
||||
```
|
||||
|
||||
영구 적용 (`~/.zshrc` 또는 `~/.bashrc`):
|
||||
|
||||
```bash
|
||||
echo 'export ANTHROPIC_BASE_URL=https://cliproxy.gru.farm/api/provider/claude' >> ~/.zshrc
|
||||
echo 'export ANTHROPIC_API_KEY=Jinie4eva!' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 환경변수로 관리
|
||||
|
||||
`.env` 파일:
|
||||
|
||||
```env
|
||||
ANTHROPIC_BASE_URL=https://cliproxy.gru.farm/api/provider/claude
|
||||
ANTHROPIC_API_KEY=Jinie4eva!
|
||||
```
|
||||
|
||||
Python에서 `.env` 사용:
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
from anthropic import Anthropic
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# base_url, api_key 자동으로 환경변수에서 읽음
|
||||
client = Anthropic()
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-6",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": "안녕!"}]
|
||||
)
|
||||
print(response.content[0].text)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **내부망 접근 시** URL을 `http://192.168.0.17:8317`로 변경
|
||||
- **OpenAI 호환 모드**는 `/v1/chat/completions`를 사용하지만, Claude 네이티브 기능(extended thinking 등)은 `/api/provider/claude/v1/messages` 사용 권장
|
||||
- **타임아웃** 설정: 긴 응답의 경우 클라이언트 타임아웃을 600초 이상으로 설정
|
||||
@@ -0,0 +1,233 @@
|
||||
# CLIProxyAPI Docker 배포 가이드
|
||||
|
||||
NAS(nas.gru.farm)에 Docker로 CLIProxyAPI를 배포하는 방법을 정리합니다.
|
||||
|
||||
## 사전 조건
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| NAS 접속 | `ssh airkjw@nas.gru.farm -p 22` |
|
||||
| Docker | `sudo /usr/local/bin/docker` (NOPASSWD) |
|
||||
| Docker Compose | `sudo /usr/local/bin/docker compose` |
|
||||
| NAS 내부 IP | 192.168.0.17 |
|
||||
|
||||
## 1. 배포 디렉토리 준비
|
||||
|
||||
```bash
|
||||
ssh airkjw@nas.gru.farm
|
||||
|
||||
# 배포 디렉토리 생성
|
||||
mkdir -p ~/docker/cli-proxy-api
|
||||
cd ~/docker/cli-proxy-api
|
||||
```
|
||||
|
||||
## 2. 필요 파일 구성
|
||||
|
||||
NAS에 아래 파일들이 필요합니다:
|
||||
|
||||
```
|
||||
~/docker/cli-proxy-api/
|
||||
├── docker-compose.yml # 컨테이너 설정
|
||||
├── config.yaml # 서비스 설정 (API 키, 포트 등)
|
||||
├── auths/ # OAuth 인증 데이터 (자동 생성)
|
||||
└── logs/ # 로그 디렉토리 (자동 생성)
|
||||
```
|
||||
|
||||
## 3. docker-compose.yml
|
||||
|
||||
로컬 빌드 방식 (소스에서 직접 빌드):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cli-proxy-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: cli-proxy-api
|
||||
ports:
|
||||
- "8317:8317" # 메인 API 포트
|
||||
# 필요시 추가 포트 오픈
|
||||
# - "8085:8085"
|
||||
volumes:
|
||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
||||
- ./auths:/root/.cli-proxy-api
|
||||
- ./logs:/CLIProxyAPI/logs
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
또는 공식 이미지 사용:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cli-proxy-api:
|
||||
image: eceasy/cli-proxy-api:latest
|
||||
container_name: cli-proxy-api
|
||||
ports:
|
||||
- "8317:8317"
|
||||
volumes:
|
||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
||||
- ./auths:/root/.cli-proxy-api
|
||||
- ./logs:/CLIProxyAPI/logs
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## 4. config.yaml 설정
|
||||
|
||||
`config.example.yaml`을 기반으로 작성합니다.
|
||||
|
||||
### 최소 설정 예시
|
||||
|
||||
```yaml
|
||||
# 서버 바인딩
|
||||
host: ""
|
||||
port: 8317
|
||||
|
||||
# API 키 (클라이언트 인증용, 원하는 값으로 설정)
|
||||
api-keys:
|
||||
- "my-secret-api-key-1"
|
||||
|
||||
# 디버그 (초기 설정 시 true 권장, 안정화 후 false)
|
||||
debug: false
|
||||
|
||||
# 로그를 파일로 기록
|
||||
logging-to-file: true
|
||||
logs-max-total-size-mb: 100
|
||||
|
||||
# 재시도 설정
|
||||
request-retry: 3
|
||||
```
|
||||
|
||||
### Claude API 키 사용 시 추가
|
||||
|
||||
```yaml
|
||||
claude-api-key:
|
||||
- api-key: "sk-ant-xxxxx"
|
||||
# base-url: "https://api.anthropic.com" # 기본값이므로 생략 가능
|
||||
```
|
||||
|
||||
### Gemini API 키 사용 시 추가
|
||||
|
||||
```yaml
|
||||
gemini-api-key:
|
||||
- api-key: "AIzaSy..."
|
||||
```
|
||||
|
||||
### Management UI 활성화 (웹 관리 패널)
|
||||
|
||||
```yaml
|
||||
remote-management:
|
||||
allow-remote: true
|
||||
secret-key: "my-management-password"
|
||||
disable-control-panel: false
|
||||
```
|
||||
|
||||
## 5. 배포 실행
|
||||
|
||||
```bash
|
||||
cd ~/docker/cli-proxy-api
|
||||
|
||||
# 공식 이미지 사용 시
|
||||
sudo /usr/local/bin/docker compose up -d
|
||||
|
||||
# 소스 빌드 시 (Gitea에서 소스 가져와서)
|
||||
git clone http://nas.gru.farm:3001/airkjw/CLIProxyAPI.git src
|
||||
sudo /usr/local/bin/docker compose -f src/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
## 6. 확인
|
||||
|
||||
```bash
|
||||
# 컨테이너 상태 확인
|
||||
sudo /usr/local/bin/docker ps | grep cli-proxy-api
|
||||
|
||||
# 로그 확인
|
||||
sudo /usr/local/bin/docker logs cli-proxy-api
|
||||
|
||||
# API 응답 테스트
|
||||
curl http://localhost:8317/
|
||||
curl http://192.168.0.17:8317/
|
||||
|
||||
# 모델 목록 확인 (API 키 인증)
|
||||
curl -H "Authorization: Bearer my-secret-api-key-1" http://localhost:8317/v1/models
|
||||
```
|
||||
|
||||
## 7. 클라이언트 연결
|
||||
|
||||
CLIProxyAPI가 실행되면 각 AI CLI 도구에서 프록시 주소로 연결합니다.
|
||||
|
||||
### Claude Code에서 사용
|
||||
|
||||
```bash
|
||||
# 환경변수 설정
|
||||
export ANTHROPIC_BASE_URL=http://192.168.0.17:8317
|
||||
export ANTHROPIC_API_KEY=my-secret-api-key-1
|
||||
```
|
||||
|
||||
### OpenAI 호환 클라이언트에서 사용
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL=http://192.168.0.17:8317/v1
|
||||
export OPENAI_API_KEY=my-secret-api-key-1
|
||||
```
|
||||
|
||||
## 8. 관리 & 운영
|
||||
|
||||
```bash
|
||||
# 컨테이너 중지
|
||||
sudo /usr/local/bin/docker compose down
|
||||
|
||||
# 설정 변경 후 재시작
|
||||
sudo /usr/local/bin/docker compose restart
|
||||
|
||||
# 이미지 업데이트 (공식 이미지 사용 시)
|
||||
sudo /usr/local/bin/docker compose pull
|
||||
sudo /usr/local/bin/docker compose up -d
|
||||
|
||||
# 로그 실시간 모니터링
|
||||
sudo /usr/local/bin/docker logs -f cli-proxy-api
|
||||
```
|
||||
|
||||
## 포트 목록
|
||||
|
||||
| 포트 | 용도 | 필수 여부 |
|
||||
|------|------|-----------|
|
||||
| 8317 | 메인 API | 필수 |
|
||||
| 8085 | 추가 API | 선택 |
|
||||
| 1455 | 추가 서비스 | 선택 |
|
||||
| 54545 | 추가 서비스 | 선택 |
|
||||
| 51121 | 추가 서비스 | 선택 |
|
||||
| 11451 | 추가 서비스 | 선택 |
|
||||
|
||||
> 기본적으로 8317 포트만 열면 됩니다. 나머지는 특정 기능 사용 시 필요합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
- `config.yaml`은 `.gitignore`에 포함되어 있어 Git에 커밋되지 않음 (API 키 보호)
|
||||
- OAuth 인증(Claude, Gemini 등)은 최초 1회 브라우저 로그인 필요
|
||||
- `auths/` 디렉토리를 볼륨으로 마운트하면 컨테이너 재생성 시에도 인증 유지
|
||||
- NAS 외부 접근 시 방화벽/포트포워딩 설정 필요
|
||||
|
||||
## 업데이트 이력
|
||||
|
||||
| 날짜 | 버전 | 비고 |
|
||||
|------|------|------|
|
||||
| 2026-05-18 | v7.1.10 | 메이저 v6→v7 — Home Control Plane(Redis) 신설, ClaudeCodeSessionAffinity 제거, Usage tracking 제거(v6.10.0), xAI Grok 이미지/비디오, Codex client models, Local mgmt password validation + spoofed IP rejection. Auth 파일 호환(재인증 불필요), config 신규 필드 모두 옵션 |
|
||||
| 2026-05-04 | v6.10.4 | 69개 커밋 변경 — WebSocket compact 처리 개선, X-Amp-Thread-Id 기반 session affinity, Codex reasoning/이미지 처리 강화, GPT-5.5 모델 추가, OpenAI 호환 provider 비활성화 옵션. 무중단 업데이트, 재인증 불필요 |
|
||||
| 2026-04-26 | v6.9.38 | Protocol multiplexer + Redis queue 도입, 관리키/Redis AUTH 반복 실패 시 IP 차단 추가. 무중단 업데이트, 재인증 불필요 |
|
||||
| 2026-04-23 | v6.9.34 | `docker compose pull && docker compose up -d`로 무중단 업데이트. Auth 파일 형식 변경 없어 재인증 불필요 |
|
||||
| 2026-04-01 | v6.9.7 | 최초 배포 |
|
||||
|
||||
### 업데이트 절차
|
||||
|
||||
```bash
|
||||
ssh airkjw@nas.gru.farm
|
||||
cd /volume2/docker/CLIProxyAPI
|
||||
sudo /usr/local/bin/docker compose pull
|
||||
sudo /usr/local/bin/docker compose up -d
|
||||
```
|
||||
|
||||
`auths/` 볼륨이 외부에 마운트되어 있어 컨테이너 교체 시 OAuth 토큰이 유지됩니다.
|
||||
+2
-2
@@ -14,7 +14,7 @@ ARG BUILD_DATE=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
|
||||
|
||||
FROM alpine:3.22.0
|
||||
FROM alpine:3.23
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
@@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
|
||||
|
||||
CMD ["./CLIProxyAPI"]
|
||||
CMD ["./CLIProxyAPI"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# CLI Proxy API
|
||||
|
||||
English | [中文](README_CN.md)
|
||||
English | [中文](README_CN.md) | [日本語](README_JA.md)
|
||||
|
||||
A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
|
||||
A proxy server that provides OpenAI/Gemini/Claude/Codex/Grok compatible API interfaces for CLI.
|
||||
|
||||
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
|
||||
|
||||
@@ -10,49 +10,53 @@ So you can use local or multi-account CLI access with OpenAI(include Responses)/
|
||||
|
||||
## Sponsor
|
||||
|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
[](https://www.packyapi.com/register?aff=cliproxyapi)
|
||||
|
||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
||||
Thanks to PackyCode for sponsoring this project!
|
||||
|
||||
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.7 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
||||
PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more.
|
||||
|
||||
Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
|
||||
PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via <a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
|
||||
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of <b>10% of the official GPT subscription price (90% OFF)</b>!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://coder.visioncoder.cn"><img src="./assets/visioncoder.png" alt="VisionCoder" width="150"></a></td>
|
||||
<td>Thanks to VisionCoder for supporting this project. <a href="https://coder.visioncoder.cn" target="_blank">VisionCoder Developer Platform</a> is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity.
|
||||
<p></p>
|
||||
VisionCoder is also offering our users a limited-time <a href="https://coder.visioncoder.cn" target="_blank">Token Plan</a> promotion: buy 1 month and get 1 month free.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
|
||||
- OpenAI/Gemini/Claude/Grok compatible API endpoints for CLI models
|
||||
- OpenAI Codex support (GPT models) via OAuth login
|
||||
- Claude Code support via OAuth login
|
||||
- Qwen Code support via OAuth login
|
||||
- iFlow support via OAuth login
|
||||
- Grok Build support via OAuth login
|
||||
- Amp CLI and IDE extensions support with provider routing
|
||||
- Streaming and non-streaming responses
|
||||
- Streaming, non-streaming, and WebSocket responses where supported
|
||||
- Function calling/tools support
|
||||
- Multimodal input support (text and images)
|
||||
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
|
||||
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
|
||||
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Grok)
|
||||
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Grok)
|
||||
- Generative Language API Key support
|
||||
- AI Studio Build multi-account load balancing
|
||||
- Gemini CLI multi-account load balancing
|
||||
- Claude Code multi-account load balancing
|
||||
- Qwen Code multi-account load balancing
|
||||
- iFlow multi-account load balancing
|
||||
- OpenAI Codex multi-account load balancing
|
||||
- Grok Build multi-account load balancing
|
||||
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
||||
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
|
||||
|
||||
@@ -64,6 +68,22 @@ CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
|
||||
|
||||
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
|
||||
|
||||
## Usage Statistics
|
||||
|
||||
Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) no longer ship built-in usage statistics. If you need usage statistics, use:
|
||||
|
||||
### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
|
||||
|
||||
Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics.
|
||||
|
||||
### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
|
||||
|
||||
Local-first usage and quota dashboard for CLIProxyAPI. It collects per-request token usage from the Redis-compatible usage queue into SQLite, visualizes daily and recent-window usage by account and model, and shows Codex 5h/7d quota remaining in a local web UI.
|
||||
|
||||
### [CPA-Manager](https://github.com/seakee/CPA-Manager)
|
||||
|
||||
Full CLIProxyAPI management center with request-level monitoring and cost estimates. CPA-Manager tracks collected requests by account, model, channel, latency, status, and token usage; estimates cost with editable model prices and one-click LiteLLM price sync; persists events in SQLite; and provides Codex account-pool operations with batch inspection, quota detection, unhealthy account discovery, cleanup suggestions, and one-click execution for day-to-day multi-account maintenance.
|
||||
|
||||
## Amp CLI Support
|
||||
|
||||
CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
|
||||
@@ -74,6 +94,14 @@ CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and A
|
||||
- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5` → `claude-sonnet-4`)
|
||||
- Security-first design with localhost-only management endpoints
|
||||
|
||||
When you need the request/response shape of a specific backend family, use the provider-specific paths instead of the merged `/v1/...` endpoints:
|
||||
|
||||
- Use `/api/provider/{provider}/v1/messages` for messages-style backends.
|
||||
- Use `/api/provider/{provider}/v1beta/models/...` for model-scoped generate endpoints.
|
||||
- Use `/api/provider/{provider}/v1/chat/completions` for chat-completions backends.
|
||||
|
||||
These routes help you select the protocol surface, but they do not by themselves guarantee a unique inference executor when the same client-visible model name is reused across multiple backends. Inference routing is still resolved from the request model/alias. For strict backend pinning, use unique aliases, prefixes, or otherwise avoid overlapping client-visible model names.
|
||||
|
||||
**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
|
||||
|
||||
## SDK Docs
|
||||
@@ -104,23 +132,19 @@ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with A
|
||||
|
||||
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
||||
|
||||
Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
|
||||
A cross-platform desktop and web app to translate and validate SRT subtitles using your existing LLM subscriptions (Gemini, ChatGPT, Claude, etc.) via CLIProxyAPI - no API keys needed.
|
||||
|
||||
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
||||
|
||||
CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
|
||||
|
||||
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
||||
|
||||
Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed.
|
||||
|
||||
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
||||
|
||||
Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
|
||||
Native macOS menu bar app that unifies Claude, Gemini, OpenAI, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed.
|
||||
|
||||
### [CodMate](https://github.com/loocor/CodMate)
|
||||
|
||||
Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
|
||||
Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, and Antigravity, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers.
|
||||
|
||||
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
||||
|
||||
@@ -144,12 +168,39 @@ A Windows tray application implemented using PowerShell scripts, without relying
|
||||
|
||||
### [霖君](https://github.com/wangdabaoqq/LinJun)
|
||||
|
||||
霖君 is a cross-platform desktop application for managing AI programming assistants, supporting macOS, Windows, and Linux systems. Unified management of Claude Code, Gemini CLI, OpenAI Codex, Qwen Code, and other AI coding tools, with local proxy for multi-account quota tracking and one-click configuration.
|
||||
霖君 is a cross-platform desktop application for managing AI programming assistants, supporting macOS, Windows, and Linux systems. Unified management of Claude Code, Gemini CLI, OpenAI Codex, and other AI coding tools, with local proxy for multi-account quota tracking and one-click configuration.
|
||||
|
||||
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
|
||||
|
||||
A modern web-based management dashboard for CLIProxyAPI built with Next.js, React, and PostgreSQL. Features real-time log streaming, structured configuration editing, API key management, OAuth provider integration for Claude/Gemini/Codex, usage analytics, container management, and config sync with OpenCode via companion plugin - no manual YAML editing needed.
|
||||
|
||||
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
|
||||
|
||||
Browser extension for one-stop management of New API-compatible relay site accounts, featuring balance and usage dashboards, auto check-in, one-click key export to common apps, in-page API availability testing, and channel/model sync and redirection. It integrates with CLIProxyAPI through the Management API for one-click provider import and config sync.
|
||||
|
||||
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
|
||||
|
||||
Shadow AI is an AI assistant tool designed specifically for restricted environments. It provides a stealthy operation
|
||||
mode without windows or traces, and enables cross-device AI Q&A interaction and control via the local area network (
|
||||
LAN). Essentially, it is an automated collaboration layer of "screen/audio capture + AI inference + low-friction delivery",
|
||||
helping users to immersively use AI assistants across applications on controlled devices or in restricted environments.
|
||||
|
||||
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
|
||||
|
||||
Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a native GUI. Connects Claude, ChatGPT, Gemini, GitHub Copilot, and custom OpenAI-compatible endpoints with usage analytics, request monitoring, and auto-configuration for popular coding tools - no API keys needed.
|
||||
|
||||
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
|
||||
|
||||
Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-account codex 5h/7d quota windows, plan-based sorting, status coloring, and multi-account summary analytics.
|
||||
|
||||
### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
|
||||
|
||||
Windows-focused, local-first desktop management platform for Codex CLI built on CLIProxyAPI, focused on simplifying local setup, account and runtime management, and providing a more complete Codex CLI experience for local users.
|
||||
|
||||
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
|
||||
|
||||
Native macOS SwiftUI app for monitoring ChatGPT/Codex account quotas in CLIProxyAPI pools. Displays account availability, Plus-base capacity, 5-hour and weekly quota bars, plan weights, and restore forecasts through the Management API.
|
||||
|
||||
> [!NOTE]
|
||||
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
||||
|
||||
@@ -167,6 +218,10 @@ Never stop coding. Smart routing to FREE & low-cost AI models with automatic fal
|
||||
|
||||
OmniRoute is an AI gateway for multi-provider LLMs: an OpenAI-compatible endpoint with smart routing, load balancing, retries, and fallbacks. Add policies, rate limits, caching, and observability for reliable, cost-aware inference.
|
||||
|
||||
### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
|
||||
|
||||
A public CLIProxyAPI-compatible fork and bundled management panel. It keeps upstream-style usage while restoring built-in usage statistics, adding cache hit rate, first-byte latency, TPS tracking, and Docker-oriented self-hosted installation docs.
|
||||
|
||||
> [!NOTE]
|
||||
> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
|
||||
|
||||
|
||||
+79
-27
@@ -1,8 +1,8 @@
|
||||
# CLI 代理 API
|
||||
|
||||
[English](README.md) | 中文
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md)
|
||||
|
||||
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
|
||||
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex/Grok 兼容 API 接口的代理服务器。
|
||||
|
||||
现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
|
||||
|
||||
@@ -10,25 +10,31 @@
|
||||
|
||||
## 赞助商
|
||||
|
||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||
[](https://www.packyapi.com/register?aff=cliproxyapi)
|
||||
|
||||
本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
|
||||
感谢 PackyCode 对本项目的赞助!
|
||||
|
||||
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7,为开发者提供顶尖的编码体验。
|
||||
PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。
|
||||
|
||||
智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
|
||||
PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi" target="_blank">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cliproxyapi"><img src="./assets/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF" target="_blank">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
|
||||
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github" target="_blank">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT <b>官网订阅一折</b> 的震撼价格!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://coder.visioncoder.cn"><img src="./assets/visioncoder.png" alt="VisionCoder" width="150"></a></td>
|
||||
<td>感谢 VisionCoder 对本项目的支持。<a href="https://coder.visioncoder.cn" target="_blank">VisionCoder 开发平台</a> 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。
|
||||
<p></p>
|
||||
VisionCoder 还为我们的用户提供 <a href="https://coder.visioncoder.cn" target="_blank">Token Plan</a> 限时活动:购买 1 个月,赠送 1 个月。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -36,23 +42,21 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
||||
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex/Grok 兼容的 API 端点
|
||||
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
||||
- 新增 Claude Code 支持(OAuth 登录)
|
||||
- 新增 Qwen Code 支持(OAuth 登录)
|
||||
- 新增 iFlow 支持(OAuth 登录)
|
||||
- 支持流式与非流式响应
|
||||
- 新增 Grok Build 支持(OAuth 登录)
|
||||
- 支持流式、非流式响应,以及受支持场景下的 WebSocket 响应
|
||||
- 函数调用/工具支持
|
||||
- 多模态输入(文本、图片)
|
||||
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
||||
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
||||
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Grok)
|
||||
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Grok)
|
||||
- 支持 Gemini AIStudio API 密钥
|
||||
- 支持 AI Studio Build 多账户轮询
|
||||
- 支持 Gemini CLI 多账户轮询
|
||||
- 支持 Claude Code 多账户轮询
|
||||
- 支持 Qwen Code 多账户轮询
|
||||
- 支持 iFlow 多账户轮询
|
||||
- 支持 OpenAI Codex 多账户轮询
|
||||
- 支持 Grok Build 多账户轮询
|
||||
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
||||
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
|
||||
|
||||
@@ -64,6 +68,22 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo
|
||||
|
||||
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
|
||||
|
||||
## 使用量统计
|
||||
|
||||
自v6.10.0版本以后,CLIProxyAPI及 [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 项目不再预置数据统计功能,如果有数据统计需求的请使用以下项目:
|
||||
|
||||
### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
|
||||
|
||||
独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。
|
||||
|
||||
### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
|
||||
|
||||
面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 Token 消耗并写入 SQLite,按账号和模型可视化每日及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。
|
||||
|
||||
### [CPA-Manager](https://github.com/seakee/CPA-Manager)
|
||||
|
||||
面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。
|
||||
|
||||
## Amp CLI 支持
|
||||
|
||||
CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
|
||||
@@ -73,6 +93,14 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支
|
||||
- 智能模型回退与自动路由
|
||||
- 以安全为先的设计,管理端点仅限 localhost
|
||||
|
||||
当你需要某一类后端的请求/响应协议形态时,优先使用 provider-specific 路径,而不是合并后的 `/v1/...` 端点:
|
||||
|
||||
- 对于 messages 风格的后端,使用 `/api/provider/{provider}/v1/messages`。
|
||||
- 对于按模型路径暴露生成接口的后端,使用 `/api/provider/{provider}/v1beta/models/...`。
|
||||
- 对于 chat-completions 风格的后端,使用 `/api/provider/{provider}/v1/chat/completions`。
|
||||
|
||||
这些路径有助于选择协议表面,但当多个后端复用同一个客户端可见模型名时,它们本身并不能保证唯一的推理执行器。实际的推理路由仍然根据请求里的 model/alias 解析。若要严格钉住某个后端,请使用唯一 alias、前缀,或避免让多个后端暴露相同的客户端模型名。
|
||||
|
||||
**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
|
||||
|
||||
## SDK 文档
|
||||
@@ -103,23 +131,19 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支
|
||||
|
||||
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
||||
|
||||
一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
|
||||
一款跨平台的桌面和 Web 应用程序,可通过 CLIProxyAPI 使用您现有的 LLM 订阅(Gemini、ChatGPT、Claude, etc.)来翻译和验证 SRT 字幕 - 无需 API 密钥。
|
||||
|
||||
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
||||
|
||||
CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
|
||||
|
||||
### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal)
|
||||
|
||||
基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。
|
||||
|
||||
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
||||
|
||||
原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
|
||||
原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。
|
||||
|
||||
### [CodMate](https://github.com/loocor/CodMate)
|
||||
|
||||
原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
|
||||
原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini 和 Antigravity 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。
|
||||
|
||||
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
||||
|
||||
@@ -143,12 +167,36 @@ Windows 托盘应用,基于 PowerShell 脚本实现,不依赖任何第三方
|
||||
|
||||
### [霖君](https://github.com/wangdabaoqq/LinJun)
|
||||
|
||||
霖君是一款用于管理AI编程助手的跨平台桌面应用,支持macOS、Windows、Linux系统。统一管理Claude Code、Gemini CLI、OpenAI Codex、Qwen Code等AI编程工具,本地代理实现多账户配额跟踪和一键配置。
|
||||
霖君是一款用于管理AI编程助手的跨平台桌面应用,支持macOS、Windows、Linux系统。统一管理Claude Code、Gemini CLI、OpenAI Codex等AI编程工具,本地代理实现多账户配额跟踪和一键配置。
|
||||
|
||||
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
|
||||
|
||||
一个面向 CLIProxyAPI 的现代化 Web 管理仪表盘,基于 Next.js、React 和 PostgreSQL 构建。支持实时日志流、结构化配置编辑、API Key 管理、Claude/Gemini/Codex 的 OAuth 提供方集成、使用量分析、容器管理,并可通过配套插件与 OpenCode 同步配置,无需手动编辑 YAML。
|
||||
|
||||
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
|
||||
|
||||
用于一站式管理 New API 兼容中转站账号的浏览器扩展,提供余额与用量看板、自动签到、密钥一键导出到常用应用、网页内 API 可用性测试,以及渠道与模型同步和重定向。支持通过 CLIProxyAPI Management API 一键导入 Provider 与同步配置。
|
||||
|
||||
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
|
||||
|
||||
Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口、无痕迹的隐蔽运行方式,并通过局域网实现跨设备的 AI 问答交互与控制。本质上是一个「屏幕/音频采集 + AI 推理 + 低摩擦投送」的自动化协作层,帮助用户在受控设备/受限环境下沉浸式跨应用地使用 AI 助手。
|
||||
|
||||
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
|
||||
|
||||
跨平台桌面应用(macOS、Windows、Linux),以原生 GUI 封装 CLIProxyAPI。支持连接 Claude、ChatGPT、Gemini、GitHub Copilot 及自定义 OpenAI 兼容端点,具备使用分析、请求监控和热门编程工具自动配置功能,无需 API 密钥。
|
||||
|
||||
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
|
||||
|
||||
上手即用的面向 CLIProxyAPI 跨平台配额查询工具,支持按账号展示 codex 5h/7d 配额窗口、按计划排序、状态着色及多账号汇总分析。
|
||||
|
||||
### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
|
||||
|
||||
基于 CLIProxyAPI 的 Windows Codex CLI 本地优先桌面管理平台,聚焦简化本机配置、账号与运行状态管理,并为本地用户提供更完整的 Codex CLI 使用体验。
|
||||
|
||||
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
|
||||
|
||||
原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
||||
|
||||
@@ -166,6 +214,10 @@ Windows 托盘应用,基于 PowerShell 脚本实现,不依赖任何第三方
|
||||
|
||||
OmniRoute 是一个面向多供应商大语言模型的 AI 网关:它提供兼容 OpenAI 的端点,具备智能路由、负载均衡、重试及回退机制。通过添加策略、速率限制、缓存和可观测性,确保推理过程既可靠又具备成本意识。
|
||||
|
||||
### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
|
||||
|
||||
一个公开的 CLIProxyAPI 兼容二开版本和配套管理面板,尽量保持与上游一致的使用方式,同时恢复内置使用量统计,并补充缓存命中率、首字响应时间、TPS 记录和面向 Docker 自托管的安装说明。
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
|
||||
|
||||
@@ -175,7 +227,7 @@ OmniRoute 是一个面向多供应商大语言模型的 AI 网关:它提供兼
|
||||
|
||||
## 写给所有中国网友的
|
||||
|
||||
QQ 群:188637136
|
||||
QQ 群:188637136(满)、1081218164
|
||||
|
||||
或
|
||||
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
# CLI Proxy API
|
||||
|
||||
[English](README.md) | [中文](README_CN.md) | 日本語
|
||||
|
||||
CLI向けのOpenAI/Gemini/Claude/Codex/Grok互換APIインターフェースを提供するプロキシサーバーです。
|
||||
|
||||
OAuth経由でOpenAI Codex(GPTモデル)およびClaude Codeもサポートしています。
|
||||
|
||||
ローカルまたはマルチアカウントのCLIアクセスを、OpenAI(Responses含む)/Gemini/Claude互換のクライアントやSDKで利用できます。
|
||||
|
||||
## スポンサー
|
||||
|
||||
[](https://www.packyapi.com/register?aff=cliproxyapi)
|
||||
|
||||
PackyCodeのスポンサーシップに感謝します!
|
||||
|
||||
PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。
|
||||
|
||||
PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:<a href="https://www.packyapi.com/register?aff=cliproxyapi">こちらのリンク</a>から登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">こちらのリンク</a>から登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
|
||||
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを <b>公式サイト価格の約1割(90% OFF)</b> という驚異的な価格でご利用いただけます!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://coder.visioncoder.cn"><img src="./assets/visioncoder.png" alt="VisionCoder" width="150"></a></td>
|
||||
<td>VisionCoderのご支援に感謝します!<a href="https://coder.visioncoder.cn">VisionCoder 開発プラットフォーム</a> は、信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどの主要AIモデルを提供し、開発者やチームがより簡単にAI機能を統合して生産性を向上できるよう支援します。さらに、VisionCoderはユーザー向けに <a href="https://coder.visioncoder.cn">Token Plan</a> の期間限定キャンペーン(1か月購入で1か月分プレゼント)も提供しています。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
- CLIモデル向けのOpenAI/Gemini/Claude/Grok互換APIエンドポイント
|
||||
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
|
||||
- OAuthログインによるClaude Codeサポート
|
||||
- OAuthログインによるGrok Buildサポート
|
||||
- プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート
|
||||
- ストリーミング、非ストリーミング、および対応環境でのWebSocketレスポンス
|
||||
- 関数呼び出し/ツールのサポート
|
||||
- マルチモーダル入力サポート(テキストと画像)
|
||||
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude、Grok)
|
||||
- シンプルなCLI認証フロー(Gemini、OpenAI、Claude、Grok)
|
||||
- Generative Language APIキーのサポート
|
||||
- AI Studioビルドのマルチアカウント負荷分散
|
||||
- Gemini CLIのマルチアカウント負荷分散
|
||||
- Claude Codeのマルチアカウント負荷分散
|
||||
- OpenAI Codexのマルチアカウント負荷分散
|
||||
- Grok Buildのマルチアカウント負荷分散
|
||||
- 設定によるOpenAI互換アップストリームプロバイダー(例:OpenRouter)
|
||||
- プロキシ埋め込み用の再利用可能なGo SDK(`docs/sdk-usage.md`を参照)
|
||||
|
||||
## はじめに
|
||||
|
||||
CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
|
||||
|
||||
## 管理API
|
||||
|
||||
[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
|
||||
|
||||
## 使用量統計
|
||||
|
||||
v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) プロジェクトには使用量統計機能がプリセットされなくなりました。使用量統計が必要な場合は、次のプロジェクトをご利用ください:
|
||||
|
||||
### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
|
||||
|
||||
CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。
|
||||
|
||||
### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
|
||||
|
||||
CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのToken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量を可視化し、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。
|
||||
|
||||
### [CPA-Manager](https://github.com/seakee/CPA-Manager)
|
||||
|
||||
リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。
|
||||
|
||||
## Amp CLIサポート
|
||||
|
||||
CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます:
|
||||
|
||||
- Ampの APIパターン用のプロバイダールートエイリアス(`/api/provider/{provider}/v1...`)
|
||||
- OAuth認証およびアカウント機能用の管理プロキシ
|
||||
- 自動ルーティングによるスマートモデルフォールバック
|
||||
- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
|
||||
- localhostのみの管理エンドポイントによるセキュリティファーストの設計
|
||||
|
||||
特定のバックエンド系統のリクエスト/レスポンス形状が必要な場合は、統合された `/v1/...` エンドポイントよりも provider-specific のパスを優先してください。
|
||||
|
||||
- messages 系のバックエンドには `/api/provider/{provider}/v1/messages`
|
||||
- モデル単位の generate 系エンドポイントには `/api/provider/{provider}/v1beta/models/...`
|
||||
- chat-completions 系のバックエンドには `/api/provider/{provider}/v1/chat/completions`
|
||||
|
||||
これらのパスはプロトコル面の選択には役立ちますが、同じクライアント向けモデル名が複数バックエンドで再利用されている場合、それだけで推論実行系が一意に固定されるわけではありません。実際の推論ルーティングは、引き続きリクエスト内の model/alias 解決に従います。厳密にバックエンドを固定したい場合は、一意な alias や prefix を使うか、クライアント向けモデル名の重複自体を避けてください。
|
||||
|
||||
**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/agent-client/amp-cli.html)**
|
||||
|
||||
## SDKドキュメント
|
||||
|
||||
- 使い方:[docs/sdk-usage.md](docs/sdk-usage.md)
|
||||
- 上級(エグゼキューターとトランスレーター):[docs/sdk-advanced.md](docs/sdk-advanced.md)
|
||||
- アクセス:[docs/sdk-access.md](docs/sdk-access.md)
|
||||
- ウォッチャー:[docs/sdk-watcher.md](docs/sdk-watcher.md)
|
||||
- カスタムプロバイダーの例:`examples/custom-provider`
|
||||
|
||||
## コントリビューション
|
||||
|
||||
コントリビューションを歓迎します!お気軽にPull Requestを送ってください。
|
||||
|
||||
1. リポジトリをフォーク
|
||||
2. フィーチャーブランチを作成(`git checkout -b feature/amazing-feature`)
|
||||
3. 変更をコミット(`git commit -m 'Add some amazing feature'`)
|
||||
4. ブランチにプッシュ(`git push origin feature/amazing-feature`)
|
||||
5. Pull Requestを作成
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
CLIProxyAPIをベースにした以下のプロジェクトがあります:
|
||||
|
||||
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
||||
|
||||
macOSネイティブのメニューバーアプリで、Claude CodeとChatGPTのサブスクリプションをAIコーディングツールで使用可能 - APIキー不要
|
||||
|
||||
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
|
||||
|
||||
CLIProxyAPI経由で既存のLLMサブスクリプション(Gemini、ChatGPT、Claude, etc.)を使用してSRT字幕を翻訳および検証する、クロスプラットフォームのデスクトップおよびWebアプリ - APIキー不要。
|
||||
|
||||
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
||||
|
||||
CLIProxyAPI OAuthを使用して複数のClaudeアカウントや代替モデル(Gemini、Codex、Antigravity)を即座に切り替えるCLIラッパー - APIキー不要
|
||||
|
||||
### [Quotio](https://github.com/nguyenphutrong/quotio)
|
||||
|
||||
Claude、Gemini、OpenAI、Antigravityのサブスクリプションを統合し、リアルタイムのクォータ追跡とスマート自動フェイルオーバーを備えたmacOSネイティブのメニューバーアプリ。Claude Code、OpenCode、Droidなどのコーディングツール向け - APIキー不要
|
||||
|
||||
### [CodMate](https://github.com/loocor/CodMate)
|
||||
|
||||
CLI AIセッション(Codex、Claude Code、Gemini CLI)を管理するmacOS SwiftUIネイティブアプリ。統合プロバイダー管理、Gitレビュー、プロジェクト整理、グローバル検索、ターミナル統合機能を搭載。CLIProxyAPIと統合し、Codex、Claude、Gemini、AntigravityのOAuth認証を提供。単一のプロキシエンドポイントを通じた組み込みおよびサードパーティプロバイダーの再ルーティングに対応 - OAuthプロバイダーではAPIキー不要
|
||||
|
||||
### [ProxyPilot](https://github.com/Finesssee/ProxyPilot)
|
||||
|
||||
TUI、システムトレイ、マルチプロバイダーOAuthを備えたWindows向けCLIProxyAPIフォーク - AIコーディングツール用、APIキー不要
|
||||
|
||||
### [Claude Proxy VSCode](https://github.com/uzhao/claude-proxy-vscode)
|
||||
|
||||
Claude Codeモデルを素早く切り替えるVSCode拡張機能。バックエンドとしてCLIProxyAPIを統合し、バックグラウンドでの自動ライフサイクル管理を搭載
|
||||
|
||||
### [ZeroLimit](https://github.com/0xtbug/zero-limit)
|
||||
|
||||
CLIProxyAPIを使用してAIコーディングアシスタントのクォータを監視するTauri + React製のWindowsデスクトップアプリ。Gemini、Claude、OpenAI Codex、Antigravityアカウントの使用量をリアルタイムダッシュボード、システムトレイ統合、ワンクリックプロキシコントロールで追跡 - APIキー不要
|
||||
|
||||
### [CPA-XXX Panel](https://github.com/ferretgeek/CPA-X)
|
||||
|
||||
CLIProxyAPI向けの軽量Web管理パネル。ヘルスチェック、リソース監視、リアルタイムログ、自動更新、リクエスト統計、料金表示機能を搭載。ワンクリックインストールとsystemdサービスに対応
|
||||
|
||||
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
|
||||
|
||||
PowerShellスクリプトで実装されたWindowsトレイアプリケーション。サードパーティライブラリに依存せず、ショートカットの自動作成、サイレント実行、パスワード管理、チャネル切り替え(Main / Plus)、自動ダウンロードおよび自動更新に対応
|
||||
|
||||
### [霖君](https://github.com/wangdabaoqq/LinJun)
|
||||
|
||||
霖君はAIプログラミングアシスタントを管理するクロスプラットフォームデスクトップアプリケーションで、macOS、Windows、Linuxシステムに対応。Claude Code、Gemini CLI、OpenAI Codexなどのコーディングツールを統合管理し、ローカルプロキシによるマルチアカウントクォータ追跡とワンクリック設定が可能
|
||||
|
||||
### [CLIProxyAPI Dashboard](https://github.com/itsmylife44/cliproxyapi-dashboard)
|
||||
|
||||
Next.js、React、PostgreSQLで構築されたCLIProxyAPI用のモダンなWebベース管理ダッシュボード。リアルタイムログストリーミング、構造化された設定編集、APIキー管理、Claude/Gemini/Codex向けOAuthプロバイダー統合、使用量分析、コンテナ管理、コンパニオンプラグインによるOpenCodeとの設定同期機能を搭載 - 手動でのYAML編集は不要
|
||||
|
||||
### [All API Hub](https://github.com/qixing-jk/all-api-hub)
|
||||
|
||||
New API互換リレーサイトアカウントをワンストップで管理するブラウザ拡張機能。残高と使用量のダッシュボード、自動チェックイン、一般的なアプリへのワンクリックキーエクスポート、ページ内API可用性テスト、チャネル/モデルの同期とリダイレクト機能を搭載。Management APIを通じてCLIProxyAPIと統合し、ワンクリックでプロバイダーのインポートと設定同期が可能
|
||||
|
||||
### [Shadow AI](https://github.com/HEUDavid/shadow-ai)
|
||||
|
||||
Shadow AIは制限された環境向けに特別に設計されたAIアシスタントツールです。ウィンドウや痕跡のないステルス動作モードを提供し、LAN(ローカルエリアネットワーク)を介したクロスデバイスAI質疑応答のインタラクションと制御を可能にします。本質的には「画面/音声キャプチャ + AI推論 + 低摩擦デリバリー」の自動化コラボレーションレイヤーであり、制御されたデバイスや制限された環境でアプリケーション横断的にAIアシスタントを没入的に使用できるようユーザーを支援します。
|
||||
|
||||
### [ProxyPal](https://github.com/buddingnewinsights/proxypal)
|
||||
|
||||
CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォームデスクトップアプリ(macOS、Windows、Linux)。Claude、ChatGPT、Gemini、GitHub Copilot、カスタムOpenAI互換エンドポイントに対応し、使用状況分析、リクエスト監視、人気コーディングツールの自動設定機能を搭載 - APIキー不要
|
||||
|
||||
### [CLIProxyAPI Quota Inspector](https://github.com/AllenReder/CLIProxyAPI-Quota-Inspector)
|
||||
|
||||
CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォータ確認ツール。アカウントごとの codex 5h/7d クォータ表示、プラン別ソート、ステータス色分け、複数アカウントの集計分析に対応。
|
||||
|
||||
### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
|
||||
|
||||
CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデスクトップ管理プラットフォーム。ローカル設定、アカウント、実行状態の管理を簡素化し、ローカルユーザーにより包括的なCodex CLI体験を提供します。
|
||||
|
||||
### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
|
||||
|
||||
CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。
|
||||
|
||||
> [!NOTE]
|
||||
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
|
||||
|
||||
## その他の選択肢
|
||||
|
||||
以下のプロジェクトはCLIProxyAPIの移植版またはそれに触発されたものです:
|
||||
|
||||
### [9Router](https://github.com/decolua/9router)
|
||||
|
||||
CLIProxyAPIに触発されたNext.js実装。インストールと使用が簡単で、フォーマット変換(OpenAI/Claude/Gemini/Ollama)、自動フォールバック付きコンボシステム、指数バックオフ付きマルチアカウント管理、Next.js Webダッシュボード、CLIツール(Cursor、Claude Code、Cline、RooCode)のサポートをゼロから構築 - APIキー不要
|
||||
|
||||
### [OmniRoute](https://github.com/diegosouzapw/OmniRoute)
|
||||
|
||||
コーディングを止めない。無料および低コストのAIモデルへのスマートルーティングと自動フォールバック。
|
||||
|
||||
OmniRouteはマルチプロバイダーLLM向けのAIゲートウェイです:スマートルーティング、負荷分散、リトライ、フォールバックを備えたOpenAI互換エンドポイント。ポリシー、レート制限、キャッシュ、可観測性を追加して、信頼性が高くコストを意識した推論を実現します。
|
||||
|
||||
### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
|
||||
|
||||
上流に近い使い方を維持する公開CLIProxyAPI互換フォーク兼管理パネルです。内蔵の使用量統計を復元し、キャッシュヒット率、初回バイト待ち時間、TPSの記録、Docker向けのセルフホスト手順を追加しています。
|
||||
|
||||
> [!NOTE]
|
||||
> CLIProxyAPIの移植版またはそれに触発されたプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
本プロジェクトはMITライセンスの下でライセンスされています - 詳細は[LICENSE](LICENSE)ファイルを参照してください。
|
||||
@@ -0,0 +1,104 @@
|
||||
# CLIProxyAPI 역방향 프록시 & HTTPS 설정 가이드
|
||||
|
||||
외부에서 `https://cliproxy.gru.farm`으로 CLIProxyAPI에 접근하기 위한 설정입니다.
|
||||
|
||||
## 1단계: DNS 레코드 추가
|
||||
|
||||
hostcocoa.com DNS 관리에서 A 레코드를 추가합니다.
|
||||
|
||||
| 타입 | 호스트 | 값 |
|
||||
|------|--------|-----|
|
||||
| A | cliproxy | 125.188.185.74 |
|
||||
|
||||
> 기존 `nas.gru.farm`, `haesol.gru.farm` 등과 같은 IP입니다.
|
||||
|
||||
## 2단계: Synology DSM 역방향 프록시 설정
|
||||
|
||||
1. DSM 웹 UI 접속 (보통 `https://nas.gru.farm:5001`)
|
||||
2. **제어판** → **로그인 포털** → **고급** 탭 → **역방향 프록시** 클릭
|
||||
3. **생성** 버튼 클릭
|
||||
4. 아래와 같이 입력:
|
||||
|
||||
### 일반 설정
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 설명 | `CLIProxyAPI` |
|
||||
| **소스 (프론트엔드)** | |
|
||||
| 프로토콜 | `HTTPS` |
|
||||
| 호스트 이름 | `cliproxy.gru.farm` |
|
||||
| 포트 | `443` |
|
||||
| HSTS | 비활성화 |
|
||||
| **대상 (백엔드)** | |
|
||||
| 프로토콜 | `HTTP` |
|
||||
| 호스트 이름 | `localhost` |
|
||||
| 포트 | `8317` |
|
||||
|
||||
### 사용자 지정 헤더 (선택)
|
||||
|
||||
필요 시 WebSocket 지원을 위해 사용자 지정 헤더 추가:
|
||||
- `Upgrade` → `$http_upgrade`
|
||||
- `Connection` → `$connection_upgrade`
|
||||
|
||||
### 타임아웃 설정
|
||||
|
||||
AI 요청은 응답이 오래 걸릴 수 있으므로 타임아웃을 늘려주세요:
|
||||
- 연결 타임아웃: `600`
|
||||
- 전송 타임아웃: `600`
|
||||
- 수신 타임아웃: `600`
|
||||
|
||||
5. **저장** 클릭
|
||||
|
||||
## 3단계: SSL 인증서 설정
|
||||
|
||||
Synology DSM에서 `cliproxy.gru.farm` 용 SSL 인증서를 설정합니다.
|
||||
|
||||
### Let's Encrypt 인증서 발급 (권장)
|
||||
|
||||
1. **제어판** → **보안** → **인증서** 탭
|
||||
2. **추가** → **새 인증서 추가** → **Let's Encrypt에서 인증서 가져오기**
|
||||
3. 도메인: `cliproxy.gru.farm`
|
||||
4. 이메일: 본인 이메일
|
||||
5. 발급 완료 후, **설정** 버튼 클릭
|
||||
6. `cliproxy.gru.farm` 역방향 프록시 항목에 방금 발급한 인증서 선택
|
||||
|
||||
### 기존 와일드카드 인증서가 있는 경우
|
||||
|
||||
`*.gru.farm` 와일드카드 인증서가 있다면 별도 발급 없이 해당 인증서를 선택하면 됩니다.
|
||||
|
||||
## 4단계: 공유기 포트 포워딩
|
||||
|
||||
공유기에서 443 포트가 NAS(192.168.0.17)로 포워딩되어 있는지 확인합니다.
|
||||
|
||||
> 기존 `haesol.gru.farm` 등이 HTTPS로 동작 중이라면 이미 설정되어 있을 가능성이 높습니다.
|
||||
|
||||
| 외부 포트 | 내부 IP | 내부 포트 | 프로토콜 |
|
||||
|-----------|---------|-----------|----------|
|
||||
| 443 | 192.168.0.17 | 443 | TCP |
|
||||
|
||||
## 5단계: 확인
|
||||
|
||||
```bash
|
||||
# DNS 전파 확인
|
||||
dig +short cliproxy.gru.farm
|
||||
# 125.188.185.74 가 나오면 성공
|
||||
|
||||
# HTTPS 접속 테스트
|
||||
curl https://cliproxy.gru.farm/
|
||||
# {"endpoints":[...],"message":"CLI Proxy API Server"}
|
||||
|
||||
# 모델 목록 확인
|
||||
curl -H "Authorization: Bearer Jinie4eva!" https://cliproxy.gru.farm/v1/models
|
||||
```
|
||||
|
||||
## 클라이언트 연결 (외부)
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
export ANTHROPIC_BASE_URL=https://cliproxy.gru.farm
|
||||
export ANTHROPIC_API_KEY=Jinie4eva!
|
||||
|
||||
# OpenAI 호환
|
||||
export OPENAI_BASE_URL=https://cliproxy.gru.farm/v1
|
||||
export OPENAI_API_KEY=Jinie4eva!
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,276 @@
|
||||
// Command fetch_antigravity_models connects to the Antigravity API using the
|
||||
// stored auth credentials and saves the dynamically fetched model list to a
|
||||
// JSON file for inspection or offline use.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/fetch_antigravity_models [flags]
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// --auths-dir <path> Directory containing auth JSON files (default: "auths")
|
||||
// --output <path> Output JSON file path (default: "antigravity_models.json")
|
||||
// --pretty Pretty-print the output JSON (default: true)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
|
||||
antigravitySandboxBaseURLDaily = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
antigravityBaseURLProd = "https://cloudcode-pa.googleapis.com"
|
||||
antigravityModelsPath = "/v1internal:fetchAvailableModels"
|
||||
)
|
||||
|
||||
func init() {
|
||||
logging.SetupBaseLogger()
|
||||
log.SetLevel(log.InfoLevel)
|
||||
}
|
||||
|
||||
// modelOutput wraps the fetched model list with fetch metadata.
|
||||
type modelOutput struct {
|
||||
Models []modelEntry `json:"models"`
|
||||
}
|
||||
|
||||
// modelEntry contains only the fields we want to keep for static model definitions.
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var authsDir string
|
||||
var outputPath string
|
||||
var pretty bool
|
||||
|
||||
flag.StringVar(&authsDir, "auths-dir", "auths", "Directory containing auth JSON files")
|
||||
flag.StringVar(&outputPath, "output", "antigravity_models.json", "Output JSON file path")
|
||||
flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON")
|
||||
flag.Parse()
|
||||
|
||||
// Resolve relative paths against the working directory.
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot get working directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !filepath.IsAbs(authsDir) {
|
||||
authsDir = filepath.Join(wd, authsDir)
|
||||
}
|
||||
if !filepath.IsAbs(outputPath) {
|
||||
outputPath = filepath.Join(wd, outputPath)
|
||||
}
|
||||
|
||||
fmt.Printf("Scanning auth files in: %s\n", authsDir)
|
||||
|
||||
// Load all auth records from the directory.
|
||||
fileStore := sdkauth.NewFileTokenStore()
|
||||
fileStore.SetBaseDir(authsDir)
|
||||
|
||||
ctx := context.Background()
|
||||
auths, err := fileStore.List(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to list auth files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(auths) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "error: no auth files found in %s\n", authsDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find the first enabled antigravity auth.
|
||||
var chosen *coreauth.Auth
|
||||
for _, a := range auths {
|
||||
if a == nil || a.Disabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(a.Provider), "antigravity") {
|
||||
chosen = a
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
fmt.Fprintf(os.Stderr, "error: no enabled antigravity auth found in %s\n", authsDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Using auth: id=%s label=%s\n", chosen.ID, chosen.Label)
|
||||
|
||||
// Fetch models from the upstream Antigravity API.
|
||||
fmt.Println("Fetching Antigravity model list from upstream...")
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
models := fetchModels(fetchCtx, chosen)
|
||||
if len(models) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "warning: no models returned (API may be unavailable or token expired)")
|
||||
} else {
|
||||
fmt.Printf("Fetched %d models.\n", len(models))
|
||||
}
|
||||
|
||||
// Build the output payload.
|
||||
out := modelOutput{
|
||||
Models: models,
|
||||
}
|
||||
|
||||
// Marshal to JSON.
|
||||
var raw []byte
|
||||
if pretty {
|
||||
raw, err = json.MarshalIndent(out, "", " ")
|
||||
} else {
|
||||
raw, err = json.Marshal(out)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to marshal JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = os.WriteFile(outputPath, raw, 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to write output file %s: %v\n", outputPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Model list saved to: %s\n", outputPath)
|
||||
}
|
||||
|
||||
func fetchModels(ctx context.Context, auth *coreauth.Auth) []modelEntry {
|
||||
accessToken := metaStringValue(auth.Metadata, "access_token")
|
||||
if accessToken == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: no access token found in auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURLs := []string{antigravityBaseURLProd, antigravityBaseURLDaily, antigravitySandboxBaseURLDaily}
|
||||
|
||||
for _, baseURL := range baseURLs {
|
||||
modelsURL := baseURL + antigravityModelsPath
|
||||
|
||||
var payload []byte
|
||||
if auth != nil && auth.Metadata != nil {
|
||||
if pid, ok := auth.Metadata["project_id"].(string); ok && strings.TrimSpace(pid) != "" {
|
||||
payload = []byte(fmt.Sprintf(`{"project": "%s"}`, strings.TrimSpace(pid)))
|
||||
}
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
payload = []byte(`{}`)
|
||||
}
|
||||
|
||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, modelsURL, strings.NewReader(string(payload)))
|
||||
if errReq != nil {
|
||||
continue
|
||||
}
|
||||
httpReq.Close = true
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
httpReq.Header.Set("User-Agent", misc.AntigravityUserAgent())
|
||||
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
if transport, _, errProxy := proxyutil.BuildHTTPTransport(auth.ProxyURL); errProxy == nil && transport != nil {
|
||||
httpClient.Transport = transport
|
||||
}
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
bodyBytes, errRead := io.ReadAll(httpResp.Body)
|
||||
httpResp.Body.Close()
|
||||
if errRead != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||
continue
|
||||
}
|
||||
|
||||
result := gjson.GetBytes(bodyBytes, "models")
|
||||
if !result.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
var models []modelEntry
|
||||
|
||||
for originalName, modelData := range result.Map() {
|
||||
modelID := strings.TrimSpace(originalName)
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
// Skip internal/experimental models
|
||||
switch modelID {
|
||||
case "chat_20706", "chat_23310", "tab_flash_lite_preview", "tab_jump_flash_lite_preview", "gemini-2.5-flash-thinking", "gemini-2.5-pro":
|
||||
continue
|
||||
}
|
||||
|
||||
displayName := modelData.Get("displayName").String()
|
||||
if displayName == "" {
|
||||
displayName = modelID
|
||||
}
|
||||
|
||||
entry := modelEntry{
|
||||
ID: modelID,
|
||||
Object: "model",
|
||||
OwnedBy: "antigravity",
|
||||
Type: "antigravity",
|
||||
DisplayName: displayName,
|
||||
Name: modelID,
|
||||
Description: displayName,
|
||||
}
|
||||
|
||||
if maxTok := modelData.Get("maxTokens").Int(); maxTok > 0 {
|
||||
entry.ContextLength = int(maxTok)
|
||||
}
|
||||
if maxOut := modelData.Get("maxOutputTokens").Int(); maxOut > 0 {
|
||||
entry.MaxCompletionTokens = int(maxOut)
|
||||
}
|
||||
|
||||
models = append(models, entry)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaStringValue(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseHomeFlagConfigHostPort(t *testing.T) {
|
||||
cfg, err := parseHomeFlagConfig("home.example.com:8327", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("parseHomeFlagConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("Enabled = false, want true")
|
||||
}
|
||||
if cfg.Host != "home.example.com" {
|
||||
t.Fatalf("Host = %q, want home.example.com", cfg.Host)
|
||||
}
|
||||
if cfg.Port != 8327 {
|
||||
t.Fatalf("Port = %d, want 8327", cfg.Port)
|
||||
}
|
||||
if cfg.Password != "secret" {
|
||||
t.Fatalf("Password = %q, want secret", cfg.Password)
|
||||
}
|
||||
if cfg.TLS.Enable {
|
||||
t.Fatal("TLS.Enable = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHomeFlagConfigRediss(t *testing.T) {
|
||||
cfg, err := parseHomeFlagConfig("rediss://:url-secret@home.example.com:444?server-name=home.example.com&skip_verify=true&ca-cert=C%3A%2Fcerts%2Fca.pem", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseHomeFlagConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Host != "home.example.com" {
|
||||
t.Fatalf("Host = %q, want home.example.com", cfg.Host)
|
||||
}
|
||||
if cfg.Port != 444 {
|
||||
t.Fatalf("Port = %d, want 444", cfg.Port)
|
||||
}
|
||||
if cfg.Password != "url-secret" {
|
||||
t.Fatalf("Password = %q, want url-secret", cfg.Password)
|
||||
}
|
||||
if !cfg.TLS.Enable {
|
||||
t.Fatal("TLS.Enable = false, want true")
|
||||
}
|
||||
if cfg.TLS.ServerName != "home.example.com" {
|
||||
t.Fatalf("TLS.ServerName = %q, want home.example.com", cfg.TLS.ServerName)
|
||||
}
|
||||
if !cfg.TLS.InsecureSkipVerify {
|
||||
t.Fatal("TLS.InsecureSkipVerify = false, want true")
|
||||
}
|
||||
if cfg.TLS.CACert != "C:/certs/ca.pem" {
|
||||
t.Fatalf("TLS.CACert = %q, want C:/certs/ca.pem", cfg.TLS.CACert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHomeFlagConfigPasswordFlagOverridesURLPassword(t *testing.T) {
|
||||
cfg, err := parseHomeFlagConfig("rediss://:url-secret@home.example.com:444", "flag-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("parseHomeFlagConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Password != "flag-secret" {
|
||||
t.Fatalf("Password = %q, want flag-secret", cfg.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHomeFlagConfigDisableClusterDiscovery(t *testing.T) {
|
||||
cfg, err := parseHomeFlagConfig("redis://home.example.com:8327?disable-cluster-discovery=true", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseHomeFlagConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if !cfg.DisableClusterDiscovery {
|
||||
t.Fatal("DisableClusterDiscovery = false, want true")
|
||||
}
|
||||
}
|
||||
+236
-44
@@ -10,27 +10,31 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/tui"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/store"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/tui"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -49,6 +53,120 @@ func init() {
|
||||
buildinfo.BuildDate = BuildDate
|
||||
}
|
||||
|
||||
func parseHomeFlagConfig(rawAddr string, password string) (config.HomeConfig, error) {
|
||||
rawAddr = strings.TrimSpace(rawAddr)
|
||||
if rawAddr == "" {
|
||||
return config.HomeConfig{}, fmt.Errorf("address is empty")
|
||||
}
|
||||
|
||||
if strings.Contains(rawAddr, "://") {
|
||||
return parseHomeURLConfig(rawAddr, password)
|
||||
}
|
||||
|
||||
host, portStr, errSplit := net.SplitHostPort(rawAddr)
|
||||
if errSplit != nil {
|
||||
return config.HomeConfig{}, fmt.Errorf("expected host:port, redis://host:port, or rediss://host:port: %w", errSplit)
|
||||
}
|
||||
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return config.HomeConfig{}, fmt.Errorf("host is empty")
|
||||
}
|
||||
|
||||
port, errPort := parseHomePort(portStr)
|
||||
if errPort != nil {
|
||||
return config.HomeConfig{}, errPort
|
||||
}
|
||||
|
||||
return config.HomeConfig{
|
||||
Enabled: true,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: password,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseHomeURLConfig(rawAddr string, password string) (config.HomeConfig, error) {
|
||||
parsed, errParse := url.Parse(rawAddr)
|
||||
if errParse != nil {
|
||||
return config.HomeConfig{}, fmt.Errorf("parse URL: %w", errParse)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
||||
if scheme != "redis" && scheme != "rediss" {
|
||||
return config.HomeConfig{}, fmt.Errorf("unsupported URL scheme %q", parsed.Scheme)
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(parsed.Hostname())
|
||||
if host == "" {
|
||||
return config.HomeConfig{}, fmt.Errorf("host is empty")
|
||||
}
|
||||
|
||||
port, errPort := parseHomePort(parsed.Port())
|
||||
if errPort != nil {
|
||||
return config.HomeConfig{}, errPort
|
||||
}
|
||||
|
||||
if password == "" && parsed.User != nil {
|
||||
if urlPassword, ok := parsed.User.Password(); ok {
|
||||
password = urlPassword
|
||||
}
|
||||
}
|
||||
|
||||
homeCfg := config.HomeConfig{
|
||||
Enabled: true,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Password: password,
|
||||
}
|
||||
query := parsed.Query()
|
||||
homeCfg.DisableClusterDiscovery = parseHomeBoolQuery(query, "disable-cluster-discovery", "disable_cluster_discovery")
|
||||
|
||||
if scheme == "rediss" {
|
||||
homeCfg.TLS.Enable = true
|
||||
homeCfg.TLS.ServerName = strings.TrimSpace(firstHomeQueryValue(query, "server-name", "server_name"))
|
||||
homeCfg.TLS.InsecureSkipVerify = parseHomeBoolQuery(query, "insecure-skip-verify", "insecure_skip_verify", "skip_verify")
|
||||
homeCfg.TLS.CACert = strings.TrimSpace(firstHomeQueryValue(query, "ca-cert", "ca_cert"))
|
||||
}
|
||||
|
||||
return homeCfg, nil
|
||||
}
|
||||
|
||||
func parseHomePort(rawPort string) (int, error) {
|
||||
rawPort = strings.TrimSpace(rawPort)
|
||||
if rawPort == "" {
|
||||
return 0, fmt.Errorf("port is empty")
|
||||
}
|
||||
|
||||
port, errPort := strconv.Atoi(rawPort)
|
||||
if errPort != nil || port <= 0 || port > 65535 {
|
||||
return 0, fmt.Errorf("invalid port %q", rawPort)
|
||||
}
|
||||
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func firstHomeQueryValue(values url.Values, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := values.Get(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseHomeBoolQuery(values url.Values, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(values.Get(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parsed, errParse := strconv.ParseBool(value)
|
||||
return errParse == nil && parsed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// main is the entry point of the application.
|
||||
// It parses command-line flags, loads configuration, and starts the appropriate
|
||||
// service based on the provided flags (login, codex-login, or server mode).
|
||||
@@ -60,38 +178,44 @@ func main() {
|
||||
var codexLogin bool
|
||||
var codexDeviceLogin bool
|
||||
var claudeLogin bool
|
||||
var qwenLogin bool
|
||||
var iflowLogin bool
|
||||
var iflowCookie bool
|
||||
var noBrowser bool
|
||||
var oauthCallbackPort int
|
||||
var antigravityLogin bool
|
||||
var kimiLogin bool
|
||||
var xaiLogin bool
|
||||
var projectID string
|
||||
var vertexImport string
|
||||
var vertexImportPrefix string
|
||||
var configPath string
|
||||
var password string
|
||||
var homeAddr string
|
||||
var homePassword string
|
||||
var homeDisableClusterDiscovery bool
|
||||
var tuiMode bool
|
||||
var standalone bool
|
||||
var localModel bool
|
||||
|
||||
// Define command-line flags for different operation modes.
|
||||
flag.BoolVar(&login, "login", false, "Login Google Account")
|
||||
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
||||
flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow")
|
||||
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
||||
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
|
||||
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
||||
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
||||
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
||||
flag.IntVar(&oauthCallbackPort, "oauth-callback-port", 0, "Override OAuth callback port (defaults to provider-specific port)")
|
||||
flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth")
|
||||
flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth")
|
||||
flag.BoolVar(&xaiLogin, "xai-login", false, "Login to xAI using OAuth")
|
||||
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
||||
flag.StringVar(&vertexImportPrefix, "vertex-import-prefix", "", "Prefix for Vertex model namespacing (use with -vertex-import)")
|
||||
flag.StringVar(&password, "password", "", "")
|
||||
flag.StringVar(&homeAddr, "home", "", "Home control plane address in host:port, redis://host:port, or rediss://host:port format (loads config from home and skips local config file)")
|
||||
flag.StringVar(&homePassword, "home-password", "", "Home control plane password (Redis AUTH)")
|
||||
flag.BoolVar(&homeDisableClusterDiscovery, "home-disable-cluster-discovery", false, "Disable Home CLUSTER NODES discovery and keep using the configured -home address")
|
||||
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
|
||||
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
|
||||
flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalog only, skip remote model fetching")
|
||||
|
||||
flag.CommandLine.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
@@ -127,6 +251,7 @@ func main() {
|
||||
var err error
|
||||
var cfg *config.Config
|
||||
var isCloudDeploy bool
|
||||
var configLoadedFromHome bool
|
||||
var (
|
||||
usePostgresStore bool
|
||||
pgStoreDSN string
|
||||
@@ -137,6 +262,7 @@ func main() {
|
||||
gitStoreRemoteURL string
|
||||
gitStoreUser string
|
||||
gitStorePassword string
|
||||
gitStoreBranch string
|
||||
gitStoreLocalPath string
|
||||
gitStoreInst *store.GitTokenStore
|
||||
gitStoreRoot string
|
||||
@@ -206,6 +332,9 @@ func main() {
|
||||
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
|
||||
gitStoreLocalPath = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok {
|
||||
gitStoreBranch = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
|
||||
useObjectStore = true
|
||||
objectStoreEndpoint = value
|
||||
@@ -233,7 +362,54 @@ func main() {
|
||||
// Determine and load the configuration file.
|
||||
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
|
||||
var configFilePath string
|
||||
if usePostgresStore {
|
||||
if strings.TrimSpace(homeAddr) != "" {
|
||||
configLoadedFromHome = true
|
||||
trimmedHomePassword := strings.TrimSpace(homePassword)
|
||||
homeCfg, errHomeCfg := parseHomeFlagConfig(homeAddr, trimmedHomePassword)
|
||||
if errHomeCfg != nil {
|
||||
log.Errorf("invalid -home address %q: %v", homeAddr, errHomeCfg)
|
||||
return
|
||||
}
|
||||
if homeDisableClusterDiscovery {
|
||||
homeCfg.DisableClusterDiscovery = true
|
||||
}
|
||||
homeClient := home.New(homeCfg)
|
||||
defer homeClient.Close()
|
||||
|
||||
ctxHome, cancelHome := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
raw, errGetConfig := homeClient.GetConfig(ctxHome)
|
||||
cancelHome()
|
||||
if errGetConfig != nil {
|
||||
log.Errorf("failed to fetch config from home: %v", errGetConfig)
|
||||
return
|
||||
}
|
||||
|
||||
parsed, errParseConfig := config.ParseConfigBytes(raw)
|
||||
if errParseConfig != nil {
|
||||
log.Errorf("failed to parse config payload from home: %v", errParseConfig)
|
||||
return
|
||||
}
|
||||
if parsed == nil {
|
||||
parsed = &config.Config{}
|
||||
}
|
||||
parsed.Home = homeCfg
|
||||
parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
|
||||
parsed.UsageStatisticsEnabled = true
|
||||
cfg = parsed
|
||||
|
||||
// Keep a non-empty config path for downstream components (log paths, management assets, etc),
|
||||
// but do not require the file to exist when loading config from home.
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
configFilePath = configPath
|
||||
} else {
|
||||
configFilePath = filepath.Join(wd, "config.yaml")
|
||||
}
|
||||
|
||||
// Local stores are intentionally disabled when config is loaded from home.
|
||||
usePostgresStore = false
|
||||
useObjectStore = false
|
||||
useGitStore = false
|
||||
} else if usePostgresStore {
|
||||
if pgStoreLocalPath == "" {
|
||||
pgStoreLocalPath = wd
|
||||
}
|
||||
@@ -340,7 +516,7 @@ func main() {
|
||||
}
|
||||
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
|
||||
authDir := filepath.Join(gitStoreRoot, "auths")
|
||||
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
|
||||
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch)
|
||||
gitStoreInst.SetBaseDir(authDir)
|
||||
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
|
||||
log.Errorf("failed to prepare git token store: %v", errRepo)
|
||||
@@ -397,24 +573,29 @@ func main() {
|
||||
// In cloud deploy mode, check if we have a valid configuration
|
||||
var configFileExists bool
|
||||
if isCloudDeploy {
|
||||
if info, errStat := os.Stat(configFilePath); errStat != nil {
|
||||
// Don't mislead: API server will not start until configuration is provided.
|
||||
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if info.IsDir() {
|
||||
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if cfg.Port == 0 {
|
||||
// LoadConfigOptional returns empty config when file is empty or invalid.
|
||||
// Config file exists but is empty or invalid; treat as missing config
|
||||
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
|
||||
configFileExists = false
|
||||
if configLoadedFromHome && cfg != nil {
|
||||
configFileExists = cfg.Port != 0
|
||||
} else {
|
||||
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
||||
configFileExists = true
|
||||
if info, errStat := os.Stat(configFilePath); errStat != nil {
|
||||
// Don't mislead: API server will not start until configuration is provided.
|
||||
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if info.IsDir() {
|
||||
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if cfg.Port == 0 {
|
||||
// LoadConfigOptional returns empty config when file is empty or invalid.
|
||||
// Config file exists but is empty or invalid; treat as missing config
|
||||
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
|
||||
configFileExists = false
|
||||
} else {
|
||||
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
||||
configFileExists = true
|
||||
}
|
||||
}
|
||||
}
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
|
||||
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
|
||||
if err = logging.ConfigureLogOutput(cfg); err != nil {
|
||||
@@ -459,7 +640,7 @@ func main() {
|
||||
|
||||
if vertexImport != "" {
|
||||
// Handle Vertex service account import
|
||||
cmd.DoVertexImport(cfg, vertexImport)
|
||||
cmd.DoVertexImport(cfg, vertexImport, vertexImportPrefix)
|
||||
} else if login {
|
||||
// Handle Google/Gemini login
|
||||
cmd.DoLogin(cfg, projectID, options)
|
||||
@@ -475,14 +656,10 @@ func main() {
|
||||
} else if claudeLogin {
|
||||
// Handle Claude login
|
||||
cmd.DoClaudeLogin(cfg, options)
|
||||
} else if qwenLogin {
|
||||
cmd.DoQwenLogin(cfg, options)
|
||||
} else if iflowLogin {
|
||||
cmd.DoIFlowLogin(cfg, options)
|
||||
} else if iflowCookie {
|
||||
cmd.DoIFlowCookieAuth(cfg, options)
|
||||
} else if kimiLogin {
|
||||
cmd.DoKimiLogin(cfg, options)
|
||||
} else if xaiLogin {
|
||||
cmd.DoXAILogin(cfg, options)
|
||||
} else {
|
||||
// In cloud deploy mode without config file, just wait for shutdown signals
|
||||
if isCloudDeploy && !configFileExists {
|
||||
@@ -490,10 +667,19 @@ func main() {
|
||||
cmd.WaitForCloudDeploy()
|
||||
return
|
||||
}
|
||||
if localModel && (!tuiMode || standalone) {
|
||||
log.Info("Local model mode: using embedded model catalog, remote model updates disabled")
|
||||
}
|
||||
if tuiMode {
|
||||
if standalone {
|
||||
// Standalone mode: start an embedded local server and connect TUI client to it.
|
||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||
misc.StartAntigravityVersionUpdater(context.Background())
|
||||
if !localModel && !cfg.Home.Enabled {
|
||||
registry.StartModelsUpdater(context.Background())
|
||||
} else if cfg.Home.Enabled {
|
||||
log.Info("Home mode: remote model updates disabled")
|
||||
}
|
||||
hook := tui.NewLogHook(2000)
|
||||
hook.SetFormatter(&logging.LogFormatter{})
|
||||
log.AddHook(hook)
|
||||
@@ -566,6 +752,12 @@ func main() {
|
||||
} else {
|
||||
// Start the main proxy service
|
||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||
misc.StartAntigravityVersionUpdater(context.Background())
|
||||
if !localModel && !cfg.Home.Enabled {
|
||||
registry.StartModelsUpdater(context.Background())
|
||||
} else if cfg.Home.Enabled {
|
||||
log.Info("Home mode: remote model updates disabled")
|
||||
}
|
||||
cmd.StartService(cfg, configFilePath, password)
|
||||
}
|
||||
}
|
||||
|
||||
+138
-17
@@ -11,6 +11,26 @@ tls:
|
||||
cert: ""
|
||||
key: ""
|
||||
|
||||
# Optional "home" control plane integration over Redis protocol.
|
||||
home:
|
||||
enabled: false
|
||||
host: "127.0.0.1"
|
||||
port: 6379
|
||||
password: ""
|
||||
# Keep CPA pinned to the configured home address instead of switching to CLUSTER NODES entries.
|
||||
# Useful when Home is behind NAT, Docker networking, or a reverse proxy.
|
||||
disable-cluster-discovery: false
|
||||
# Optional TLS for the outbound Redis connection to the home control plane.
|
||||
# Enable this when connecting through rediss:// or an SSL stream proxy.
|
||||
tls:
|
||||
enable: false
|
||||
# Optional SNI/certificate name override. Leave empty to use the configured home host.
|
||||
server-name: ""
|
||||
# Trust a private CA bundle in addition to system roots.
|
||||
ca-cert: ""
|
||||
# Only for testing self-signed endpoints; disables certificate verification.
|
||||
insecure-skip-verify: false
|
||||
|
||||
# Management API settings
|
||||
remote-management:
|
||||
# Whether to allow remote (non-localhost) management access.
|
||||
@@ -25,6 +45,10 @@ remote-management:
|
||||
# Disable the bundled management control panel asset download and HTTP route when true.
|
||||
disable-control-panel: false
|
||||
|
||||
# Disable automatic periodic background updates of the management panel from GitHub (default: false).
|
||||
# When enabled, the panel is only downloaded on first access if missing, and never auto-updated afterward.
|
||||
# disable-auto-update-panel: false
|
||||
|
||||
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
|
||||
panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||
|
||||
@@ -62,7 +86,13 @@ error-logs-max-files: 10
|
||||
# When false, disable in-memory usage statistics aggregation
|
||||
usage-statistics-enabled: false
|
||||
|
||||
# How long (in seconds) Redis usage queue items are retained in memory for the RESP interface (LPOP/RPOP).
|
||||
# Note: the in-process Redis RESP usage output is disabled when home.enabled is true.
|
||||
# Default: 60. Max: 3600.
|
||||
redis-usage-queue-retention-seconds: 60
|
||||
|
||||
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
|
||||
proxy-url: ""
|
||||
|
||||
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
|
||||
@@ -75,37 +105,77 @@ passthrough-headers: false
|
||||
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
|
||||
request-retry: 3
|
||||
|
||||
# Maximum number of different credentials to try for one failed request.
|
||||
# Set to 0 to keep legacy behavior (try all available credentials).
|
||||
max-retry-credentials: 0
|
||||
|
||||
# Maximum wait time in seconds for a cooled-down credential before triggering a retry.
|
||||
max-retry-interval: 30
|
||||
|
||||
# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
|
||||
disable-cooling: false
|
||||
|
||||
# disable-image-generation supports: false (default), true, or "chat".
|
||||
# - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits).
|
||||
# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled.
|
||||
disable-image-generation: false
|
||||
|
||||
# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).
|
||||
# When > 0, overrides the default worker count (16).
|
||||
# auth-auto-refresh-workers: 16
|
||||
|
||||
# Quota exceeded behavior
|
||||
quota-exceeded:
|
||||
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
|
||||
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
|
||||
antigravity-credits: true # Whether to use credits as last-resort fallback when all free-tier auths are exhausted for Claude models
|
||||
|
||||
# Routing strategy for selecting credentials when multiple match.
|
||||
routing:
|
||||
strategy: "round-robin" # round-robin (default), fill-first
|
||||
# Enable universal session-sticky routing for all clients.
|
||||
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
|
||||
# X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
|
||||
# X-Client-Request-Id (PI), conversation_id, or first few messages hash.
|
||||
# Automatic failover is always enabled when bound auth becomes unavailable.
|
||||
session-affinity: false # default: false
|
||||
# How long session-to-auth bindings are retained. Default: 1h
|
||||
session-affinity-ttl: "1h"
|
||||
|
||||
# When true, enable authentication for the WebSocket API (/v1/ws).
|
||||
ws-auth: false
|
||||
ws-auth: true
|
||||
|
||||
# When true, enable Gemini CLI internal endpoints (/v1internal:*).
|
||||
# Default is false for safety.
|
||||
enable-gemini-cli-endpoint: false
|
||||
|
||||
# When > 0, emit blank lines every N seconds for non-streaming responses to prevent idle timeouts.
|
||||
nonstream-keepalive-interval: 0
|
||||
|
||||
# Streaming behavior (SSE keep-alives + safe bootstrap retries).
|
||||
# streaming:
|
||||
# keepalive-seconds: 15 # Default: 0 (disabled). <= 0 disables keep-alives.
|
||||
# bootstrap-retries: 1 # Default: 0 (disabled). Retries before first byte is sent.
|
||||
|
||||
# Signature cache validation for thinking blocks (Antigravity/Claude).
|
||||
# When true (default), cached signatures are preferred and validated.
|
||||
# When false, client signatures are used directly after normalization (bypass mode for testing).
|
||||
# antigravity-signature-cache-enabled: true
|
||||
|
||||
# Bypass mode signature validation strictness (only applies when signature cache is disabled).
|
||||
# When true, validates full Claude protobuf tree (Field 2 -> Field 1 structure).
|
||||
# When false (default), only checks R/E prefix + base64 + first byte 0x12.
|
||||
# antigravity-signature-bypass-strict: false
|
||||
|
||||
# Gemini API keys
|
||||
# gemini-api-key:
|
||||
# - api-key: "AIzaSy...01"
|
||||
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
|
||||
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
|
||||
# base-url: "https://generativelanguage.googleapis.com"
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# proxy-url: "socks5://proxy.example.com:1080"
|
||||
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||
# models:
|
||||
# - name: "gemini-2.5-flash" # upstream model name
|
||||
# alias: "gemini-flash" # client alias mapped to the upstream model
|
||||
@@ -120,10 +190,12 @@ nonstream-keepalive-interval: 0
|
||||
# codex-api-key:
|
||||
# - api-key: "sk-atSM..."
|
||||
# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
|
||||
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
|
||||
# base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||
# models:
|
||||
# - name: "gpt-5-codex" # upstream model name
|
||||
# alias: "codex-latest" # client alias mapped to the upstream model
|
||||
@@ -138,10 +210,12 @@ nonstream-keepalive-interval: 0
|
||||
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||
# - api-key: "sk-atSM..."
|
||||
# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
|
||||
# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
|
||||
# base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||
# models:
|
||||
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
||||
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
||||
@@ -160,36 +234,70 @@ nonstream-keepalive-interval: 0
|
||||
# - "API"
|
||||
# - "proxy"
|
||||
# cache-user-id: true # optional: default is false; set true to reuse cached user_id per API key instead of generating a random one each request
|
||||
# experimental-cch-signing: false # optional: default is false; when true, sign the final /v1/messages body using the current Claude Code cch algorithm
|
||||
# # keep this disabled unless you explicitly need the behavior, so upstream seed changes fall back to legacy proxy behavior
|
||||
|
||||
# Default headers for Claude API requests. Update when Claude Code releases new versions.
|
||||
# These are used as fallbacks when the client does not send its own headers.
|
||||
# In legacy mode, user-agent/package-version/runtime-version/timeout are used as fallbacks
|
||||
# when the client omits them, while OS/arch remain runtime-derived. When
|
||||
# stabilize-device-profile is enabled, OS/arch stay pinned to the baseline values below,
|
||||
# while user-agent/package-version/runtime-version seed a software fingerprint that can
|
||||
# still upgrade to newer official Claude client versions.
|
||||
# claude-header-defaults:
|
||||
# user-agent: "claude-cli/2.1.44 (external, sdk-cli)"
|
||||
# package-version: "0.74.0"
|
||||
# runtime-version: "v24.3.0"
|
||||
# os: "MacOS"
|
||||
# arch: "arm64"
|
||||
# timeout: "600"
|
||||
# stabilize-device-profile: false # optional, default false; set true to enable per-auth/API-key fingerprint pinning
|
||||
|
||||
# Default headers for Codex OAuth model requests.
|
||||
# These are used only for file-backed/OAuth Codex requests when the client
|
||||
# does not send the header. `user-agent` applies to HTTP and websocket requests;
|
||||
# `beta-features` only applies to websocket requests. They do not apply to codex-api-key entries.
|
||||
# codex-header-defaults:
|
||||
# user-agent: "codex_cli_rs/0.114.0 (Mac OS 14.2.0; x86_64) vscode/1.111.0"
|
||||
# beta-features: "multi_agent"
|
||||
|
||||
# OpenAI compatibility providers
|
||||
# openai-compatibility:
|
||||
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||
# disabled: false # optional: set to true to disable this provider without removing it
|
||||
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
|
||||
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||
# disable-cooling: false # optional: per-provider override for auth/model cooldown scheduling
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# api-key-entries:
|
||||
# - api-key: "sk-or-v1-...b780"
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
||||
# models: # The models supported by the provider.
|
||||
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||
# alias: "kimi-k2" # The alias used in the API.
|
||||
# alias: "kimi-k2" # The alias used in the API.
|
||||
# thinking: # optional: omit to default to levels ["low","medium","high"]
|
||||
# levels: ["low", "medium", "high"]
|
||||
# # You may repeat the same alias to build an internal model pool.
|
||||
# # The client still sees only one alias in the model list.
|
||||
# # Requests to that alias will round-robin across the upstream names below,
|
||||
# # and if the chosen upstream fails before producing output, the request will
|
||||
# # continue with the next upstream model in the same alias pool.
|
||||
# - name: "deepseek-v3.1"
|
||||
# alias: "claude-opus-4.66"
|
||||
# - name: "glm-5"
|
||||
# alias: "claude-opus-4.66"
|
||||
# - name: "kimi-k2.5"
|
||||
# alias: "claude-opus-4.66"
|
||||
|
||||
# Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
|
||||
# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
|
||||
# vertex-api-key:
|
||||
# - api-key: "vk-123..." # x-goog-api-key header
|
||||
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
|
||||
# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
|
||||
# base-url: "https://example.com/api" # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
|
||||
# # proxy-url: "direct" # optional: explicit direct connect for this credential
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# models: # optional: map aliases to upstream model names
|
||||
@@ -197,6 +305,9 @@ nonstream-keepalive-interval: 0
|
||||
# alias: "vertex-flash" # client-visible alias
|
||||
# - name: "gemini-2.5-pro"
|
||||
# alias: "vertex-pro"
|
||||
# excluded-models: # optional: models to exclude from listing
|
||||
# - "imagen-3.0-generate-002"
|
||||
# - "imagen-*"
|
||||
|
||||
# Amp Integration
|
||||
# ampcode:
|
||||
@@ -234,8 +345,12 @@ nonstream-keepalive-interval: 0
|
||||
|
||||
# Global OAuth model name aliases (per channel)
|
||||
# These aliases rename model IDs for both model listing and request routing.
|
||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi.
|
||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi, xai.
|
||||
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
|
||||
# NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping
|
||||
# client-visible names can become ambiguous across providers. /api/provider/{provider}/... helps
|
||||
# you select the protocol surface, but inference backend selection can still follow the resolved
|
||||
# model/alias. For strict backend pinning, use unique aliases/prefixes or avoid overlapping names.
|
||||
# You can repeat the same name with different aliases to expose multiple client model names.
|
||||
# oauth-model-alias:
|
||||
# gemini-cli:
|
||||
@@ -257,15 +372,12 @@ nonstream-keepalive-interval: 0
|
||||
# codex:
|
||||
# - name: "gpt-5"
|
||||
# alias: "g5"
|
||||
# qwen:
|
||||
# - name: "qwen3-coder-plus"
|
||||
# alias: "qwen-plus"
|
||||
# iflow:
|
||||
# - name: "glm-4.7"
|
||||
# alias: "glm-god"
|
||||
# kimi:
|
||||
# - name: "kimi-k2.5"
|
||||
# alias: "k2.5"
|
||||
# xai:
|
||||
# - name: "grok-4.3"
|
||||
# alias: "grok-latest"
|
||||
|
||||
# OAuth provider excluded models
|
||||
# oauth-excluded-models:
|
||||
@@ -284,12 +396,10 @@ nonstream-keepalive-interval: 0
|
||||
# - "claude-3-5-haiku-20241022"
|
||||
# codex:
|
||||
# - "gpt-5-codex-mini"
|
||||
# qwen:
|
||||
# - "vision-model"
|
||||
# iflow:
|
||||
# - "tstars2.0"
|
||||
# kimi:
|
||||
# - "kimi-k2-thinking"
|
||||
# xai:
|
||||
# - "grok-3-mini"
|
||||
|
||||
# Optional payload configuration
|
||||
# payload:
|
||||
@@ -297,6 +407,17 @@ nonstream-keepalive-interval: 0
|
||||
# - models:
|
||||
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
|
||||
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex, antigravity
|
||||
# from-protocol: "responses" # restricts the rule to the source protocol, options: openai, responses, gemini, claude
|
||||
# headers: # all configured request headers must match; values support "*" wildcards
|
||||
# X-Client-Tier: "tenant-*-region-*"
|
||||
# match: # all payload JSON paths must equal the configured values
|
||||
# - "metadata.client": "codex"
|
||||
# not-match: # payload JSON paths must not equal the configured values
|
||||
# - "metadata.mode": "dev"
|
||||
# exist: # all payload JSON paths must exist and not be null
|
||||
# - "tools.#(type==\"web_search\").type"
|
||||
# not-exist: # all payload JSON paths must be missing or null
|
||||
# - "metadata.disable_payload"
|
||||
# params: # JSON path (gjson/sjson syntax) -> value
|
||||
# "generationConfig.thinkingConfig.thinkingBudget": 32768
|
||||
# default-raw: # Default raw rules set parameters using raw JSON when missing (must be valid JSON).
|
||||
|
||||
+4
-121
@@ -5,113 +5,12 @@
|
||||
# This script automates the process of building and running the Docker container
|
||||
# with version information dynamically injected at build time.
|
||||
|
||||
# Hidden feature: Preserve usage statistics across rebuilds
|
||||
# Usage: ./docker-build.sh --with-usage
|
||||
# First run prompts for management API key, saved to temp/stats/.api_secret
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STATS_DIR="temp/stats"
|
||||
STATS_FILE="${STATS_DIR}/.usage_backup.json"
|
||||
SECRET_FILE="${STATS_DIR}/.api_secret"
|
||||
WITH_USAGE=false
|
||||
|
||||
get_port() {
|
||||
if [[ -f "config.yaml" ]]; then
|
||||
grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/'
|
||||
else
|
||||
echo "8317"
|
||||
fi
|
||||
}
|
||||
|
||||
export_stats_api_secret() {
|
||||
if [[ -f "${SECRET_FILE}" ]]; then
|
||||
API_SECRET=$(cat "${SECRET_FILE}")
|
||||
else
|
||||
if [[ ! -d "${STATS_DIR}" ]]; then
|
||||
mkdir -p "${STATS_DIR}"
|
||||
fi
|
||||
echo "First time using --with-usage. Management API key required."
|
||||
read -r -p "Enter management key: " -s API_SECRET
|
||||
echo
|
||||
echo "${API_SECRET}" > "${SECRET_FILE}"
|
||||
chmod 600 "${SECRET_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_container_running() {
|
||||
local port
|
||||
port=$(get_port)
|
||||
|
||||
if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
|
||||
echo "Error: cli-proxy-api service is not responding at localhost:${port}"
|
||||
echo "Please start the container first or use without --with-usage flag."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
export_stats() {
|
||||
local port
|
||||
port=$(get_port)
|
||||
|
||||
if [[ ! -d "${STATS_DIR}" ]]; then
|
||||
mkdir -p "${STATS_DIR}"
|
||||
fi
|
||||
check_container_running
|
||||
echo "Exporting usage statistics..."
|
||||
EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \
|
||||
"http://localhost:${port}/v0/management/usage/export")
|
||||
HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1)
|
||||
RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d')
|
||||
|
||||
if [[ "${HTTP_CODE}" != "200" ]]; then
|
||||
echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${RESPONSE_BODY}" > "${STATS_FILE}"
|
||||
echo "Statistics exported to ${STATS_FILE}"
|
||||
}
|
||||
|
||||
import_stats() {
|
||||
local port
|
||||
port=$(get_port)
|
||||
|
||||
echo "Importing usage statistics..."
|
||||
IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "X-Management-Key: ${API_SECRET}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @"${STATS_FILE}" \
|
||||
"http://localhost:${port}/v0/management/usage/import")
|
||||
IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1)
|
||||
IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d')
|
||||
|
||||
if [[ "${IMPORT_CODE}" == "200" ]]; then
|
||||
echo "Statistics imported successfully"
|
||||
else
|
||||
echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}"
|
||||
fi
|
||||
|
||||
rm -f "${STATS_FILE}"
|
||||
}
|
||||
|
||||
wait_for_service() {
|
||||
local port
|
||||
port=$(get_port)
|
||||
|
||||
echo "Waiting for service to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 2
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--with-usage" ]]; then
|
||||
WITH_USAGE=true
|
||||
export_stats_api_secret
|
||||
if [[ "${1:-}" != "" ]]; then
|
||||
echo "Error: unknown option '${1}'."
|
||||
echo "Usage: ./docker-build.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Step 1: Choose Environment ---
|
||||
@@ -124,14 +23,7 @@ read -r -p "Enter choice [1-2]: " choice
|
||||
case "$choice" in
|
||||
1)
|
||||
echo "--- Running with Pre-built Image ---"
|
||||
if [[ "${WITH_USAGE}" == "true" ]]; then
|
||||
export_stats
|
||||
fi
|
||||
docker compose up -d --remove-orphans --no-build
|
||||
if [[ "${WITH_USAGE}" == "true" ]]; then
|
||||
wait_for_service
|
||||
import_stats
|
||||
fi
|
||||
echo "Services are starting from remote image."
|
||||
echo "Run 'docker compose logs -f' to see the logs."
|
||||
;;
|
||||
@@ -158,18 +50,9 @@ case "$choice" in
|
||||
--build-arg COMMIT="${COMMIT}" \
|
||||
--build-arg BUILD_DATE="${BUILD_DATE}"
|
||||
|
||||
if [[ "${WITH_USAGE}" == "true" ]]; then
|
||||
export_stats
|
||||
fi
|
||||
|
||||
echo "Starting the services..."
|
||||
docker compose up -d --remove-orphans --pull never
|
||||
|
||||
if [[ "${WITH_USAGE}" == "true" ]]; then
|
||||
wait_for_service
|
||||
import_stats
|
||||
fi
|
||||
|
||||
echo "Build complete. Services are starting."
|
||||
echo "Run 'docker compose logs -f' to see the logs."
|
||||
;;
|
||||
|
||||
@@ -24,14 +24,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
|
||||
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/logging"
|
||||
sdktr "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -52,11 +52,11 @@ func init() {
|
||||
sdktr.Register(fOpenAI, fMyProv,
|
||||
func(model string, raw []byte, stream bool) []byte { return raw },
|
||||
sdktr.ResponseTransform{
|
||||
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||
return []string{string(raw)}
|
||||
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) [][]byte {
|
||||
return [][]byte{raw}
|
||||
},
|
||||
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||
return string(raw)
|
||||
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []byte {
|
||||
return raw
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator/builtin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v6
|
||||
module github.com/router-for-me/CLIProxyAPI/v7
|
||||
|
||||
go 1.26.0
|
||||
|
||||
@@ -31,6 +31,12 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
@@ -81,6 +87,7 @@ require (
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pierrec/xxHash v0.1.5
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
|
||||
@@ -18,6 +18,8 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
@@ -152,10 +154,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo=
|
||||
github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -201,6 +207,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
)
|
||||
|
||||
// Register ensures the config-access provider is available to the access manager.
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
)
|
||||
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *bufferedConn) Read(p []byte) (int, error) {
|
||||
if c == nil {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if c.reader == nil {
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
|
||||
func (c *bufferedConn) ConnectionState() tls.ConnectionState {
|
||||
if c == nil || c.Conn == nil {
|
||||
return tls.ConnectionState{}
|
||||
}
|
||||
if stater, ok := c.Conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
|
||||
return stater.ConnectionState()
|
||||
}
|
||||
return tls.ConnectionState{}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
type apiKeyUsageEntry struct {
|
||||
Success int64 `json:"success"`
|
||||
Failed int64 `json:"failed"`
|
||||
RecentRequests []coreauth.RecentRequestBucket `json:"recent_requests"`
|
||||
}
|
||||
|
||||
func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket {
|
||||
if len(dst) == 0 {
|
||||
return src
|
||||
}
|
||||
if len(src) == 0 {
|
||||
return dst
|
||||
}
|
||||
if len(dst) != len(src) {
|
||||
n := len(dst)
|
||||
if len(src) < n {
|
||||
n = len(src)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
dst[i].Success += src[i].Success
|
||||
dst[i].Failed += src[i].Failed
|
||||
}
|
||||
return dst
|
||||
}
|
||||
for i := range dst {
|
||||
dst[i].Success += src[i].Success
|
||||
dst[i].Failed += src[i].Failed
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// GetAPIKeyUsage returns recent request buckets for all in-memory api_key auths,
|
||||
// grouped by provider and keyed by "base_url|api_key".
|
||||
func (h *Handler) GetAPIKeyUsage(c *gin.Context) {
|
||||
if h == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler not initialized"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
manager := h.authManager
|
||||
h.mu.Unlock()
|
||||
if manager == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
out := make(map[string]map[string]apiKeyUsageEntry)
|
||||
for _, auth := range manager.List() {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
kind, apiKey := auth.AccountInfo()
|
||||
if !strings.EqualFold(strings.TrimSpace(kind), "api_key") {
|
||||
continue
|
||||
}
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
continue
|
||||
}
|
||||
baseURL := ""
|
||||
if auth.Attributes != nil {
|
||||
baseURL = strings.TrimSpace(auth.Attributes["base_url"])
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(auth.Attributes["base-url"])
|
||||
}
|
||||
}
|
||||
compositeKey := baseURL + "|" + apiKey
|
||||
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
|
||||
recent := auth.RecentRequestsSnapshot(now)
|
||||
providerBucket, ok := out[provider]
|
||||
if !ok {
|
||||
providerBucket = make(map[string]apiKeyUsageEntry)
|
||||
out[provider] = providerBucket
|
||||
}
|
||||
if existing, exists := providerBucket[compositeKey]; exists {
|
||||
existing.Success += auth.Success
|
||||
existing.Failed += auth.Failed
|
||||
existing.RecentRequests = mergeRecentRequestBuckets(existing.RecentRequests, recent)
|
||||
providerBucket[compositeKey] = existing
|
||||
continue
|
||||
}
|
||||
providerBucket[compositeKey] = apiKeyUsageEntry{
|
||||
Success: auth.Success,
|
||||
Failed: auth.Failed,
|
||||
RecentRequests: recent,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func sumRecentRequestBuckets(buckets []coreauth.RecentRequestBucket) (int64, int64) {
|
||||
var success int64
|
||||
var failed int64
|
||||
for _, bucket := range buckets {
|
||||
success += bucket.Success
|
||||
failed += bucket.Failed
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
if _, err := manager.Register(context.Background(), &coreauth.Auth{
|
||||
ID: "codex-auth",
|
||||
Provider: "codex",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "codex-key",
|
||||
"base_url": "https://codex.example.com",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("register codex auth: %v", err)
|
||||
}
|
||||
if _, err := manager.Register(context.Background(), &coreauth.Auth{
|
||||
ID: "claude-auth",
|
||||
Provider: "claude",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "claude-key",
|
||||
"base_url": "https://claude.example.com",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("register claude auth: %v", err)
|
||||
}
|
||||
|
||||
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: true})
|
||||
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: false})
|
||||
manager.MarkResult(context.Background(), coreauth.Result{AuthID: "claude-auth", Provider: "claude", Model: "claude-4", Success: true})
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/api-key-usage", nil)
|
||||
ginCtx.Request = req
|
||||
h.GetAPIKeyUsage(ginCtx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]map[string]apiKeyUsageEntry
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
|
||||
codexEntry := payload["codex"]["https://codex.example.com|codex-key"]
|
||||
if codexEntry.Success != 1 || codexEntry.Failed != 1 {
|
||||
t.Fatalf("codex totals = %d/%d, want 1/1", codexEntry.Success, codexEntry.Failed)
|
||||
}
|
||||
if len(codexEntry.RecentRequests) != 20 {
|
||||
t.Fatalf("codex buckets len = %d, want 20", len(codexEntry.RecentRequests))
|
||||
}
|
||||
codexSuccess, codexFailed := sumRecentRequestBuckets(codexEntry.RecentRequests)
|
||||
if codexSuccess != 1 || codexFailed != 1 {
|
||||
t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed)
|
||||
}
|
||||
|
||||
claudeEntry := payload["claude"]["https://claude.example.com|claude-key"]
|
||||
if claudeEntry.Success != 1 || claudeEntry.Failed != 0 {
|
||||
t.Fatalf("claude totals = %d/%d, want 1/0", claudeEntry.Success, claudeEntry.Failed)
|
||||
}
|
||||
if len(claudeEntry.RecentRequests) != 20 {
|
||||
t.Fatalf("claude buckets len = %d, want 20", len(claudeEntry.RecentRequests))
|
||||
}
|
||||
claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeEntry.RecentRequests)
|
||||
if claudeSuccess != 1 || claudeFailed != 0 {
|
||||
t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed)
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,17 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
@@ -637,6 +637,11 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {
|
||||
if proxyStr := strings.TrimSpace(auth.ProxyURL); proxyStr != "" {
|
||||
proxyCandidates = append(proxyCandidates, proxyStr)
|
||||
}
|
||||
if h != nil && h.cfg != nil {
|
||||
if proxyStr := strings.TrimSpace(proxyURLFromAPIKeyConfig(h.cfg, auth)); proxyStr != "" {
|
||||
proxyCandidates = append(proxyCandidates, proxyStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h != nil && h.cfg != nil {
|
||||
if proxyStr := strings.TrimSpace(h.cfg.ProxyURL); proxyStr != "" {
|
||||
@@ -659,46 +664,131 @@ func (h *Handler) apiCallTransport(auth *coreauth.Auth) http.RoundTripper {
|
||||
return clone
|
||||
}
|
||||
|
||||
func buildProxyTransport(proxyStr string) *http.Transport {
|
||||
proxyStr = strings.TrimSpace(proxyStr)
|
||||
if proxyStr == "" {
|
||||
type apiKeyConfigEntry interface {
|
||||
GetAPIKey() string
|
||||
GetBaseURL() string
|
||||
}
|
||||
|
||||
func resolveAPIKeyConfig[T apiKeyConfigEntry](entries []T, auth *coreauth.Auth) *T {
|
||||
if auth == nil || len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
proxyURL, errParse := url.Parse(proxyStr)
|
||||
if errParse != nil {
|
||||
log.WithError(errParse).Debug("parse proxy URL failed")
|
||||
return nil
|
||||
attrKey, attrBase := "", ""
|
||||
if auth.Attributes != nil {
|
||||
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
|
||||
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
|
||||
}
|
||||
if proxyURL.Scheme == "" || proxyURL.Host == "" {
|
||||
log.Debug("proxy URL missing scheme/host")
|
||||
return nil
|
||||
}
|
||||
|
||||
if proxyURL.Scheme == "socks5" {
|
||||
var proxyAuth *proxy.Auth
|
||||
if proxyURL.User != nil {
|
||||
username := proxyURL.User.Username()
|
||||
password, _ := proxyURL.User.Password()
|
||||
proxyAuth = &proxy.Auth{User: username, Password: password}
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
cfgKey := strings.TrimSpace((*entry).GetAPIKey())
|
||||
cfgBase := strings.TrimSpace((*entry).GetBaseURL())
|
||||
if attrKey != "" && attrBase != "" {
|
||||
if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) {
|
||||
return entry
|
||||
}
|
||||
continue
|
||||
}
|
||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, proxyAuth, proxy.Direct)
|
||||
if errSOCKS5 != nil {
|
||||
log.WithError(errSOCKS5).Debug("create SOCKS5 dialer failed")
|
||||
return nil
|
||||
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
|
||||
if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
||||
return &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
if attrKey != "" {
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
if strings.EqualFold(strings.TrimSpace((*entry).GetAPIKey()), attrKey) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
|
||||
return nil
|
||||
}
|
||||
|
||||
func proxyURLFromAPIKeyConfig(cfg *config.Config, auth *coreauth.Auth) string {
|
||||
if cfg == nil || auth == nil {
|
||||
return ""
|
||||
}
|
||||
authKind, authAccount := auth.AccountInfo()
|
||||
if !strings.EqualFold(strings.TrimSpace(authKind), "api_key") {
|
||||
return ""
|
||||
}
|
||||
|
||||
attrs := auth.Attributes
|
||||
compatName := ""
|
||||
providerKey := ""
|
||||
if len(attrs) > 0 {
|
||||
compatName = strings.TrimSpace(attrs["compat_name"])
|
||||
providerKey = strings.TrimSpace(attrs["provider_key"])
|
||||
}
|
||||
if compatName != "" || strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") {
|
||||
return resolveOpenAICompatAPIKeyProxyURL(cfg, auth, strings.TrimSpace(authAccount), providerKey, compatName)
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(auth.Provider)) {
|
||||
case "gemini":
|
||||
if entry := resolveAPIKeyConfig(cfg.GeminiKey, auth); entry != nil {
|
||||
return strings.TrimSpace(entry.ProxyURL)
|
||||
}
|
||||
case "claude":
|
||||
if entry := resolveAPIKeyConfig(cfg.ClaudeKey, auth); entry != nil {
|
||||
return strings.TrimSpace(entry.ProxyURL)
|
||||
}
|
||||
case "codex":
|
||||
if entry := resolveAPIKeyConfig(cfg.CodexKey, auth); entry != nil {
|
||||
return strings.TrimSpace(entry.ProxyURL)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveOpenAICompatAPIKeyProxyURL(cfg *config.Config, auth *coreauth.Auth, apiKey, providerKey, compatName string) string {
|
||||
if cfg == nil || auth == nil {
|
||||
return ""
|
||||
}
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" {
|
||||
return ""
|
||||
}
|
||||
candidates := make([]string, 0, 3)
|
||||
if v := strings.TrimSpace(compatName); v != "" {
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
if v := strings.TrimSpace(providerKey); v != "" {
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
if v := strings.TrimSpace(auth.Provider); v != "" {
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
compat := &cfg.OpenAICompatibility[i]
|
||||
if compat.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
|
||||
for j := range compat.APIKeyEntries {
|
||||
entry := &compat.APIKeyEntries[j]
|
||||
if strings.EqualFold(strings.TrimSpace(entry.APIKey), apiKey) {
|
||||
return strings.TrimSpace(entry.ProxyURL)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildProxyTransport(proxyStr string) *http.Transport {
|
||||
transport, _, errBuild := proxyutil.BuildHTTPTransport(proxyStr)
|
||||
if errBuild != nil {
|
||||
log.WithError(errBuild).Debug("build proxy transport failed")
|
||||
return nil
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
@@ -2,172 +2,211 @@ package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
)
|
||||
|
||||
type memoryAuthStore struct {
|
||||
mu sync.Mutex
|
||||
items map[string]*coreauth.Auth
|
||||
}
|
||||
func TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func (s *memoryAuthStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
|
||||
_ = ctx
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]*coreauth.Auth, 0, len(s.items))
|
||||
for _, a := range s.items {
|
||||
out = append(out, a.Clone())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memoryAuthStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
|
||||
_ = ctx
|
||||
if auth == nil {
|
||||
return "", nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.items == nil {
|
||||
s.items = make(map[string]*coreauth.Auth)
|
||||
}
|
||||
s.items[auth.ID] = auth.Clone()
|
||||
s.mu.Unlock()
|
||||
return auth.ID, nil
|
||||
}
|
||||
|
||||
func (s *memoryAuthStore) Delete(ctx context.Context, id string) error {
|
||||
_ = ctx
|
||||
s.mu.Lock()
|
||||
delete(s.items, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResolveTokenForAuth_Antigravity_RefreshesExpiredToken(t *testing.T) {
|
||||
var callCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
t.Fatalf("unexpected content-type: %s", ct)
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
values, err := url.ParseQuery(string(bodyBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("parse form: %v", err)
|
||||
}
|
||||
if values.Get("grant_type") != "refresh_token" {
|
||||
t.Fatalf("unexpected grant_type: %s", values.Get("grant_type"))
|
||||
}
|
||||
if values.Get("refresh_token") != "rt" {
|
||||
t.Fatalf("unexpected refresh_token: %s", values.Get("refresh_token"))
|
||||
}
|
||||
if values.Get("client_id") != antigravityOAuthClientID {
|
||||
t.Fatalf("unexpected client_id: %s", values.Get("client_id"))
|
||||
}
|
||||
if values.Get("client_secret") != antigravityOAuthClientSecret {
|
||||
t.Fatalf("unexpected client_secret")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-token",
|
||||
"refresh_token": "rt2",
|
||||
"expires_in": int64(3600),
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
originalURL := antigravityOAuthTokenURL
|
||||
antigravityOAuthTokenURL = srv.URL
|
||||
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
||||
|
||||
store := &memoryAuthStore{}
|
||||
manager := coreauth.NewManager(store, nil, nil)
|
||||
|
||||
auth := &coreauth.Auth{
|
||||
ID: "antigravity-test.json",
|
||||
FileName: "antigravity-test.json",
|
||||
Provider: "antigravity",
|
||||
Metadata: map[string]any{
|
||||
"type": "antigravity",
|
||||
"access_token": "old-token",
|
||||
"refresh_token": "rt",
|
||||
"expires_in": int64(3600),
|
||||
"timestamp": time.Now().Add(-2 * time.Hour).UnixMilli(),
|
||||
"expired": time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"},
|
||||
},
|
||||
}
|
||||
if _, err := manager.Register(context.Background(), auth); err != nil {
|
||||
t.Fatalf("register auth: %v", err)
|
||||
|
||||
transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "direct"})
|
||||
httpTransport, ok := transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", transport)
|
||||
}
|
||||
if httpTransport.Proxy != nil {
|
||||
t.Fatal("expected direct transport to disable proxy function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICallTransportInvalidAuthFallsBackToGlobalProxy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"},
|
||||
},
|
||||
}
|
||||
|
||||
transport := h.apiCallTransport(&coreauth.Auth{ProxyURL: "bad-value"})
|
||||
httpTransport, ok := transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", transport)
|
||||
}
|
||||
|
||||
req, errRequest := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if errRequest != nil {
|
||||
t.Fatalf("http.NewRequest returned error: %v", errRequest)
|
||||
}
|
||||
|
||||
proxyURL, errProxy := httpTransport.Proxy(req)
|
||||
if errProxy != nil {
|
||||
t.Fatalf("httpTransport.Proxy returned error: %v", errProxy)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://global-proxy.example.com:8080" {
|
||||
t.Fatalf("proxy URL = %v, want http://global-proxy.example.com:8080", proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPICallTransportAPIKeyAuthFallsBackToConfigProxyURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://global-proxy.example.com:8080"},
|
||||
GeminiKey: []config.GeminiKey{{
|
||||
APIKey: "gemini-key",
|
||||
ProxyURL: "http://gemini-proxy.example.com:8080",
|
||||
}},
|
||||
ClaudeKey: []config.ClaudeKey{{
|
||||
APIKey: "claude-key",
|
||||
ProxyURL: "http://claude-proxy.example.com:8080",
|
||||
}},
|
||||
CodexKey: []config.CodexKey{{
|
||||
APIKey: "codex-key",
|
||||
ProxyURL: "http://codex-proxy.example.com:8080",
|
||||
}},
|
||||
OpenAICompatibility: []config.OpenAICompatibility{{
|
||||
Name: "bohe",
|
||||
BaseURL: "https://bohe.example.com",
|
||||
APIKeyEntries: []config.OpenAICompatibilityAPIKey{{
|
||||
APIKey: "compat-key",
|
||||
ProxyURL: "http://compat-proxy.example.com:8080",
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
auth *coreauth.Auth
|
||||
wantProxy string
|
||||
}{
|
||||
{
|
||||
name: "gemini",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "gemini",
|
||||
Attributes: map[string]string{"api_key": "gemini-key"},
|
||||
},
|
||||
wantProxy: "http://gemini-proxy.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "claude",
|
||||
Attributes: map[string]string{"api_key": "claude-key"},
|
||||
},
|
||||
wantProxy: "http://claude-proxy.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "codex",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "codex",
|
||||
Attributes: map[string]string{"api_key": "codex-key"},
|
||||
},
|
||||
wantProxy: "http://codex-proxy.example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "openai-compatibility",
|
||||
auth: &coreauth.Auth{
|
||||
Provider: "bohe",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "compat-key",
|
||||
"compat_name": "bohe",
|
||||
"provider_key": "bohe",
|
||||
},
|
||||
},
|
||||
wantProxy: "http://compat-proxy.example.com:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := h.apiCallTransport(tc.auth)
|
||||
httpTransport, ok := transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", transport)
|
||||
}
|
||||
|
||||
req, errRequest := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if errRequest != nil {
|
||||
t.Fatalf("http.NewRequest returned error: %v", errRequest)
|
||||
}
|
||||
|
||||
proxyURL, errProxy := httpTransport.Proxy(req)
|
||||
if errProxy != nil {
|
||||
t.Fatalf("httpTransport.Proxy returned error: %v", errProxy)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != tc.wantProxy {
|
||||
t.Fatalf("proxy URL = %v, want %s", proxyURL, tc.wantProxy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthByIndexDistinguishesSharedAPIKeysAcrossProviders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
geminiAuth := &coreauth.Auth{
|
||||
ID: "gemini:apikey:123",
|
||||
Provider: "gemini",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "shared-key",
|
||||
},
|
||||
}
|
||||
compatAuth := &coreauth.Auth{
|
||||
ID: "openai-compatibility:bohe:456",
|
||||
Provider: "bohe",
|
||||
Label: "bohe",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "shared-key",
|
||||
"compat_name": "bohe",
|
||||
"provider_key": "bohe",
|
||||
},
|
||||
}
|
||||
|
||||
if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil {
|
||||
t.Fatalf("register gemini auth: %v", errRegister)
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), compatAuth); errRegister != nil {
|
||||
t.Fatalf("register compat auth: %v", errRegister)
|
||||
}
|
||||
|
||||
geminiIndex := geminiAuth.EnsureIndex()
|
||||
compatIndex := compatAuth.EnsureIndex()
|
||||
if geminiIndex == compatIndex {
|
||||
t.Fatalf("shared api key produced duplicate auth_index %q", geminiIndex)
|
||||
}
|
||||
|
||||
h := &Handler{authManager: manager}
|
||||
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTokenForAuth: %v", err)
|
||||
|
||||
gotGemini := h.authByIndex(geminiIndex)
|
||||
if gotGemini == nil {
|
||||
t.Fatal("expected gemini auth by index")
|
||||
}
|
||||
if token != "new-token" {
|
||||
t.Fatalf("expected refreshed token, got %q", token)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("expected 1 refresh call, got %d", callCount)
|
||||
if gotGemini.ID != geminiAuth.ID {
|
||||
t.Fatalf("authByIndex(gemini) returned %q, want %q", gotGemini.ID, geminiAuth.ID)
|
||||
}
|
||||
|
||||
updated, ok := manager.GetByID(auth.ID)
|
||||
if !ok || updated == nil {
|
||||
t.Fatalf("expected auth in manager after update")
|
||||
gotCompat := h.authByIndex(compatIndex)
|
||||
if gotCompat == nil {
|
||||
t.Fatal("expected compat auth by index")
|
||||
}
|
||||
if got := tokenValueFromMetadata(updated.Metadata); got != "new-token" {
|
||||
t.Fatalf("expected manager metadata updated, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenForAuth_Antigravity_SkipsRefreshWhenTokenValid(t *testing.T) {
|
||||
var callCount int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
originalURL := antigravityOAuthTokenURL
|
||||
antigravityOAuthTokenURL = srv.URL
|
||||
t.Cleanup(func() { antigravityOAuthTokenURL = originalURL })
|
||||
|
||||
auth := &coreauth.Auth{
|
||||
ID: "antigravity-valid.json",
|
||||
FileName: "antigravity-valid.json",
|
||||
Provider: "antigravity",
|
||||
Metadata: map[string]any{
|
||||
"type": "antigravity",
|
||||
"access_token": "ok-token",
|
||||
"expired": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
h := &Handler{}
|
||||
token, err := h.resolveTokenForAuth(context.Background(), auth)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTokenForAuth: %v", err)
|
||||
}
|
||||
if token != "ok-token" {
|
||||
t.Fatalf("expected existing token, got %q", token)
|
||||
}
|
||||
if callCount != 0 {
|
||||
t.Fatalf("expected no refresh calls, got %d", callCount)
|
||||
if gotCompat.ID != compatAuth.ID {
|
||||
t.Fatalf("authByIndex(compat) returned %q, want %q", gotCompat.ID, compatAuth.ID)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestUploadAuthFile_BatchMultipart(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
|
||||
files := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "alpha.json", content: `{"type":"codex","email":"alpha@example.com"}`},
|
||||
{name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for _, file := range files {
|
||||
part, err := writer.CreateFormFile("file", file.name)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create multipart file: %v", err)
|
||||
}
|
||||
if _, err = part.Write([]byte(file.content)); err != nil {
|
||||
t.Fatalf("failed to write multipart content: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
ctx.Request = req
|
||||
|
||||
h.UploadAuthFile(ctx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected upload status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got, ok := payload["uploaded"].(float64); !ok || int(got) != len(files) {
|
||||
t.Fatalf("expected uploaded=%d, got %#v", len(files), payload["uploaded"])
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
fullPath := filepath.Join(authDir, file.name)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected uploaded file %s to exist: %v", file.name, err)
|
||||
}
|
||||
if string(data) != file.content {
|
||||
t.Fatalf("expected file %s content %q, got %q", file.name, file.content, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
auths := manager.List()
|
||||
if len(auths) != len(files) {
|
||||
t.Fatalf("expected %d auth entries, got %d", len(files), len(auths))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAuthFile_BatchMultipart_InvalidJSONDoesNotOverwriteExistingFile(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
|
||||
existingName := "alpha.json"
|
||||
existingContent := `{"type":"codex","email":"alpha@example.com"}`
|
||||
if err := os.WriteFile(filepath.Join(authDir, existingName), []byte(existingContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to seed existing auth file: %v", err)
|
||||
}
|
||||
|
||||
files := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: existingName, content: `{"type":"codex"`},
|
||||
{name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for _, file := range files {
|
||||
part, err := writer.CreateFormFile("file", file.name)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create multipart file: %v", err)
|
||||
}
|
||||
if _, err = part.Write([]byte(file.content)); err != nil {
|
||||
t.Fatalf("failed to write multipart content: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
ctx.Request = req
|
||||
|
||||
h.UploadAuthFile(ctx)
|
||||
|
||||
if rec.Code != http.StatusMultiStatus {
|
||||
t.Fatalf("expected upload status %d, got %d with body %s", http.StatusMultiStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(authDir, existingName))
|
||||
if err != nil {
|
||||
t.Fatalf("expected existing auth file to remain readable: %v", err)
|
||||
}
|
||||
if string(data) != existingContent {
|
||||
t.Fatalf("expected existing auth file to remain %q, got %q", existingContent, string(data))
|
||||
}
|
||||
|
||||
betaData, err := os.ReadFile(filepath.Join(authDir, "beta.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid auth file to be created: %v", err)
|
||||
}
|
||||
if string(betaData) != files[1].content {
|
||||
t.Fatalf("expected beta auth file content %q, got %q", files[1].content, string(betaData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAuthFile_BatchQuery(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
files := []string{"alpha.json", "beta.json"}
|
||||
for _, name := range files {
|
||||
if err := os.WriteFile(filepath.Join(authDir, name), []byte(`{"type":"codex"}`), 0o600); err != nil {
|
||||
t.Fatalf("failed to write auth file %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(
|
||||
http.MethodDelete,
|
||||
"/v0/management/auth-files?name="+url.QueryEscape(files[0])+"&name="+url.QueryEscape(files[1]),
|
||||
nil,
|
||||
)
|
||||
ctx.Request = req
|
||||
|
||||
h.DeleteAuthFile(ctx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got, ok := payload["deleted"].(float64); !ok || int(got) != len(files) {
|
||||
t.Fatalf("expected deleted=%d, got %#v", len(files), payload["deleted"])
|
||||
}
|
||||
|
||||
for _, name := range files {
|
||||
if _, err := os.Stat(filepath.Join(authDir, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected auth file %s to be removed, stat err: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
authDir := filepath.Join(tempDir, "auth")
|
||||
externalDir := filepath.Join(tempDir, "external")
|
||||
if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {
|
||||
t.Fatalf("failed to create auth dir: %v", errMkdirAuth)
|
||||
}
|
||||
if errMkdirExternal := os.MkdirAll(externalDir, 0o700); errMkdirExternal != nil {
|
||||
t.Fatalf("failed to create external dir: %v", errMkdirExternal)
|
||||
}
|
||||
|
||||
fileName := "codex-user@example.com-plus.json"
|
||||
shadowPath := filepath.Join(authDir, fileName)
|
||||
realPath := filepath.Join(externalDir, fileName)
|
||||
if errWriteShadow := os.WriteFile(shadowPath, []byte(`{"type":"codex","email":"shadow@example.com"}`), 0o600); errWriteShadow != nil {
|
||||
t.Fatalf("failed to write shadow file: %v", errWriteShadow)
|
||||
}
|
||||
if errWriteReal := os.WriteFile(realPath, []byte(`{"type":"codex","email":"real@example.com"}`), 0o600); errWriteReal != nil {
|
||||
t.Fatalf("failed to write real file: %v", errWriteReal)
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: "legacy/" + fileName,
|
||||
FileName: fileName,
|
||||
Provider: "codex",
|
||||
Status: coreauth.StatusError,
|
||||
Unavailable: true,
|
||||
Attributes: map[string]string{
|
||||
"path": realPath,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "codex",
|
||||
"email": "real@example.com",
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
deleteRec := httptest.NewRecorder()
|
||||
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||
deleteCtx.Request = deleteReq
|
||||
h.DeleteAuthFile(deleteCtx)
|
||||
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
if _, errStatReal := os.Stat(realPath); !os.IsNotExist(errStatReal) {
|
||||
t.Fatalf("expected managed auth file to be removed, stat err: %v", errStatReal)
|
||||
}
|
||||
if _, errStatShadow := os.Stat(shadowPath); errStatShadow != nil {
|
||||
t.Fatalf("expected shadow auth file to remain, stat err: %v", errStatShadow)
|
||||
}
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
listCtx, _ := gin.CreateTestContext(listRec)
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
|
||||
listCtx.Request = listReq
|
||||
h.ListAuthFiles(listCtx)
|
||||
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, listRec.Code, listRec.Body.String())
|
||||
}
|
||||
var listPayload map[string]any
|
||||
if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listPayload); errUnmarshal != nil {
|
||||
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
|
||||
}
|
||||
filesRaw, ok := listPayload["files"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected files array, payload: %#v", listPayload)
|
||||
}
|
||||
if len(filesRaw) != 0 {
|
||||
t.Fatalf("expected removed auth to be hidden from list, got %d entries", len(filesRaw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAuthFile_FallbackToAuthDirPath(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
fileName := "fallback-user.json"
|
||||
filePath := filepath.Join(authDir, fileName)
|
||||
if errWrite := os.WriteFile(filePath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
deleteRec := httptest.NewRecorder()
|
||||
deleteCtx, _ := gin.CreateTestContext(deleteRec)
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/v0/management/auth-files?name="+url.QueryEscape(fileName), nil)
|
||||
deleteCtx.Request = deleteReq
|
||||
h.DeleteAuthFile(deleteCtx)
|
||||
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
if _, errStat := os.Stat(filePath); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("expected auth file to be removed from auth dir, stat err: %v", errStat)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestDownloadAuthFile_ReturnsFile(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
fileName := "download-user.json"
|
||||
expected := []byte(`{"type":"codex"}`)
|
||||
if err := os.WriteFile(filepath.Join(authDir, fileName), expected, 0o600); err != nil {
|
||||
t.Fatalf("failed to write auth file: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(fileName), nil)
|
||||
h.DownloadAuthFile(ctx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected download status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.Bytes(); string(got) != string(expected) {
|
||||
t.Fatalf("unexpected download content: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAuthFile_RejectsPathSeparators(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
|
||||
|
||||
for _, name := range []string{
|
||||
"../external/secret.json",
|
||||
`..\\external\\secret.json`,
|
||||
"nested/secret.json",
|
||||
`nested\\secret.json`,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(name), nil)
|
||||
h.DownloadAuthFile(ctx)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected %d for name %q, got %d with body %s", http.StatusBadRequest, name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build windows
|
||||
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
authDir := filepath.Join(tempDir, "auth")
|
||||
externalDir := filepath.Join(tempDir, "external")
|
||||
if err := os.MkdirAll(authDir, 0o700); err != nil {
|
||||
t.Fatalf("failed to create auth dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(externalDir, 0o700); err != nil {
|
||||
t.Fatalf("failed to create external dir: %v", err)
|
||||
}
|
||||
|
||||
secretName := "secret.json"
|
||||
secretPath := filepath.Join(externalDir, secretName)
|
||||
if err := os.WriteFile(secretPath, []byte(`{"secret":true}`), 0o600); err != nil {
|
||||
t.Fatalf("failed to write external file: %v", err)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v0/management/auth-files/download?name="+url.QueryEscape("../external/"+secretName),
|
||||
nil,
|
||||
)
|
||||
h.DownloadAuthFile(ctx)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d with body %s", http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestPatchAuthFileFields_MergeHeadersAndDeleteEmptyValues(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
store := &memoryAuthStore{}
|
||||
manager := coreauth.NewManager(store, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: "test.json",
|
||||
FileName: "test.json",
|
||||
Provider: "claude",
|
||||
Attributes: map[string]string{
|
||||
"path": "/tmp/test.json",
|
||||
"header:X-Old": "old",
|
||||
"header:X-Remove": "gone",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "claude",
|
||||
"headers": map[string]any{
|
||||
"X-Old": "old",
|
||||
"X-Remove": "gone",
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
|
||||
|
||||
body := `{"name":"test.json","prefix":"p1","proxy_url":"http://proxy.local","headers":{"X-Old":"new","X-New":"v","X-Remove":" ","X-Nope":""}}`
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx.Request = req
|
||||
h.PatchAuthFileFields(ctx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, ok := manager.GetByID("test.json")
|
||||
if !ok || updated == nil {
|
||||
t.Fatalf("expected auth record to exist after patch")
|
||||
}
|
||||
|
||||
if updated.Prefix != "p1" {
|
||||
t.Fatalf("prefix = %q, want %q", updated.Prefix, "p1")
|
||||
}
|
||||
if updated.ProxyURL != "http://proxy.local" {
|
||||
t.Fatalf("proxy_url = %q, want %q", updated.ProxyURL, "http://proxy.local")
|
||||
}
|
||||
|
||||
if updated.Metadata == nil {
|
||||
t.Fatalf("expected metadata to be non-nil")
|
||||
}
|
||||
if got, _ := updated.Metadata["prefix"].(string); got != "p1" {
|
||||
t.Fatalf("metadata.prefix = %q, want %q", got, "p1")
|
||||
}
|
||||
if got, _ := updated.Metadata["proxy_url"].(string); got != "http://proxy.local" {
|
||||
t.Fatalf("metadata.proxy_url = %q, want %q", got, "http://proxy.local")
|
||||
}
|
||||
|
||||
headersMeta, ok := updated.Metadata["headers"].(map[string]any)
|
||||
if !ok {
|
||||
raw, _ := json.Marshal(updated.Metadata["headers"])
|
||||
t.Fatalf("metadata.headers = %T (%s), want map[string]any", updated.Metadata["headers"], string(raw))
|
||||
}
|
||||
if got := headersMeta["X-Old"]; got != "new" {
|
||||
t.Fatalf("metadata.headers.X-Old = %#v, want %q", got, "new")
|
||||
}
|
||||
if got := headersMeta["X-New"]; got != "v" {
|
||||
t.Fatalf("metadata.headers.X-New = %#v, want %q", got, "v")
|
||||
}
|
||||
if _, ok := headersMeta["X-Remove"]; ok {
|
||||
t.Fatalf("expected metadata.headers.X-Remove to be deleted")
|
||||
}
|
||||
if _, ok := headersMeta["X-Nope"]; ok {
|
||||
t.Fatalf("expected metadata.headers.X-Nope to be absent")
|
||||
}
|
||||
|
||||
if got := updated.Attributes["header:X-Old"]; got != "new" {
|
||||
t.Fatalf("attrs header:X-Old = %q, want %q", got, "new")
|
||||
}
|
||||
if got := updated.Attributes["header:X-New"]; got != "v" {
|
||||
t.Fatalf("attrs header:X-New = %q, want %q", got, "v")
|
||||
}
|
||||
if _, ok := updated.Attributes["header:X-Remove"]; ok {
|
||||
t.Fatalf("expected attrs header:X-Remove to be deleted")
|
||||
}
|
||||
if _, ok := updated.Attributes["header:X-Nope"]; ok {
|
||||
t.Fatalf("expected attrs header:X-Nope to be absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAuthFileFields_HeadersEmptyMapIsNoop(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
store := &memoryAuthStore{}
|
||||
manager := coreauth.NewManager(store, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: "noop.json",
|
||||
FileName: "noop.json",
|
||||
Provider: "claude",
|
||||
Attributes: map[string]string{
|
||||
"path": "/tmp/noop.json",
|
||||
"header:X-Kee": "1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "claude",
|
||||
"headers": map[string]any{
|
||||
"X-Kee": "1",
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
|
||||
|
||||
body := `{"name":"noop.json","note":"hello","headers":{}}`
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx.Request = req
|
||||
h.PatchAuthFileFields(ctx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
updated, ok := manager.GetByID("noop.json")
|
||||
if !ok || updated == nil {
|
||||
t.Fatalf("expected auth record to exist after patch")
|
||||
}
|
||||
if got := updated.Attributes["header:X-Kee"]; got != "1" {
|
||||
t.Fatalf("attrs header:X-Kee = %q, want %q", got, "1")
|
||||
}
|
||||
headersMeta, ok := updated.Metadata["headers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata.headers to remain a map, got %T", updated.Metadata["headers"])
|
||||
}
|
||||
if got := headersMeta["X-Kee"]; got != "1" {
|
||||
t.Fatalf("metadata.headers.X-Kee = %#v, want %q", got, "1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestListAuthFiles_IncludesProjectIDFromManager(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
fileName := "gemini-user@example.com-project-a.json"
|
||||
filePath := filepath.Join(authDir, fileName)
|
||||
if errWrite := os.WriteFile(filePath, []byte(`{"type":"gemini","email":"user@example.com","project_id":"project-a"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
FileName: fileName,
|
||||
Provider: "gemini-cli",
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: map[string]string{
|
||||
"path": filePath,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "gemini",
|
||||
"email": "user@example.com",
|
||||
"project_id": "project-a",
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
entry := firstAuthFileEntry(t, h)
|
||||
if got := entry["project_id"]; got != "project-a" {
|
||||
t.Fatalf("expected project_id %q, got %#v", "project-a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuthFilesFromDisk_IncludesProjectID(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
authDir := t.TempDir()
|
||||
filePath := filepath.Join(authDir, "gemini-user@example.com-project-a.json")
|
||||
if errWrite := os.WriteFile(filePath, []byte(`{"type":"gemini","email":"user@example.com","project_id":"project-a"}`), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write auth file: %v", errWrite)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||
|
||||
entry := firstAuthFileEntry(t, h)
|
||||
if got := entry["project_id"]; got != "project-a" {
|
||||
t.Fatalf("expected project_id %q, got %#v", "project-a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func firstAuthFileEntry(t *testing.T, h *Handler) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(rec)
|
||||
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
|
||||
|
||||
h.ListAuthFiles(ginCtx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
|
||||
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
|
||||
}
|
||||
filesRaw, ok := payload["files"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected files array, payload: %#v", payload)
|
||||
}
|
||||
if len(filesRaw) != 1 {
|
||||
t.Fatalf("expected 1 auth entry, got %d", len(filesRaw))
|
||||
}
|
||||
fileEntry, ok := filesRaw[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected file entry object, got %#v", filesRaw[0])
|
||||
}
|
||||
return fileEntry
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
manager := coreauth.NewManager(nil, nil, nil)
|
||||
record := &coreauth.Auth{
|
||||
ID: "runtime-only-auth-1",
|
||||
Provider: "codex",
|
||||
Attributes: map[string]string{
|
||||
"runtime_only": "true",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "codex",
|
||||
},
|
||||
}
|
||||
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
|
||||
t.Fatalf("failed to register auth record: %v", errRegister)
|
||||
}
|
||||
|
||||
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
|
||||
h.tokenStore = &memoryAuthStore{}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
|
||||
ginCtx.Request = req
|
||||
|
||||
h.ListAuthFiles(ginCtx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
|
||||
t.Fatalf("failed to decode list payload: %v", errUnmarshal)
|
||||
}
|
||||
filesRaw, ok := payload["files"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected files array, payload: %#v", payload)
|
||||
}
|
||||
if len(filesRaw) != 1 {
|
||||
t.Fatalf("expected 1 auth entry, got %d", len(filesRaw))
|
||||
}
|
||||
|
||||
fileEntry, ok := filesRaw[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected file entry object, got %#v", filesRaw[0])
|
||||
}
|
||||
|
||||
if _, ok := fileEntry["success"].(float64); !ok {
|
||||
t.Fatalf("expected success number, got %#v", fileEntry["success"])
|
||||
}
|
||||
if _, ok := fileEntry["failed"].(float64); !ok {
|
||||
t.Fatalf("expected failed number, got %#v", fileEntry["failed"])
|
||||
}
|
||||
|
||||
recentRaw, ok := fileEntry["recent_requests"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"])
|
||||
}
|
||||
if len(recentRaw) != 20 {
|
||||
t.Fatalf("expected 20 recent_requests buckets, got %d", len(recentRaw))
|
||||
}
|
||||
for idx, item := range recentRaw {
|
||||
bucket, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected bucket object at %d, got %#v", idx, item)
|
||||
}
|
||||
if _, ok := bucket["time"].(string); !ok {
|
||||
t.Fatalf("expected bucket time string at %d, got %#v", idx, bucket["time"])
|
||||
}
|
||||
if _, ok := bucket["success"].(float64); !ok {
|
||||
t.Fatalf("expected bucket success number at %d, got %#v", idx, bucket["success"])
|
||||
}
|
||||
if _, ok := bucket["failed"].(float64); !ok {
|
||||
t.Fatalf("expected bucket failed number at %d, got %#v", idx, bucket["failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
|
||||
)
|
||||
|
||||
type geminiKeyWithAuthIndex struct {
|
||||
config.GeminiKey
|
||||
AuthIndex string `json:"auth-index,omitempty"`
|
||||
}
|
||||
|
||||
type claudeKeyWithAuthIndex struct {
|
||||
config.ClaudeKey
|
||||
AuthIndex string `json:"auth-index,omitempty"`
|
||||
}
|
||||
|
||||
type codexKeyWithAuthIndex struct {
|
||||
config.CodexKey
|
||||
AuthIndex string `json:"auth-index,omitempty"`
|
||||
}
|
||||
|
||||
type vertexCompatKeyWithAuthIndex struct {
|
||||
config.VertexCompatKey
|
||||
AuthIndex string `json:"auth-index,omitempty"`
|
||||
}
|
||||
|
||||
type openAICompatibilityAPIKeyWithAuthIndex struct {
|
||||
config.OpenAICompatibilityAPIKey
|
||||
AuthIndex string `json:"auth-index,omitempty"`
|
||||
}
|
||||
|
||||
type openAICompatibilityWithAuthIndex struct {
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
BaseURL string `json:"base-url"`
|
||||
APIKeyEntries []openAICompatibilityAPIKeyWithAuthIndex `json:"api-key-entries,omitempty"`
|
||||
Models []config.OpenAICompatibilityModel `json:"models,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
AuthIndex string `json:"auth-index,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) liveAuthIndexByID() map[string]string {
|
||||
out := map[string]string{}
|
||||
if h == nil {
|
||||
return out
|
||||
}
|
||||
h.mu.Lock()
|
||||
manager := h.authManager
|
||||
h.mu.Unlock()
|
||||
if manager == nil {
|
||||
return out
|
||||
}
|
||||
// authManager.List() returns clones, so EnsureIndex only affects these copies.
|
||||
for _, auth := range manager.List() {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(auth.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.TrimSpace(auth.Index)
|
||||
if idx == "" {
|
||||
idx = auth.EnsureIndex()
|
||||
}
|
||||
if idx == "" {
|
||||
continue
|
||||
}
|
||||
out[id] = idx
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
liveIndexByID := h.liveAuthIndexByID()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
idGen := synthesizer.NewStableIDGenerator()
|
||||
out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey))
|
||||
for i := range h.cfg.GeminiKey {
|
||||
entry := h.cfg.GeminiKey[i]
|
||||
authIndex := ""
|
||||
if key := strings.TrimSpace(entry.APIKey); key != "" {
|
||||
id, _ := idGen.Next("gemini:apikey", key, entry.BaseURL)
|
||||
authIndex = liveIndexByID[id]
|
||||
}
|
||||
out[i] = geminiKeyWithAuthIndex{
|
||||
GeminiKey: entry,
|
||||
AuthIndex: authIndex,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
liveIndexByID := h.liveAuthIndexByID()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
idGen := synthesizer.NewStableIDGenerator()
|
||||
out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey))
|
||||
for i := range h.cfg.ClaudeKey {
|
||||
entry := h.cfg.ClaudeKey[i]
|
||||
authIndex := ""
|
||||
if key := strings.TrimSpace(entry.APIKey); key != "" {
|
||||
id, _ := idGen.Next("claude:apikey", key, entry.BaseURL)
|
||||
authIndex = liveIndexByID[id]
|
||||
}
|
||||
out[i] = claudeKeyWithAuthIndex{
|
||||
ClaudeKey: entry,
|
||||
AuthIndex: authIndex,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
liveIndexByID := h.liveAuthIndexByID()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
idGen := synthesizer.NewStableIDGenerator()
|
||||
out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey))
|
||||
for i := range h.cfg.CodexKey {
|
||||
entry := h.cfg.CodexKey[i]
|
||||
authIndex := ""
|
||||
if key := strings.TrimSpace(entry.APIKey); key != "" {
|
||||
id, _ := idGen.Next("codex:apikey", key, entry.BaseURL)
|
||||
authIndex = liveIndexByID[id]
|
||||
}
|
||||
out[i] = codexKeyWithAuthIndex{
|
||||
CodexKey: entry,
|
||||
AuthIndex: authIndex,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
liveIndexByID := h.liveAuthIndexByID()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
idGen := synthesizer.NewStableIDGenerator()
|
||||
out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey))
|
||||
for i := range h.cfg.VertexCompatAPIKey {
|
||||
entry := h.cfg.VertexCompatAPIKey[i]
|
||||
id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL)
|
||||
authIndex := liveIndexByID[id]
|
||||
out[i] = vertexCompatKeyWithAuthIndex{
|
||||
VertexCompatKey: entry,
|
||||
AuthIndex: authIndex,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
liveIndexByID := h.liveAuthIndexByID()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)
|
||||
out := make([]openAICompatibilityWithAuthIndex, len(normalized))
|
||||
idGen := synthesizer.NewStableIDGenerator()
|
||||
for i := range normalized {
|
||||
entry := normalized[i]
|
||||
providerName := strings.ToLower(strings.TrimSpace(entry.Name))
|
||||
if providerName == "" {
|
||||
providerName = "openai-compatibility"
|
||||
}
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
|
||||
response := openAICompatibilityWithAuthIndex{
|
||||
Name: entry.Name,
|
||||
Priority: entry.Priority,
|
||||
Disabled: entry.Disabled,
|
||||
Prefix: entry.Prefix,
|
||||
BaseURL: entry.BaseURL,
|
||||
Models: entry.Models,
|
||||
Headers: entry.Headers,
|
||||
AuthIndex: "",
|
||||
}
|
||||
if len(entry.APIKeyEntries) == 0 {
|
||||
id, _ := idGen.Next(idKind, entry.BaseURL)
|
||||
response.AuthIndex = liveIndexByID[id]
|
||||
} else {
|
||||
response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries))
|
||||
for j := range entry.APIKeyEntries {
|
||||
apiKeyEntry := entry.APIKeyEntries[j]
|
||||
id, _ := idGen.Next(idKind, apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL)
|
||||
response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{
|
||||
OpenAICompatibilityAPIKey: apiKeyEntry,
|
||||
AuthIndex: liveIndexByID[id],
|
||||
}
|
||||
}
|
||||
}
|
||||
out[i] = response
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
// Generic helpers for list[string]
|
||||
@@ -120,7 +120,7 @@ func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
||||
|
||||
// gemini-api-key: []GeminiKey
|
||||
func (h *Handler) GetGeminiKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
|
||||
c.JSON(200, gin.H{"gemini-api-key": h.geminiKeysWithAuthIndex()})
|
||||
}
|
||||
func (h *Handler) PutGeminiKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
@@ -139,9 +139,11 @@ func (h *Handler) PutGeminiKeys(c *gin.Context) {
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
||||
type geminiKeyPatch struct {
|
||||
@@ -161,6 +163,9 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
targetIndex := -1
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
||||
targetIndex = *body.Index
|
||||
@@ -187,7 +192,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
||||
if trimmed == "" {
|
||||
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
entry.APIKey = trimmed
|
||||
@@ -209,24 +214,53 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
||||
}
|
||||
h.cfg.GeminiKey[targetIndex] = entry
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
||||
for _, v := range h.cfg.GeminiKey {
|
||||
if v.APIKey != val {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
||||
for _, v := range h.cfg.GeminiKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) != len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = out
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persistLocked(c)
|
||||
} else {
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(out) != len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = out
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
} else {
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.GeminiKey {
|
||||
if strings.TrimSpace(h.cfg.GeminiKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount == 0 {
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
@@ -234,7 +268,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
||||
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -243,7 +277,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
||||
|
||||
// claude-api-key: []ClaudeKey
|
||||
func (h *Handler) GetClaudeKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
|
||||
c.JSON(200, gin.H{"claude-api-key": h.claudeKeysWithAuthIndex()})
|
||||
}
|
||||
func (h *Handler) PutClaudeKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
@@ -265,9 +299,11 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) {
|
||||
for i := range arr {
|
||||
normalizeClaudeKey(&arr[i])
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.cfg.ClaudeKey = arr
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
||||
type claudeKeyPatch struct {
|
||||
@@ -288,6 +324,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
targetIndex := -1
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
|
||||
targetIndex = *body.Index
|
||||
@@ -331,20 +370,47 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
||||
normalizeClaudeKey(&entry)
|
||||
h.cfg.ClaudeKey[targetIndex] = entry
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
||||
if val := c.Query("api-key"); val != "" {
|
||||
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
||||
for _, v := range h.cfg.ClaudeKey {
|
||||
if v.APIKey != val {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
|
||||
for _, v := range h.cfg.ClaudeKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
h.cfg.ClaudeKey = out
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.ClaudeKey {
|
||||
if strings.TrimSpace(h.cfg.ClaudeKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
if matchIndex != -1 {
|
||||
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...)
|
||||
}
|
||||
h.cfg.ClaudeKey = out
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
@@ -353,7 +419,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
||||
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
|
||||
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -362,7 +428,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
|
||||
|
||||
// openai-compatibility: []OpenAICompatibility
|
||||
func (h *Handler) GetOpenAICompat(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
|
||||
c.JSON(200, gin.H{"openai-compatibility": h.openAICompatibilityWithAuthIndex()})
|
||||
}
|
||||
func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
@@ -388,14 +454,17 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||
filtered = append(filtered, arr[i])
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.cfg.OpenAICompatibility = filtered
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
type openAICompatPatch struct {
|
||||
Name *string `json:"name"`
|
||||
Prefix *string `json:"prefix"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
BaseURL *string `json:"base-url"`
|
||||
APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
|
||||
Models *[]config.OpenAICompatibilityModel `json:"models"`
|
||||
@@ -410,6 +479,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
targetIndex := -1
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||
targetIndex = *body.Index
|
||||
@@ -435,12 +507,15 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
if body.Value.Prefix != nil {
|
||||
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
|
||||
}
|
||||
if body.Value.Disabled != nil {
|
||||
entry.Disabled = *body.Value.Disabled
|
||||
}
|
||||
if body.Value.BaseURL != nil {
|
||||
trimmed := strings.TrimSpace(*body.Value.BaseURL)
|
||||
if trimmed == "" {
|
||||
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
entry.BaseURL = trimmed
|
||||
@@ -457,10 +532,12 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
normalizeOpenAICompatibilityEntry(&entry)
|
||||
h.cfg.OpenAICompatibility[targetIndex] = entry
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if name := c.Query("name"); name != "" {
|
||||
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
||||
for _, v := range h.cfg.OpenAICompatibility {
|
||||
@@ -470,7 +547,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
||||
}
|
||||
h.cfg.OpenAICompatibility = out
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
@@ -479,7 +556,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
||||
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
|
||||
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -488,7 +565,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
||||
|
||||
// vertex-api-key: []VertexCompatKey
|
||||
func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
|
||||
c.JSON(200, gin.H{"vertex-api-key": h.vertexCompatKeysWithAuthIndex()})
|
||||
}
|
||||
func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
@@ -509,19 +586,26 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
|
||||
}
|
||||
for i := range arr {
|
||||
normalizeVertexCompatKey(&arr[i])
|
||||
if arr[i].APIKey == "" {
|
||||
c.JSON(400, gin.H{"error": fmt.Sprintf("vertex-api-key[%d].api-key is required", i)})
|
||||
return
|
||||
}
|
||||
}
|
||||
h.cfg.VertexCompatAPIKey = arr
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...)
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
type vertexCompatPatch struct {
|
||||
APIKey *string `json:"api-key"`
|
||||
Prefix *string `json:"prefix"`
|
||||
BaseURL *string `json:"base-url"`
|
||||
ProxyURL *string `json:"proxy-url"`
|
||||
Headers *map[string]string `json:"headers"`
|
||||
Models *[]config.VertexCompatModel `json:"models"`
|
||||
APIKey *string `json:"api-key"`
|
||||
Prefix *string `json:"prefix"`
|
||||
BaseURL *string `json:"base-url"`
|
||||
ProxyURL *string `json:"proxy-url"`
|
||||
Headers *map[string]string `json:"headers"`
|
||||
Models *[]config.VertexCompatModel `json:"models"`
|
||||
ExcludedModels *[]string `json:"excluded-models"`
|
||||
}
|
||||
var body struct {
|
||||
Index *int `json:"index"`
|
||||
@@ -532,6 +616,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
targetIndex := -1
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
|
||||
targetIndex = *body.Index
|
||||
@@ -558,7 +645,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
if trimmed == "" {
|
||||
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
entry.APIKey = trimmed
|
||||
@@ -571,7 +658,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
if trimmed == "" {
|
||||
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
entry.BaseURL = trimmed
|
||||
@@ -585,23 +672,53 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
|
||||
if body.Value.Models != nil {
|
||||
entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
|
||||
}
|
||||
if body.Value.ExcludedModels != nil {
|
||||
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
|
||||
}
|
||||
normalizeVertexCompatKey(&entry)
|
||||
h.cfg.VertexCompatAPIKey[targetIndex] = entry
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
|
||||
for _, v := range h.cfg.VertexCompatAPIKey {
|
||||
if v.APIKey != val {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
|
||||
for _, v := range h.cfg.VertexCompatAPIKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
h.cfg.VertexCompatAPIKey = out
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.VertexCompatAPIKey {
|
||||
if strings.TrimSpace(h.cfg.VertexCompatAPIKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
if matchIndex != -1 {
|
||||
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...)
|
||||
}
|
||||
h.cfg.VertexCompatAPIKey = out
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
@@ -610,7 +727,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
|
||||
if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
|
||||
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
|
||||
h.cfg.SanitizeVertexCompatKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -801,7 +918,7 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
|
||||
|
||||
// codex-api-key: []CodexKey
|
||||
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
||||
c.JSON(200, gin.H{"codex-api-key": h.codexKeysWithAuthIndex()})
|
||||
}
|
||||
func (h *Handler) PutCodexKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
@@ -830,9 +947,11 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.cfg.CodexKey = filtered
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
type codexKeyPatch struct {
|
||||
@@ -853,6 +972,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
targetIndex := -1
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||
targetIndex = *body.Index
|
||||
@@ -883,7 +1005,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
if trimmed == "" {
|
||||
h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
entry.BaseURL = trimmed
|
||||
@@ -903,20 +1025,47 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
normalizeCodexKey(&entry)
|
||||
h.cfg.CodexKey[targetIndex] = entry
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
||||
if val := c.Query("api-key"); val != "" {
|
||||
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||
for _, v := range h.cfg.CodexKey {
|
||||
if v.APIKey != val {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
|
||||
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
|
||||
base := strings.TrimSpace(baseRaw)
|
||||
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||
for _, v := range h.cfg.CodexKey {
|
||||
if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
h.cfg.CodexKey = out
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := range h.cfg.CodexKey {
|
||||
if strings.TrimSpace(h.cfg.CodexKey[i].APIKey) == val {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if matchCount > 1 {
|
||||
c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"})
|
||||
return
|
||||
}
|
||||
if matchIndex != -1 {
|
||||
h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...)
|
||||
}
|
||||
h.cfg.CodexKey = out
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
@@ -925,7 +1074,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
|
||||
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
|
||||
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
h.persistLocked(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1025,6 +1174,7 @@ func normalizeVertexCompatKey(entry *config.VertexCompatKey) {
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
||||
if len(entry.Models) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func writeTestConfigFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if errWrite := os.WriteFile(path, []byte("{}\n"), 0o600); errWrite != nil {
|
||||
t.Fatalf("failed to write test config: %v", errWrite)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
GeminiKey: []config.GeminiKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key", nil)
|
||||
|
||||
h.DeleteGeminiKey(c)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.GeminiKey); got != 2 {
|
||||
t.Fatalf("gemini keys len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGeminiKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
GeminiKey: []config.GeminiKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/gemini-api-key?api-key=shared-key&base-url=https://a.example.com", nil)
|
||||
|
||||
h.DeleteGeminiKey(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.GeminiKey); got != 1 {
|
||||
t.Fatalf("gemini keys len = %d, want 1", got)
|
||||
}
|
||||
if got := h.cfg.GeminiKey[0].BaseURL; got != "https://b.example.com" {
|
||||
t.Fatalf("remaining base-url = %q, want %q", got, "https://b.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteClaudeKey_DeletesEmptyBaseURLWhenExplicitlyProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
ClaudeKey: []config.ClaudeKey{
|
||||
{APIKey: "shared-key", BaseURL: ""},
|
||||
{APIKey: "shared-key", BaseURL: "https://claude.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/claude-api-key?api-key=shared-key&base-url=", nil)
|
||||
|
||||
h.DeleteClaudeKey(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.ClaudeKey); got != 1 {
|
||||
t.Fatalf("claude keys len = %d, want 1", got)
|
||||
}
|
||||
if got := h.cfg.ClaudeKey[0].BaseURL; got != "https://claude.example.com" {
|
||||
t.Fatalf("remaining base-url = %q, want %q", got, "https://claude.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVertexCompatKey_DeletesOnlyMatchingBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
VertexCompatAPIKey: []config.VertexCompatKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/vertex-api-key?api-key=shared-key&base-url=https://b.example.com", nil)
|
||||
|
||||
h.DeleteVertexCompatKey(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.VertexCompatAPIKey); got != 1 {
|
||||
t.Fatalf("vertex keys len = %d, want 1", got)
|
||||
}
|
||||
if got := h.cfg.VertexCompatAPIKey[0].BaseURL; got != "https://a.example.com" {
|
||||
t.Fatalf("remaining base-url = %q, want %q", got, "https://a.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCodexKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
CodexKey: []config.CodexKey{
|
||||
{APIKey: "shared-key", BaseURL: "https://a.example.com"},
|
||||
{APIKey: "shared-key", BaseURL: "https://b.example.com"},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/v0/management/codex-api-key?api-key=shared-key", nil)
|
||||
|
||||
h.DeleteCodexKey(c)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if got := len(h.cfg.CodexKey); got != 2 {
|
||||
t.Fatalf("codex keys len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -41,7 +40,6 @@ type Handler struct {
|
||||
attemptsMu sync.Mutex
|
||||
failedAttempts map[string]*attemptInfo // keyed by client IP
|
||||
authManager *coreauth.Manager
|
||||
usageStats *usage.RequestStatistics
|
||||
tokenStore coreauth.Store
|
||||
localPassword string
|
||||
allowRemoteOverride bool
|
||||
@@ -60,7 +58,6 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
|
||||
configFilePath: configFilePath,
|
||||
failedAttempts: make(map[string]*attemptInfo),
|
||||
authManager: manager,
|
||||
usageStats: usage.GetRequestStatistics(),
|
||||
tokenStore: sdkAuth.GetTokenStore(),
|
||||
allowRemoteOverride: envSecret != "",
|
||||
envSecret: envSecret,
|
||||
@@ -105,13 +102,24 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag
|
||||
}
|
||||
|
||||
// SetConfig updates the in-memory config reference when the server hot-reloads.
|
||||
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
|
||||
func (h *Handler) SetConfig(cfg *config.Config) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.cfg = cfg
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAuthManager updates the auth manager reference used by management endpoints.
|
||||
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
|
||||
|
||||
// SetUsageStatistics allows replacing the usage statistics reference.
|
||||
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
|
||||
func (h *Handler) SetAuthManager(manager *coreauth.Manager) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.authManager = manager
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
||||
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
||||
@@ -138,9 +146,6 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
|
||||
// All requests (local and remote) require a valid management key.
|
||||
// Additionally, remote access requires allow-remote-management=true.
|
||||
func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
const maxFailures = 5
|
||||
const banDuration = 30 * time.Minute
|
||||
|
||||
return func(c *gin.Context) {
|
||||
c.Header("X-CPA-VERSION", buildinfo.Version)
|
||||
c.Header("X-CPA-COMMIT", buildinfo.Commit)
|
||||
@@ -148,64 +153,6 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
|
||||
clientIP := c.ClientIP()
|
||||
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
|
||||
cfg := h.cfg
|
||||
var (
|
||||
allowRemote bool
|
||||
secretHash string
|
||||
)
|
||||
if cfg != nil {
|
||||
allowRemote = cfg.RemoteManagement.AllowRemote
|
||||
secretHash = cfg.RemoteManagement.SecretKey
|
||||
}
|
||||
if h.allowRemoteOverride {
|
||||
allowRemote = true
|
||||
}
|
||||
envSecret := h.envSecret
|
||||
|
||||
fail := func() {}
|
||||
if !localClient {
|
||||
h.attemptsMu.Lock()
|
||||
ai := h.failedAttempts[clientIP]
|
||||
if ai != nil {
|
||||
if !ai.blockedUntil.IsZero() {
|
||||
if time.Now().Before(ai.blockedUntil) {
|
||||
remaining := time.Until(ai.blockedUntil).Round(time.Second)
|
||||
h.attemptsMu.Unlock()
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
|
||||
return
|
||||
}
|
||||
// Ban expired, reset state
|
||||
ai.blockedUntil = time.Time{}
|
||||
ai.count = 0
|
||||
}
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
|
||||
if !allowRemote {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
fail = func() {
|
||||
h.attemptsMu.Lock()
|
||||
aip := h.failedAttempts[clientIP]
|
||||
if aip == nil {
|
||||
aip = &attemptInfo{}
|
||||
h.failedAttempts[clientIP] = aip
|
||||
}
|
||||
aip.count++
|
||||
aip.lastActivity = time.Now()
|
||||
if aip.count >= maxFailures {
|
||||
aip.blockedUntil = time.Now().Add(banDuration)
|
||||
aip.count = 0
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
}
|
||||
if secretHash == "" && envSecret == "" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
|
||||
return
|
||||
}
|
||||
|
||||
// Accept either Authorization: Bearer <key> or X-Management-Key
|
||||
var provided string
|
||||
@@ -221,61 +168,126 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
provided = c.GetHeader("X-Management-Key")
|
||||
}
|
||||
|
||||
if provided == "" {
|
||||
if !localClient {
|
||||
fail()
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
||||
allowed, statusCode, errMsg := h.AuthenticateManagementKey(clientIP, localClient, provided)
|
||||
if !allowed {
|
||||
c.AbortWithStatusJSON(statusCode, gin.H{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
if localClient {
|
||||
if lp := h.localPassword; lp != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
|
||||
if !localClient {
|
||||
h.attemptsMu.Lock()
|
||||
if ai := h.failedAttempts[clientIP]; ai != nil {
|
||||
ai.count = 0
|
||||
ai.blockedUntil = time.Time{}
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
|
||||
if !localClient {
|
||||
fail()
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
|
||||
return
|
||||
}
|
||||
|
||||
if !localClient {
|
||||
h.attemptsMu.Lock()
|
||||
if ai := h.failedAttempts[clientIP]; ai != nil {
|
||||
ai.count = 0
|
||||
ai.blockedUntil = time.Time{}
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticateManagementKey verifies the provided management key for the given client.
|
||||
// It mirrors the behaviour of Middleware() so non-HTTP callers can reuse the same logic.
|
||||
func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, provided string) (bool, int, string) {
|
||||
const maxFailures = 5
|
||||
const banDuration = 30 * time.Minute
|
||||
|
||||
if h == nil {
|
||||
return false, http.StatusForbidden, "remote management disabled"
|
||||
}
|
||||
|
||||
cfg := h.cfg
|
||||
var (
|
||||
allowRemote bool
|
||||
secretHash string
|
||||
)
|
||||
if cfg != nil {
|
||||
allowRemote = cfg.RemoteManagement.AllowRemote
|
||||
secretHash = cfg.RemoteManagement.SecretKey
|
||||
}
|
||||
if h.allowRemoteOverride {
|
||||
allowRemote = true
|
||||
}
|
||||
envSecret := h.envSecret
|
||||
|
||||
now := time.Now()
|
||||
h.attemptsMu.Lock()
|
||||
ai := h.failedAttempts[clientIP]
|
||||
if ai != nil && !ai.blockedUntil.IsZero() {
|
||||
if now.Before(ai.blockedUntil) {
|
||||
remaining := ai.blockedUntil.Sub(now).Round(time.Second)
|
||||
h.attemptsMu.Unlock()
|
||||
return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)
|
||||
}
|
||||
// Ban expired, reset state
|
||||
ai.blockedUntil = time.Time{}
|
||||
ai.count = 0
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
|
||||
if !localClient && !allowRemote {
|
||||
return false, http.StatusForbidden, "remote management disabled"
|
||||
}
|
||||
|
||||
fail := func() {
|
||||
h.attemptsMu.Lock()
|
||||
aip := h.failedAttempts[clientIP]
|
||||
if aip == nil {
|
||||
aip = &attemptInfo{}
|
||||
h.failedAttempts[clientIP] = aip
|
||||
}
|
||||
aip.count++
|
||||
aip.lastActivity = time.Now()
|
||||
if aip.count >= maxFailures {
|
||||
aip.blockedUntil = time.Now().Add(banDuration)
|
||||
aip.count = 0
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
|
||||
reset := func() {
|
||||
h.attemptsMu.Lock()
|
||||
if ai := h.failedAttempts[clientIP]; ai != nil {
|
||||
ai.count = 0
|
||||
ai.blockedUntil = time.Time{}
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
|
||||
if secretHash == "" && envSecret == "" {
|
||||
return false, http.StatusForbidden, "remote management key not set"
|
||||
}
|
||||
|
||||
if provided == "" {
|
||||
fail()
|
||||
return false, http.StatusUnauthorized, "missing management key"
|
||||
}
|
||||
|
||||
if localClient {
|
||||
if lp := h.localPassword; lp != "" {
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
|
||||
reset()
|
||||
return true, 0, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
|
||||
reset()
|
||||
return true, 0, ""
|
||||
}
|
||||
|
||||
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
|
||||
fail()
|
||||
return false, http.StatusUnauthorized, "invalid management key"
|
||||
}
|
||||
|
||||
reset()
|
||||
|
||||
return true, 0, ""
|
||||
}
|
||||
|
||||
// persist saves the current in-memory config to disk.
|
||||
func (h *Handler) persist(c *gin.Context) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.persistLocked(c)
|
||||
}
|
||||
|
||||
// persistLocked saves the current in-memory config to disk.
|
||||
// It expects the caller to hold h.mu.
|
||||
func (h *Handler) persistLocked(c *gin.Context) bool {
|
||||
// Preserve comments when writing
|
||||
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestAuthenticateManagementKey_LocalhostIPBan_BlocksCorrectKeyDuringBan(t *testing.T) {
|
||||
h := &Handler{
|
||||
cfg: &config.Config{},
|
||||
failedAttempts: make(map[string]*attemptInfo),
|
||||
envSecret: "test-secret",
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "wrong-secret")
|
||||
if allowed {
|
||||
t.Fatalf("expected auth to be denied at attempt %d", i+1)
|
||||
}
|
||||
if statusCode != http.StatusUnauthorized || errMsg != "invalid management key" {
|
||||
t.Fatalf("unexpected auth failure at attempt %d: status=%d msg=%q", i+1, statusCode, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "test-secret")
|
||||
if allowed {
|
||||
t.Fatalf("expected correct key to be denied while banned")
|
||||
}
|
||||
if statusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden status while banned, got %d", statusCode)
|
||||
}
|
||||
if !strings.HasPrefix(errMsg, "IP banned due to too many failed attempts. Try again in") {
|
||||
t.Fatalf("unexpected banned message: %q", errMsg)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
// GetStaticModelDefinitions returns static model metadata for a given channel.
|
||||
|
||||
@@ -79,7 +79,7 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if sessionStatus != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
|
||||
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": sessionStatus})
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(sessionProvider, canonicalProvider) {
|
||||
@@ -89,6 +89,11 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) {
|
||||
|
||||
if _, errWrite := WriteOAuthCallbackFileForPendingSession(h.cfg.AuthDir, canonicalProvider, state, code, errMsg); errWrite != nil {
|
||||
if errors.Is(errWrite, errOAuthSessionNotPending) {
|
||||
_, status, okSession := GetOAuthSession(state)
|
||||
if okSession && status != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": status})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,6 +190,21 @@ func IsOAuthSessionPending(state, provider string) bool {
|
||||
return oauthSessions.IsPending(state, provider)
|
||||
}
|
||||
|
||||
func oauthSessionErrorWithCause(message string, cause error) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
message = "Authentication failed"
|
||||
}
|
||||
if cause == nil {
|
||||
return message
|
||||
}
|
||||
detail := strings.TrimSpace(cause.Error())
|
||||
if detail == "" {
|
||||
return message
|
||||
}
|
||||
return message + ": " + detail
|
||||
}
|
||||
|
||||
func ValidateOAuthState(state string) error {
|
||||
trimmed := strings.TrimSpace(state)
|
||||
if trimmed == "" {
|
||||
@@ -225,12 +240,10 @@ func NormalizeOAuthProvider(provider string) (string, error) {
|
||||
return "codex", nil
|
||||
case "gemini", "google":
|
||||
return "gemini", nil
|
||||
case "iflow", "i-flow":
|
||||
return "iflow", nil
|
||||
case "antigravity", "anti-gravity":
|
||||
return "antigravity", nil
|
||||
case "qwen":
|
||||
return "qwen", nil
|
||||
case "xai", "x-ai", "x.ai", "grok":
|
||||
return "xai", nil
|
||||
default:
|
||||
return "", errUnsupportedOAuthFlow
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
type memoryAuthStore struct {
|
||||
mu sync.Mutex
|
||||
items map[string]*coreauth.Auth
|
||||
}
|
||||
|
||||
func (s *memoryAuthStore) List(_ context.Context) ([]*coreauth.Auth, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
out := make([]*coreauth.Auth, 0, len(s.items))
|
||||
for _, item := range s.items {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memoryAuthStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.items == nil {
|
||||
s.items = make(map[string]*coreauth.Auth)
|
||||
}
|
||||
s.items[auth.ID] = auth
|
||||
return auth.ID, nil
|
||||
}
|
||||
|
||||
func (s *memoryAuthStore) Delete(_ context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.items, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryAuthStore) SetBaseDir(string) {}
|
||||
@@ -2,78 +2,54 @@ package management
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
)
|
||||
|
||||
type usageExportPayload struct {
|
||||
Version int `json:"version"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
Usage usage.StatisticsSnapshot `json:"usage"`
|
||||
}
|
||||
type usageQueueRecord []byte
|
||||
|
||||
type usageImportPayload struct {
|
||||
Version int `json:"version"`
|
||||
Usage usage.StatisticsSnapshot `json:"usage"`
|
||||
}
|
||||
|
||||
// GetUsageStatistics returns the in-memory request statistics snapshot.
|
||||
func (h *Handler) GetUsageStatistics(c *gin.Context) {
|
||||
var snapshot usage.StatisticsSnapshot
|
||||
if h != nil && h.usageStats != nil {
|
||||
snapshot = h.usageStats.Snapshot()
|
||||
func (r usageQueueRecord) MarshalJSON() ([]byte, error) {
|
||||
if json.Valid(r) {
|
||||
return append([]byte(nil), r...), nil
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"usage": snapshot,
|
||||
"failed_requests": snapshot.FailureCount,
|
||||
})
|
||||
return json.Marshal(string(r))
|
||||
}
|
||||
|
||||
// ExportUsageStatistics returns a complete usage snapshot for backup/migration.
|
||||
func (h *Handler) ExportUsageStatistics(c *gin.Context) {
|
||||
var snapshot usage.StatisticsSnapshot
|
||||
if h != nil && h.usageStats != nil {
|
||||
snapshot = h.usageStats.Snapshot()
|
||||
}
|
||||
c.JSON(http.StatusOK, usageExportPayload{
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().UTC(),
|
||||
Usage: snapshot,
|
||||
})
|
||||
}
|
||||
|
||||
// ImportUsageStatistics merges a previously exported usage snapshot into memory.
|
||||
func (h *Handler) ImportUsageStatistics(c *gin.Context) {
|
||||
if h == nil || h.usageStats == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"})
|
||||
// GetUsageQueue pops queued usage records from the usage queue.
|
||||
func (h *Handler) GetUsageQueue(c *gin.Context) {
|
||||
if h == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
|
||||
count, errCount := parseUsageQueueCount(c.Query("count"))
|
||||
if errCount != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errCount.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload usageImportPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if payload.Version != 0 && payload.Version != 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
|
||||
return
|
||||
items := redisqueue.PopOldest(count)
|
||||
records := make([]usageQueueRecord, 0, len(items))
|
||||
for _, item := range items {
|
||||
records = append(records, usageQueueRecord(append([]byte(nil), item...)))
|
||||
}
|
||||
|
||||
result := h.usageStats.MergeSnapshot(payload.Usage)
|
||||
snapshot := h.usageStats.Snapshot()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"added": result.Added,
|
||||
"skipped": result.Skipped,
|
||||
"total_requests": snapshot.TotalRequests,
|
||||
"failed_requests": snapshot.FailureCount,
|
||||
})
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
func parseUsageQueueCount(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 1, nil
|
||||
}
|
||||
count, errCount := strconv.Atoi(value)
|
||||
if errCount != nil || count <= 0 {
|
||||
return 0, errors.New("count must be a positive integer")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
)
|
||||
|
||||
func TestGetUsageQueuePopsRequestedRecords(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
withManagementUsageQueue(t, func() {
|
||||
redisqueue.Enqueue([]byte(`{"id":1}`))
|
||||
redisqueue.Enqueue([]byte(`{"id":2}`))
|
||||
redisqueue.Enqueue([]byte(`{"id":3}`))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(rec)
|
||||
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
|
||||
|
||||
h := &Handler{}
|
||||
h.GetUsageQueue(ginCtx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload []json.RawMessage
|
||||
if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal response: %v", errUnmarshal)
|
||||
}
|
||||
if len(payload) != 2 {
|
||||
t.Fatalf("response records = %d, want 2", len(payload))
|
||||
}
|
||||
requireRecordID(t, payload[0], 1)
|
||||
requireRecordID(t, payload[1], 2)
|
||||
|
||||
remaining := redisqueue.PopOldest(10)
|
||||
if len(remaining) != 1 || string(remaining[0]) != `{"id":3}` {
|
||||
t.Fatalf("remaining queue = %q, want third item only", remaining)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUsageQueueInvalidCountDoesNotPop(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
withManagementUsageQueue(t, func() {
|
||||
redisqueue.Enqueue([]byte(`{"id":1}`))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(rec)
|
||||
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=0", nil)
|
||||
|
||||
h := &Handler{}
|
||||
h.GetUsageQueue(ginCtx)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
|
||||
remaining := redisqueue.PopOldest(10)
|
||||
if len(remaining) != 1 || string(remaining[0]) != `{"id":1}` {
|
||||
t.Fatalf("remaining queue = %q, want original item", remaining)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func withManagementUsageQueue(t *testing.T, fn func()) {
|
||||
t.Helper()
|
||||
|
||||
prevQueueEnabled := redisqueue.Enabled()
|
||||
redisqueue.SetEnabled(false)
|
||||
redisqueue.SetEnabled(true)
|
||||
|
||||
defer func() {
|
||||
redisqueue.SetEnabled(false)
|
||||
redisqueue.SetEnabled(prevQueueEnabled)
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
func requireRecordID(t *testing.T, raw json.RawMessage, want int) {
|
||||
t.Helper()
|
||||
|
||||
var payload struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(raw, &payload); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal record: %v", errUnmarshal)
|
||||
}
|
||||
if payload.ID != want {
|
||||
t.Fatalf("record id = %d, want %d", payload.ID, want)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.
|
||||
|
||||
@@ -5,14 +5,16 @@ package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
)
|
||||
|
||||
const maxErrorOnlyCapturedRequestBodyBytes int64 = 1 << 20 // 1 MiB
|
||||
@@ -136,7 +138,7 @@ func captureRequestInfo(c *gin.Context, captureBody bool) (*RequestInfo, error)
|
||||
|
||||
// Restore the body for the actual request processing
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
body = bodyBytes
|
||||
body = decodeCapturedRequestBodyForLog(bodyBytes, c.Request.Header.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
return &RequestInfo{
|
||||
@@ -149,6 +151,58 @@ func captureRequestInfo(c *gin.Context, captureBody bool) (*RequestInfo, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeCapturedRequestBodyForLog(raw []byte, encoding string) []byte {
|
||||
if len(raw) == 0 {
|
||||
return raw
|
||||
}
|
||||
|
||||
decoded, errDecode := decodeCapturedRequestBody(raw, encoding)
|
||||
if errDecode != nil {
|
||||
return raw
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func decodeCapturedRequestBody(raw []byte, encoding string) ([]byte, error) {
|
||||
encoding = strings.TrimSpace(encoding)
|
||||
if encoding == "" || strings.EqualFold(encoding, "identity") {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(encoding, ",")
|
||||
body := raw
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
enc := strings.ToLower(strings.TrimSpace(parts[i]))
|
||||
switch enc {
|
||||
case "", "identity":
|
||||
continue
|
||||
case "zstd":
|
||||
decoded, errDecode := decodeCapturedZstdRequestBody(body)
|
||||
if errDecode != nil {
|
||||
return nil, errDecode
|
||||
}
|
||||
body = decoded
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported request content encoding: %s", enc)
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func decodeCapturedZstdRequestBody(raw []byte) ([]byte, error) {
|
||||
decoder, errNewReader := zstd.NewReader(bytes.NewReader(raw))
|
||||
if errNewReader != nil {
|
||||
return nil, fmt.Errorf("failed to create zstd request decoder: %w", errNewReader)
|
||||
}
|
||||
defer decoder.Close()
|
||||
|
||||
decoded, errRead := io.ReadAll(decoder)
|
||||
if errRead != nil {
|
||||
return nil, fmt.Errorf("failed to decode zstd request body: %w", errRead)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// shouldLogRequest determines whether the request should be logged.
|
||||
// It skips management endpoints to avoid leaking secrets but allows
|
||||
// all other routes, including module-provided ones, to honor request-log.
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
func TestShouldSkipMethodForRequestLogging(t *testing.T) {
|
||||
@@ -136,3 +141,43 @@ func TestShouldCaptureRequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRequestInfoDecodesZstdRequestBodyForLog(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
payload := []byte(`{"model":"test-model","stream":true}`)
|
||||
var compressed bytes.Buffer
|
||||
encoder, errNewWriter := zstd.NewWriter(&compressed)
|
||||
if errNewWriter != nil {
|
||||
t.Fatalf("zstd.NewWriter: %v", errNewWriter)
|
||||
}
|
||||
if _, errWrite := encoder.Write(payload); errWrite != nil {
|
||||
t.Fatalf("zstd write: %v", errWrite)
|
||||
}
|
||||
if errClose := encoder.Close(); errClose != nil {
|
||||
t.Fatalf("zstd close: %v", errClose)
|
||||
}
|
||||
compressedBytes := compressed.Bytes()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(compressedBytes))
|
||||
req.Header.Set("Content-Encoding", "zstd")
|
||||
c.Request = req
|
||||
|
||||
info, errCapture := captureRequestInfo(c, true)
|
||||
if errCapture != nil {
|
||||
t.Fatalf("captureRequestInfo: %v", errCapture)
|
||||
}
|
||||
if !bytes.Equal(info.Body, payload) {
|
||||
t.Fatalf("logged request body = %q, want %q", string(info.Body), string(payload))
|
||||
}
|
||||
|
||||
restoredBody, errRead := io.ReadAll(c.Request.Body)
|
||||
if errRead != nil {
|
||||
t.Fatalf("read restored request body: %v", errRead)
|
||||
}
|
||||
if !bytes.Equal(restoredBody, compressedBytes) {
|
||||
t.Fatal("request body was not restored with the original compressed bytes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
)
|
||||
|
||||
const requestBodyOverrideContextKey = "REQUEST_BODY_OVERRIDE"
|
||||
const responseBodyOverrideContextKey = "RESPONSE_BODY_OVERRIDE"
|
||||
const websocketTimelineOverrideContextKey = "WEBSOCKET_TIMELINE_OVERRIDE"
|
||||
|
||||
// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
|
||||
type RequestInfo struct {
|
||||
@@ -304,6 +306,10 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
||||
if len(apiResponse) > 0 {
|
||||
_ = w.streamWriter.WriteAPIResponse(apiResponse)
|
||||
}
|
||||
apiWebsocketTimeline := w.extractAPIWebsocketTimeline(c)
|
||||
if len(apiWebsocketTimeline) > 0 {
|
||||
_ = w.streamWriter.WriteAPIWebsocketTimeline(apiWebsocketTimeline)
|
||||
}
|
||||
if err := w.streamWriter.Close(); err != nil {
|
||||
w.streamWriter = nil
|
||||
return err
|
||||
@@ -312,7 +318,7 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)
|
||||
return w.logRequest(w.extractRequestBody(c), finalStatusCode, w.cloneHeaders(), w.extractResponseBody(c), w.extractWebsocketTimeline(c), w.extractAPIRequest(c), w.extractAPIResponse(c), w.extractAPIWebsocketTimeline(c), w.extractAPIResponseTimestamp(c), slicesAPIResponseError, forceLog)
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) cloneHeaders() map[string][]string {
|
||||
@@ -352,6 +358,18 @@ func (w *ResponseWriterWrapper) extractAPIResponse(c *gin.Context) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) extractAPIWebsocketTimeline(c *gin.Context) []byte {
|
||||
apiTimeline, isExist := c.Get("API_WEBSOCKET_TIMELINE")
|
||||
if !isExist {
|
||||
return nil
|
||||
}
|
||||
data, ok := apiTimeline.([]byte)
|
||||
if !ok || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return bytes.Clone(data)
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) extractAPIResponseTimestamp(c *gin.Context) time.Time {
|
||||
ts, isExist := c.Get("API_RESPONSE_TIMESTAMP")
|
||||
if !isExist {
|
||||
@@ -364,19 +382,8 @@ func (w *ResponseWriterWrapper) extractAPIResponseTimestamp(c *gin.Context) time
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) extractRequestBody(c *gin.Context) []byte {
|
||||
if c != nil {
|
||||
if bodyOverride, isExist := c.Get(requestBodyOverrideContextKey); isExist {
|
||||
switch value := bodyOverride.(type) {
|
||||
case []byte:
|
||||
if len(value) > 0 {
|
||||
return bytes.Clone(value)
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return []byte(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if body := extractBodyOverride(c, requestBodyOverrideContextKey); len(body) > 0 {
|
||||
return body
|
||||
}
|
||||
if w.requestInfo != nil && len(w.requestInfo.Body) > 0 {
|
||||
return w.requestInfo.Body
|
||||
@@ -384,13 +391,48 @@ func (w *ResponseWriterWrapper) extractRequestBody(c *gin.Context) []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body []byte, apiRequestBody, apiResponseBody []byte, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
|
||||
func (w *ResponseWriterWrapper) extractResponseBody(c *gin.Context) []byte {
|
||||
if body := extractBodyOverride(c, responseBodyOverrideContextKey); len(body) > 0 {
|
||||
return body
|
||||
}
|
||||
if w.body == nil || w.body.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return bytes.Clone(w.body.Bytes())
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) extractWebsocketTimeline(c *gin.Context) []byte {
|
||||
return extractBodyOverride(c, websocketTimelineOverrideContextKey)
|
||||
}
|
||||
|
||||
func extractBodyOverride(c *gin.Context, key string) []byte {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
bodyOverride, isExist := c.Get(key)
|
||||
if !isExist {
|
||||
return nil
|
||||
}
|
||||
switch value := bodyOverride.(type) {
|
||||
case []byte:
|
||||
if len(value) > 0 {
|
||||
return bytes.Clone(value)
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return []byte(value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, headers map[string][]string, body, websocketTimeline, apiRequestBody, apiResponseBody, apiWebsocketTimeline []byte, apiResponseTimestamp time.Time, apiResponseErrors []*interfaces.ErrorMessage, forceLog bool) error {
|
||||
if w.requestInfo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if loggerWithOptions, ok := w.logger.(interface {
|
||||
LogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error
|
||||
LogRequestWithOptions(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []byte, []byte, []*interfaces.ErrorMessage, bool, string, time.Time, time.Time) error
|
||||
}); ok {
|
||||
return loggerWithOptions.LogRequestWithOptions(
|
||||
w.requestInfo.URL,
|
||||
@@ -400,8 +442,10 @@ func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, h
|
||||
statusCode,
|
||||
headers,
|
||||
body,
|
||||
websocketTimeline,
|
||||
apiRequestBody,
|
||||
apiResponseBody,
|
||||
apiWebsocketTimeline,
|
||||
apiResponseErrors,
|
||||
forceLog,
|
||||
w.requestInfo.RequestID,
|
||||
@@ -418,8 +462,10 @@ func (w *ResponseWriterWrapper) logRequest(requestBody []byte, statusCode int, h
|
||||
statusCode,
|
||||
headers,
|
||||
body,
|
||||
websocketTimeline,
|
||||
apiRequestBody,
|
||||
apiResponseBody,
|
||||
apiWebsocketTimeline,
|
||||
apiResponseErrors,
|
||||
w.requestInfo.RequestID,
|
||||
w.requestInfo.Timestamp,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
)
|
||||
|
||||
func TestExtractRequestBodyPrefersOverride(t *testing.T) {
|
||||
@@ -33,7 +37,7 @@ func TestExtractRequestBodySupportsStringOverride(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
wrapper := &ResponseWriterWrapper{}
|
||||
wrapper := &ResponseWriterWrapper{body: &bytes.Buffer{}}
|
||||
c.Set(requestBodyOverrideContextKey, "override-as-string")
|
||||
|
||||
body := wrapper.extractRequestBody(c)
|
||||
@@ -41,3 +45,158 @@ func TestExtractRequestBodySupportsStringOverride(t *testing.T) {
|
||||
t.Fatalf("request body = %q, want %q", string(body), "override-as-string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseBodyPrefersOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
wrapper := &ResponseWriterWrapper{body: &bytes.Buffer{}}
|
||||
wrapper.body.WriteString("original-response")
|
||||
|
||||
body := wrapper.extractResponseBody(c)
|
||||
if string(body) != "original-response" {
|
||||
t.Fatalf("response body = %q, want %q", string(body), "original-response")
|
||||
}
|
||||
|
||||
c.Set(responseBodyOverrideContextKey, []byte("override-response"))
|
||||
body = wrapper.extractResponseBody(c)
|
||||
if string(body) != "override-response" {
|
||||
t.Fatalf("response body = %q, want %q", string(body), "override-response")
|
||||
}
|
||||
|
||||
body[0] = 'X'
|
||||
if got := wrapper.extractResponseBody(c); string(got) != "override-response" {
|
||||
t.Fatalf("response override should be cloned, got %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseBodySupportsStringOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
wrapper := &ResponseWriterWrapper{}
|
||||
c.Set(responseBodyOverrideContextKey, "override-response-as-string")
|
||||
|
||||
body := wrapper.extractResponseBody(c)
|
||||
if string(body) != "override-response-as-string" {
|
||||
t.Fatalf("response body = %q, want %q", string(body), "override-response-as-string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBodyOverrideClonesBytes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
override := []byte("body-override")
|
||||
c.Set(requestBodyOverrideContextKey, override)
|
||||
|
||||
body := extractBodyOverride(c, requestBodyOverrideContextKey)
|
||||
if !bytes.Equal(body, override) {
|
||||
t.Fatalf("body override = %q, want %q", string(body), string(override))
|
||||
}
|
||||
|
||||
body[0] = 'X'
|
||||
if !bytes.Equal(override, []byte("body-override")) {
|
||||
t.Fatalf("override mutated: %q", string(override))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWebsocketTimelineUsesOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
wrapper := &ResponseWriterWrapper{}
|
||||
if got := wrapper.extractWebsocketTimeline(c); got != nil {
|
||||
t.Fatalf("expected nil websocket timeline, got %q", string(got))
|
||||
}
|
||||
|
||||
c.Set(websocketTimelineOverrideContextKey, []byte("timeline"))
|
||||
body := wrapper.extractWebsocketTimeline(c)
|
||||
if string(body) != "timeline" {
|
||||
t.Fatalf("websocket timeline = %q, want %q", string(body), "timeline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeStreamingWritesAPIWebsocketTimeline(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
streamWriter := &testStreamingLogWriter{}
|
||||
wrapper := &ResponseWriterWrapper{
|
||||
ResponseWriter: c.Writer,
|
||||
logger: &testRequestLogger{enabled: true},
|
||||
requestInfo: &RequestInfo{
|
||||
URL: "/v1/responses",
|
||||
Method: "POST",
|
||||
Headers: map[string][]string{"Content-Type": {"application/json"}},
|
||||
RequestID: "req-1",
|
||||
Timestamp: time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
isStreaming: true,
|
||||
streamWriter: streamWriter,
|
||||
}
|
||||
|
||||
c.Set("API_WEBSOCKET_TIMELINE", []byte("Timestamp: 2026-04-01T12:00:00Z\nEvent: api.websocket.request\n{}"))
|
||||
|
||||
if err := wrapper.Finalize(c); err != nil {
|
||||
t.Fatalf("Finalize error: %v", err)
|
||||
}
|
||||
if string(streamWriter.apiWebsocketTimeline) != "Timestamp: 2026-04-01T12:00:00Z\nEvent: api.websocket.request\n{}" {
|
||||
t.Fatalf("stream writer websocket timeline = %q", string(streamWriter.apiWebsocketTimeline))
|
||||
}
|
||||
if !streamWriter.closed {
|
||||
t.Fatal("expected stream writer to be closed")
|
||||
}
|
||||
}
|
||||
|
||||
type testRequestLogger struct {
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func (l *testRequestLogger) LogRequest(string, string, map[string][]string, []byte, int, map[string][]string, []byte, []byte, []byte, []byte, []byte, []*interfaces.ErrorMessage, string, time.Time, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *testRequestLogger) LogStreamingRequest(string, string, map[string][]string, []byte, string) (logging.StreamingLogWriter, error) {
|
||||
return &testStreamingLogWriter{}, nil
|
||||
}
|
||||
|
||||
func (l *testRequestLogger) IsEnabled() bool {
|
||||
return l.enabled
|
||||
}
|
||||
|
||||
type testStreamingLogWriter struct {
|
||||
apiWebsocketTimeline []byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (w *testStreamingLogWriter) WriteChunkAsync([]byte) {}
|
||||
|
||||
func (w *testStreamingLogWriter) WriteStatus(int, map[string][]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *testStreamingLogWriter) WriteAPIRequest([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *testStreamingLogWriter) WriteAPIResponse([]byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *testStreamingLogWriter) WriteAPIWebsocketTimeline(apiWebsocketTimeline []byte) error {
|
||||
w.apiWebsocketTimeline = bytes.Clone(apiWebsocketTimeline)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *testStreamingLogWriter) SetFirstChunkTimestamp(time.Time) {}
|
||||
|
||||
func (w *testStreamingLogWriter) Close() error {
|
||||
w.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
)
|
||||
|
||||
func TestAmpModule_Name(t *testing.T) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -123,6 +123,10 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize request body: remove thinking blocks with invalid signatures
|
||||
// to prevent upstream API 400 errors
|
||||
bodyBytes = SanitizeAmpRequestBody(bodyBytes)
|
||||
|
||||
// Restore the body for the handler to read
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
@@ -249,6 +253,7 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
||||
log.Debugf("amp model mapping: request %s -> %s", normalizedModel, resolvedModel)
|
||||
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
|
||||
rewriter := NewResponseRewriter(c.Writer, modelName)
|
||||
rewriter.suppressThinking = true
|
||||
c.Writer = rewriter
|
||||
// Filter Anthropic-Beta header only for local handling paths
|
||||
filterAntropicBetaHeader(c)
|
||||
@@ -259,10 +264,17 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
||||
} else if len(providers) > 0 {
|
||||
// Log: Using local provider (free)
|
||||
logAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)
|
||||
// Wrap with ResponseRewriter for local providers too, because upstream
|
||||
// proxies (e.g. NewAPI) may return a different model name and lack
|
||||
// Amp-required fields like thinking.signature.
|
||||
rewriter := NewResponseRewriter(c.Writer, modelName)
|
||||
rewriter.suppressThinking = providerName != "claude"
|
||||
c.Writer = rewriter
|
||||
// Filter Anthropic-Beta header only for local handling paths
|
||||
filterAntropicBetaHeader(c)
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
handler(c)
|
||||
rewriter.Flush()
|
||||
} else {
|
||||
// No provider, no mapping, no proxy: fall back to the wrapped handler so it can return an error response
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package amp
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
)
|
||||
|
||||
func TestNewModelMapper(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -76,6 +77,9 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
|
||||
req.Header.Del("X-Api-Key")
|
||||
req.Header.Del("X-Goog-Api-Key")
|
||||
|
||||
// Remove proxy, client identity, and browser fingerprint headers
|
||||
misc.ScrubProxyAndFingerprintHeaders(req)
|
||||
|
||||
// Remove query-based credentials if they match the authenticated client API key.
|
||||
// This prevents leaking client auth material to the Amp upstream while avoiding
|
||||
// breaking unrelated upstream query parameters.
|
||||
@@ -104,11 +108,6 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
|
||||
// Modify incoming responses to handle gzip without Content-Encoding
|
||||
// This addresses the same issue as inline handler gzip handling, but at the proxy level
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
// Only process successful responses
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if already marked as gzip (Content-Encoding set)
|
||||
if resp.Header.Get("Content-Encoding") != "" {
|
||||
return nil
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
// Helper: compress data with gzip
|
||||
@@ -129,11 +129,11 @@ func TestModifyResponse_GzipScenarios(t *testing.T) {
|
||||
wantCE: "",
|
||||
},
|
||||
{
|
||||
name: "skips_non_2xx_status",
|
||||
name: "decompresses_non_2xx_status_when_gzip_detected",
|
||||
header: http.Header{},
|
||||
body: good,
|
||||
status: 404,
|
||||
wantBody: good,
|
||||
wantBody: goodJSON,
|
||||
wantCE: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package amp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -12,15 +14,17 @@ import (
|
||||
)
|
||||
|
||||
// ResponseRewriter wraps a gin.ResponseWriter to intercept and modify the response body
|
||||
// It's used to rewrite model names in responses when model mapping is used
|
||||
// It is used to rewrite model names in responses when model mapping is used
|
||||
// and to keep Amp-compatible response shapes.
|
||||
type ResponseRewriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
originalModel string
|
||||
isStreaming bool
|
||||
body *bytes.Buffer
|
||||
originalModel string
|
||||
isStreaming bool
|
||||
suppressThinking bool
|
||||
}
|
||||
|
||||
// NewResponseRewriter creates a new response rewriter for model name substitution
|
||||
// NewResponseRewriter creates a new response rewriter for model name substitution.
|
||||
func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRewriter {
|
||||
return &ResponseRewriter{
|
||||
ResponseWriter: w,
|
||||
@@ -29,17 +33,66 @@ func NewResponseRewriter(w gin.ResponseWriter, originalModel string) *ResponseRe
|
||||
}
|
||||
}
|
||||
|
||||
// Write intercepts response writes and buffers them for model name replacement
|
||||
const maxBufferedResponseBytes = 2 * 1024 * 1024 // 2MB safety cap
|
||||
|
||||
func looksLikeSSEChunk(data []byte) bool {
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(trimmed, []byte("data:")) ||
|
||||
bytes.HasPrefix(trimmed, []byte("event:")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rw *ResponseRewriter) enableStreaming(reason string) error {
|
||||
if rw.isStreaming {
|
||||
return nil
|
||||
}
|
||||
rw.isStreaming = true
|
||||
|
||||
if rw.body != nil && rw.body.Len() > 0 {
|
||||
buf := rw.body.Bytes()
|
||||
toFlush := make([]byte, len(buf))
|
||||
copy(toFlush, buf)
|
||||
rw.body.Reset()
|
||||
|
||||
if _, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(toFlush)); err != nil {
|
||||
return err
|
||||
}
|
||||
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("amp response rewriter: switched to streaming (%s)", reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rw *ResponseRewriter) Write(data []byte) (int, error) {
|
||||
// Detect streaming on first write
|
||||
if rw.body.Len() == 0 && !rw.isStreaming {
|
||||
if !rw.isStreaming && rw.body.Len() == 0 {
|
||||
contentType := rw.Header().Get("Content-Type")
|
||||
rw.isStreaming = strings.Contains(contentType, "text/event-stream") ||
|
||||
strings.Contains(contentType, "stream")
|
||||
}
|
||||
|
||||
if !rw.isStreaming {
|
||||
if looksLikeSSEChunk(data) {
|
||||
if err := rw.enableStreaming("sse heuristic"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
} else if rw.body.Len()+len(data) > maxBufferedResponseBytes {
|
||||
log.Warnf("amp response rewriter: buffer exceeded %d bytes, switching to streaming", maxBufferedResponseBytes)
|
||||
if err := rw.enableStreaming("buffer limit"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rw.isStreaming {
|
||||
n, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))
|
||||
rewritten := rw.rewriteStreamChunk(data)
|
||||
n, err := rw.ResponseWriter.Write(rewritten)
|
||||
if err == nil {
|
||||
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
@@ -50,7 +103,6 @@ func (rw *ResponseRewriter) Write(data []byte) (int, error) {
|
||||
return rw.body.Write(data)
|
||||
}
|
||||
|
||||
// Flush writes the buffered response with model names rewritten
|
||||
func (rw *ResponseRewriter) Flush() {
|
||||
if rw.isStreaming {
|
||||
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
@@ -59,40 +111,126 @@ func (rw *ResponseRewriter) Flush() {
|
||||
return
|
||||
}
|
||||
if rw.body.Len() > 0 {
|
||||
if _, err := rw.ResponseWriter.Write(rw.rewriteModelInResponse(rw.body.Bytes())); err != nil {
|
||||
rewritten := rw.rewriteModelInResponse(rw.body.Bytes())
|
||||
// Update Content-Length to match the rewritten body size, since
|
||||
// signature injection and model name changes alter the payload length.
|
||||
rw.ResponseWriter.Header().Set("Content-Length", fmt.Sprintf("%d", len(rewritten)))
|
||||
if _, err := rw.ResponseWriter.Write(rewritten); err != nil {
|
||||
log.Warnf("amp response rewriter: failed to write rewritten response: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// modelFieldPaths lists all JSON paths where model name may appear
|
||||
var modelFieldPaths = []string{"message.model", "model", "modelVersion", "response.model", "response.modelVersion"}
|
||||
|
||||
// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON
|
||||
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility
|
||||
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
|
||||
// 1. Amp Compatibility: Suppress thinking blocks if tool use is detected
|
||||
// The Amp client struggles when both thinking and tool_use blocks are present
|
||||
// ampCanonicalToolNames maps tool names to the exact casing expected by the
|
||||
// Amp mode tool whitelist (case-sensitive match).
|
||||
var ampCanonicalToolNames = map[string]string{
|
||||
"bash": "Bash",
|
||||
"read": "Read",
|
||||
"grep": "Grep",
|
||||
"glob": "glob",
|
||||
"task": "Task",
|
||||
"check": "Check",
|
||||
}
|
||||
|
||||
// normalizeAmpToolNames fixes tool_use block names to match Amp's canonical casing.
|
||||
// Some upstream models return lowercase tool names (e.g. "bash" instead of "Bash")
|
||||
// which causes Amp's case-sensitive mode whitelist to reject them.
|
||||
func normalizeAmpToolNames(data []byte) []byte {
|
||||
// Non-streaming: content[].name in tool_use blocks
|
||||
for index, block := range gjson.GetBytes(data, "content").Array() {
|
||||
if block.Get("type").String() != "tool_use" {
|
||||
continue
|
||||
}
|
||||
name := block.Get("name").String()
|
||||
if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
|
||||
path := fmt.Sprintf("content.%d.name", index)
|
||||
var err error
|
||||
data, err = sjson.SetBytes(data, path, canonical)
|
||||
if err != nil {
|
||||
log.Warnf("Amp ResponseRewriter: failed to normalize tool name %q to %q: %v", name, canonical, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming: content_block.name in content_block_start events
|
||||
if gjson.GetBytes(data, "content_block.type").String() == "tool_use" {
|
||||
name := gjson.GetBytes(data, "content_block.name").String()
|
||||
if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
|
||||
var err error
|
||||
data, err = sjson.SetBytes(data, "content_block.name", canonical)
|
||||
if err != nil {
|
||||
log.Warnf("Amp ResponseRewriter: failed to normalize streaming tool name %q to %q: %v", name, canonical, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ensureAmpSignature injects empty signature fields into tool_use/thinking blocks
|
||||
// in API responses so that the Amp TUI does not crash on P.signature.length.
|
||||
func ensureAmpSignature(data []byte) []byte {
|
||||
for index, block := range gjson.GetBytes(data, "content").Array() {
|
||||
blockType := block.Get("type").String()
|
||||
if blockType != "tool_use" && blockType != "thinking" {
|
||||
continue
|
||||
}
|
||||
signaturePath := fmt.Sprintf("content.%d.signature", index)
|
||||
if gjson.GetBytes(data, signaturePath).Exists() {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
data, err = sjson.SetBytes(data, signaturePath, "")
|
||||
if err != nil {
|
||||
log.Warnf("Amp ResponseRewriter: failed to add empty signature to %s block: %v", blockType, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
contentBlockType := gjson.GetBytes(data, "content_block.type").String()
|
||||
if (contentBlockType == "tool_use" || contentBlockType == "thinking") && !gjson.GetBytes(data, "content_block.signature").Exists() {
|
||||
var err error
|
||||
data, err = sjson.SetBytes(data, "content_block.signature", "")
|
||||
if err != nil {
|
||||
log.Warnf("Amp ResponseRewriter: failed to add empty signature to streaming %s block: %v", contentBlockType, err)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte {
|
||||
if !rw.suppressThinking {
|
||||
return data
|
||||
}
|
||||
if gjson.GetBytes(data, `content.#(type=="tool_use")`).Exists() {
|
||||
filtered := gjson.GetBytes(data, `content.#(type!="thinking")#`)
|
||||
if filtered.Exists() {
|
||||
originalCount := gjson.GetBytes(data, "content.#").Int()
|
||||
filteredCount := filtered.Get("#").Int()
|
||||
|
||||
if originalCount > filteredCount {
|
||||
var err error
|
||||
data, err = sjson.SetBytes(data, "content", filtered.Value())
|
||||
if err != nil {
|
||||
log.Warnf("Amp ResponseRewriter: failed to suppress thinking blocks: %v", err)
|
||||
} else {
|
||||
log.Debugf("Amp ResponseRewriter: Suppressed %d thinking blocks due to tool usage", originalCount-filteredCount)
|
||||
// Log the result for verification
|
||||
log.Debugf("Amp ResponseRewriter: Resulting content: %s", gjson.GetBytes(data, "content").String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
|
||||
data = ensureAmpSignature(data)
|
||||
data = normalizeAmpToolNames(data)
|
||||
data = rw.suppressAmpThinking(data)
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
}
|
||||
|
||||
if rw.originalModel == "" {
|
||||
return data
|
||||
}
|
||||
@@ -104,24 +242,167 @@ func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// rewriteStreamChunk rewrites model names in SSE stream chunks
|
||||
func (rw *ResponseRewriter) rewriteStreamChunk(chunk []byte) []byte {
|
||||
if rw.originalModel == "" {
|
||||
return chunk
|
||||
lines := bytes.Split(chunk, []byte("\n"))
|
||||
var out [][]byte
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
|
||||
// Case 1: "event:" line - look ahead for its "data:" line
|
||||
if bytes.HasPrefix(trimmed, []byte("event: ")) {
|
||||
// Scan forward past blank lines to find the data: line
|
||||
dataIdx := -1
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
t := bytes.TrimSpace(lines[j])
|
||||
if len(t) == 0 {
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(t, []byte("data: ")) {
|
||||
dataIdx = j
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if dataIdx >= 0 {
|
||||
// Found event+data pair - process through rewriter
|
||||
jsonData := bytes.TrimPrefix(bytes.TrimSpace(lines[dataIdx]), []byte("data: "))
|
||||
if len(jsonData) > 0 && jsonData[0] == '{' {
|
||||
rewritten := rw.rewriteStreamEvent(jsonData)
|
||||
if rewritten == nil {
|
||||
i = dataIdx + 1
|
||||
continue
|
||||
}
|
||||
// Emit event line
|
||||
out = append(out, line)
|
||||
// Emit blank lines between event and data
|
||||
for k := i + 1; k < dataIdx; k++ {
|
||||
out = append(out, lines[k])
|
||||
}
|
||||
// Emit rewritten data
|
||||
out = append(out, append([]byte("data: "), rewritten...))
|
||||
i = dataIdx + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// No data line found (orphan event from cross-chunk split)
|
||||
// Pass it through as-is - the data will arrive in the next chunk
|
||||
out = append(out, line)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 2: standalone "data:" line (no preceding event: in this chunk)
|
||||
if bytes.HasPrefix(trimmed, []byte("data: ")) {
|
||||
jsonData := bytes.TrimPrefix(trimmed, []byte("data: "))
|
||||
if len(jsonData) > 0 && jsonData[0] == '{' {
|
||||
rewritten := rw.rewriteStreamEvent(jsonData)
|
||||
if rewritten != nil {
|
||||
out = append(out, append([]byte("data: "), rewritten...))
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: everything else
|
||||
out = append(out, line)
|
||||
i++
|
||||
}
|
||||
|
||||
// SSE format: "data: {json}\n\n"
|
||||
lines := bytes.Split(chunk, []byte("\n"))
|
||||
for i, line := range lines {
|
||||
if bytes.HasPrefix(line, []byte("data: ")) {
|
||||
jsonData := bytes.TrimPrefix(line, []byte("data: "))
|
||||
if len(jsonData) > 0 && jsonData[0] == '{' {
|
||||
// Rewrite JSON in the data line
|
||||
rewritten := rw.rewriteModelInResponse(jsonData)
|
||||
lines[i] = append([]byte("data: "), rewritten...)
|
||||
return bytes.Join(out, []byte("\n"))
|
||||
}
|
||||
|
||||
// rewriteStreamEvent processes a single JSON event in the SSE stream.
|
||||
// It rewrites model names and ensures signature fields exist.
|
||||
// NOTE: streaming mode does NOT suppress thinking blocks - they are
|
||||
// passed through with signature injection to avoid breaking SSE index
|
||||
// alignment and TUI rendering.
|
||||
func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte {
|
||||
// Inject empty signature where needed
|
||||
data = ensureAmpSignature(data)
|
||||
|
||||
// Normalize tool names to canonical casing
|
||||
data = normalizeAmpToolNames(data)
|
||||
|
||||
// Rewrite model name
|
||||
if rw.originalModel != "" {
|
||||
for _, path := range modelFieldPaths {
|
||||
if gjson.GetBytes(data, path).Exists() {
|
||||
data, _ = sjson.SetBytes(data, path, rw.originalModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bytes.Join(lines, []byte("\n"))
|
||||
return data
|
||||
}
|
||||
|
||||
// SanitizeAmpRequestBody removes thinking blocks with empty/missing/invalid signatures
|
||||
// and strips the proxy-injected "signature" field from tool_use blocks in the messages
|
||||
// array before forwarding to the upstream API.
|
||||
// This prevents 400 errors from the API which requires valid signatures on thinking
|
||||
// blocks and does not accept a signature field on tool_use blocks.
|
||||
func SanitizeAmpRequestBody(body []byte) []byte {
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.Exists() || !messages.IsArray() {
|
||||
return body
|
||||
}
|
||||
|
||||
modified := false
|
||||
for msgIdx, msg := range messages.Array() {
|
||||
if msg.Get("role").String() != "assistant" {
|
||||
continue
|
||||
}
|
||||
content := msg.Get("content")
|
||||
if !content.Exists() || !content.IsArray() {
|
||||
continue
|
||||
}
|
||||
|
||||
var keepBlocks []interface{}
|
||||
contentModified := false
|
||||
|
||||
for _, block := range content.Array() {
|
||||
blockType := block.Get("type").String()
|
||||
if blockType == "thinking" {
|
||||
sig := block.Get("signature")
|
||||
if !sig.Exists() || sig.Type != gjson.String || strings.TrimSpace(sig.String()) == "" {
|
||||
contentModified = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Use raw JSON to prevent float64 rounding of large integers in tool_use inputs
|
||||
blockRaw := []byte(block.Raw)
|
||||
if blockType == "tool_use" && block.Get("signature").Exists() {
|
||||
blockRaw, _ = sjson.DeleteBytes(blockRaw, "signature")
|
||||
contentModified = true
|
||||
}
|
||||
|
||||
// sjson.SetBytes supports raw JSON strings if wrapped in gjson.Raw
|
||||
keepBlocks = append(keepBlocks, json.RawMessage(blockRaw))
|
||||
}
|
||||
|
||||
if contentModified {
|
||||
contentPath := fmt.Sprintf("messages.%d.content", msgIdx)
|
||||
var err error
|
||||
if len(keepBlocks) == 0 {
|
||||
body, err = sjson.SetBytes(body, contentPath, []interface{}{})
|
||||
} else {
|
||||
body, err = sjson.SetBytes(body, contentPath, keepBlocks)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("Amp RequestSanitizer: failed to sanitize message %d: %v", msgIdx, err)
|
||||
continue
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
log.Debugf("Amp RequestSanitizer: sanitized request body")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package amp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -100,6 +101,131 @@ func TestRewriteStreamChunk_MessageModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteStreamChunk_PreservesThinkingWithSignatureInjection(t *testing.T) {
|
||||
rw := &ResponseRewriter{}
|
||||
|
||||
chunk := []byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"abc\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"name\":\"bash\",\"input\":{}}}\n\n")
|
||||
result := rw.rewriteStreamChunk(chunk)
|
||||
|
||||
// Streaming mode preserves thinking blocks (does NOT suppress them)
|
||||
// to avoid breaking SSE index alignment and TUI rendering
|
||||
if !contains(result, []byte(`"content_block":{"type":"thinking"`)) {
|
||||
t.Fatalf("expected thinking content_block_start to be preserved, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"delta":{"type":"thinking_delta"`)) {
|
||||
t.Fatalf("expected thinking_delta to be preserved, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"type":"content_block_stop","index":0`)) {
|
||||
t.Fatalf("expected content_block_stop for thinking block to be preserved, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"content_block":{"type":"tool_use"`)) {
|
||||
t.Fatalf("expected tool_use content_block frame to remain, got %s", string(result))
|
||||
}
|
||||
// Signature should be injected into both thinking and tool_use blocks
|
||||
if count := strings.Count(string(result), `"signature":""`); count != 2 {
|
||||
t.Fatalf("expected 2 signature injections, but got %d in %s", count, string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAmpRequestBody_RemovesWhitespaceAndNonStringSignatures(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop-whitespace","signature":" "},{"type":"thinking","thinking":"drop-number","signature":123},{"type":"thinking","thinking":"keep-valid","signature":"valid-signature"},{"type":"text","text":"keep-text"}]}]}`)
|
||||
result := SanitizeAmpRequestBody(input)
|
||||
|
||||
if contains(result, []byte("drop-whitespace")) {
|
||||
t.Fatalf("expected whitespace-only signature block to be removed, got %s", string(result))
|
||||
}
|
||||
if contains(result, []byte("drop-number")) {
|
||||
t.Fatalf("expected non-string signature block to be removed, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte("keep-valid")) {
|
||||
t.Fatalf("expected valid thinking block to remain, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte("keep-text")) {
|
||||
t.Fatalf("expected non-thinking content to remain, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAmpRequestBody_StripsSignatureFromToolUseBlocks(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"thought","signature":"valid-sig"},{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"},"signature":""}]}]}`)
|
||||
result := SanitizeAmpRequestBody(input)
|
||||
|
||||
if contains(result, []byte(`"signature":""`)) {
|
||||
t.Fatalf("expected signature to be stripped from tool_use block, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"valid-sig"`)) {
|
||||
t.Fatalf("expected thinking signature to remain, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"tool_use"`)) {
|
||||
t.Fatalf("expected tool_use block to remain, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAmpRequestBody_MixedInvalidThinkingAndToolUseSignature(t *testing.T) {
|
||||
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop-me","signature":""},{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"},"signature":""}]}]}`)
|
||||
result := SanitizeAmpRequestBody(input)
|
||||
|
||||
if contains(result, []byte("drop-me")) {
|
||||
t.Fatalf("expected invalid thinking block to be removed, got %s", string(result))
|
||||
}
|
||||
if contains(result, []byte(`"signature"`)) {
|
||||
t.Fatalf("expected signature to be stripped from tool_use block, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"tool_use"`)) {
|
||||
t.Fatalf("expected tool_use block to remain, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmpToolNames_NonStreaming(t *testing.T) {
|
||||
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}},{"type":"tool_use","id":"toolu_02","name":"read","input":{"path":"/tmp"}},{"type":"text","text":"hello"}]}`)
|
||||
result := normalizeAmpToolNames(input)
|
||||
|
||||
if !contains(result, []byte(`"name":"Bash"`)) {
|
||||
t.Errorf("expected bash->Bash, got %s", string(result))
|
||||
}
|
||||
if !contains(result, []byte(`"name":"Read"`)) {
|
||||
t.Errorf("expected read->Read, got %s", string(result))
|
||||
}
|
||||
if contains(result, []byte(`"name":"bash"`)) {
|
||||
t.Errorf("expected lowercase bash to be replaced, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmpToolNames_Streaming(t *testing.T) {
|
||||
input := []byte(`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"grep","id":"toolu_01","input":{}}}`)
|
||||
result := normalizeAmpToolNames(input)
|
||||
|
||||
if !contains(result, []byte(`"name":"Grep"`)) {
|
||||
t.Errorf("expected grep->Grep in streaming, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmpToolNames_AlreadyCorrect(t *testing.T) {
|
||||
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
|
||||
result := normalizeAmpToolNames(input)
|
||||
|
||||
if string(result) != string(input) {
|
||||
t.Errorf("expected no modification for correctly-cased tool, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmpToolNames_GlobPreserved(t *testing.T) {
|
||||
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
|
||||
result := normalizeAmpToolNames(input)
|
||||
|
||||
if string(result) != string(input) {
|
||||
t.Errorf("expected glob to remain lowercase, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAmpToolNames_UnknownToolUntouched(t *testing.T) {
|
||||
input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"edit_file","input":{"path":"/tmp/x"}}]}`)
|
||||
result := normalizeAmpToolNames(input)
|
||||
|
||||
if string(result) != string(input) {
|
||||
t.Errorf("expected no modification for unknown tool, got %s", string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func contains(data, substr []byte) bool {
|
||||
for i := 0; i <= len(data)-len(substr); i++ {
|
||||
if string(data[i:i+len(substr)]) == string(substr) {
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -21,12 +21,12 @@ import (
|
||||
// from gin.Context to the request context for SecretSource lookup.
|
||||
type clientAPIKeyContextKey struct{}
|
||||
|
||||
// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context["apiKey"]
|
||||
// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context["userApiKey"]
|
||||
// into the request context so that SecretSource can look it up for per-client upstream routing.
|
||||
func clientAPIKeyMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Extract the client API key from gin context (set by AuthMiddleware)
|
||||
if apiKey, exists := c.Get("apiKey"); exists {
|
||||
if apiKey, exists := c.Get("userApiKey"); exists {
|
||||
if keyStr, ok := apiKey.(string); ok && keyStr != "" {
|
||||
// Inject into request context for SecretSource.Get(ctx) to read
|
||||
ctx := context.WithValue(c.Request.Context(), clientAPIKeyContextKey{}, keyStr)
|
||||
@@ -199,6 +199,7 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
||||
ampAPI.Any("/telemetry/*path", proxyHandler)
|
||||
ampAPI.Any("/threads", proxyHandler)
|
||||
ampAPI.Any("/threads/*path", proxyHandler)
|
||||
ampAPI.Any("/thread-actors", proxyHandler)
|
||||
ampAPI.Any("/otel", proxyHandler)
|
||||
ampAPI.Any("/otel/*path", proxyHandler)
|
||||
ampAPI.Any("/tab", proxyHandler)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
)
|
||||
|
||||
func TestRegisterManagementRoutes(t *testing.T) {
|
||||
@@ -49,6 +49,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
|
||||
{"/api/meta", http.MethodGet},
|
||||
{"/api/telemetry", http.MethodGet},
|
||||
{"/api/threads", http.MethodGet},
|
||||
{"/api/thread-actors", http.MethodPost},
|
||||
{"/threads/", http.MethodGet},
|
||||
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
|
||||
{"/api/otel", http.MethodGet},
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
)
|
||||
|
||||
// Context encapsulates the dependencies exposed to routing modules during
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type muxListener struct {
|
||||
addr net.Addr
|
||||
connCh chan net.Conn
|
||||
closeCh chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newMuxListener(addr net.Addr, buffer int) *muxListener {
|
||||
if buffer <= 0 {
|
||||
buffer = 1
|
||||
}
|
||||
return &muxListener{
|
||||
addr: addr,
|
||||
connCh: make(chan net.Conn, buffer),
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *muxListener) Put(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-l.closeCh:
|
||||
return net.ErrClosed
|
||||
case l.connCh <- conn:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *muxListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case <-l.closeCh:
|
||||
return nil, net.ErrClosed
|
||||
case conn := <-l.connCh:
|
||||
if conn == nil {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *muxListener) Close() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
l.once.Do(func() {
|
||||
close(l.closeCh)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *muxListener) Addr() net.Addr {
|
||||
if l == nil {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
if l.addr == nil {
|
||||
return &net.TCPAddr{}
|
||||
}
|
||||
return l.addr
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func normalizeHTTPServeError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeListenerError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) acceptMuxConnections(listener net.Listener, httpListener *muxListener) error {
|
||||
if s == nil || listener == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
|
||||
for {
|
||||
conn, errAccept := listener.Accept()
|
||||
if errAccept != nil {
|
||||
return errAccept
|
||||
}
|
||||
if conn == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dispatch each connection to a goroutine so that slow/idle clients
|
||||
// cannot block the accept loop. Previously, TLS handshake and
|
||||
// reader.Peek(1) were performed inline; an idle TCP connection that
|
||||
// never sent bytes would block Peek indefinitely, preventing all
|
||||
// subsequent connections from being accepted (issue #3267).
|
||||
go s.routeMuxConnection(conn, httpListener)
|
||||
}
|
||||
}
|
||||
|
||||
// routeMuxConnection performs per-connection protocol detection and routing.
|
||||
func (s *Server) routeMuxConnection(conn net.Conn, httpListener *muxListener) {
|
||||
// Set a read deadline so that idle connections that never send bytes do not
|
||||
// leak goroutines and file descriptors. The deadline is cleared once the
|
||||
// connection is successfully routed to its handler.
|
||||
const muxSniffDeadline = 10 * time.Second
|
||||
_ = conn.SetReadDeadline(time.Now().Add(muxSniffDeadline))
|
||||
|
||||
tlsConn, ok := conn.(*tls.Conn)
|
||||
if ok {
|
||||
if errHandshake := tlsConn.Handshake(); errHandshake != nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close connection after TLS handshake error: %v", errClose)
|
||||
}
|
||||
return
|
||||
}
|
||||
proto := strings.TrimSpace(tlsConn.ConnectionState().NegotiatedProtocol)
|
||||
if proto == "h2" || proto == "http/1.1" {
|
||||
if httpListener == nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close connection: %v", errClose)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errPut := httpListener.Put(tlsConn); errPut != nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close connection after HTTP routing failure: %v", errClose)
|
||||
}
|
||||
} else {
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
prefix, errPeek := reader.Peek(1)
|
||||
if errPeek != nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close connection after protocol peek failure: %v", errClose)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isRedisRESPPrefix(prefix[0]) {
|
||||
if s.cfg != nil && s.cfg.Home.Enabled {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close redis connection while home mode is enabled: %v", errClose)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !s.managementRoutesEnabled.Load() {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close redis connection while management is disabled: %v", errClose)
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
s.handleRedisConnection(conn, reader)
|
||||
return
|
||||
}
|
||||
|
||||
if httpListener == nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close connection without HTTP listener: %v", errClose)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if errPut := httpListener.Put(&bufferedConn{Conn: conn, reader: reader}); errPut != nil {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("failed to close connection after HTTP routing failure: %v", errClose)
|
||||
}
|
||||
} else {
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAcceptMuxNotBlockedByIdleConnection(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
var routed atomic.Int32
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
routed.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv := httptest.NewUnstartedServer(handler)
|
||||
defer srv.Close()
|
||||
|
||||
muxLn := newMuxListener(listener.Addr(), 1024)
|
||||
server := &Server{managementRoutesEnabled: atomic.Bool{}}
|
||||
server.managementRoutesEnabled.Store(false)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.acceptMuxConnections(listener, muxLn)
|
||||
}()
|
||||
|
||||
srv.Listener = muxLn
|
||||
srv.Start()
|
||||
|
||||
// Open an idle TCP connection that never sends any bytes.
|
||||
idleConn, err := net.DialTimeout("tcp", listener.Addr().String(), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial idle connection: %v", err)
|
||||
}
|
||||
defer idleConn.Close()
|
||||
|
||||
// Give the accept loop time to pick up the idle connection.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Send a real HTTP request. Before the fix, the accept loop would be
|
||||
// blocked on Peek(1) for the idle connection, causing this request to
|
||||
// time out.
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get("http://" + listener.Addr().String() + "/")
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
t.Fatalf("HTTP request failed (accept loop may be blocked by idle connection): %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
listener.Close()
|
||||
|
||||
if routed.Load() == 0 {
|
||||
t.Error("expected at least one request to be routed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const redisUsageChannel = "usage"
|
||||
|
||||
type redisSubscriptionCommand struct {
|
||||
args []string
|
||||
err error
|
||||
}
|
||||
|
||||
func isRedisRESPPrefix(prefix byte) bool {
|
||||
switch prefix {
|
||||
case '*', '$', '+', '-', ':':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
|
||||
if s == nil || conn == nil || reader == nil {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP, localClient := resolveRemoteIP(conn.RemoteAddr())
|
||||
authed := false
|
||||
writer := bufio.NewWriter(conn)
|
||||
defer func() {
|
||||
if errClose := conn.Close(); errClose != nil {
|
||||
log.Errorf("redis connection close error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
flush := func() bool {
|
||||
if errFlush := writer.Flush(); errFlush != nil {
|
||||
log.Errorf("redis protocol flush error: %v", errFlush)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if s.cfg != nil && s.cfg.Home.Enabled {
|
||||
_ = writeRedisError(writer, "ERR redis usage output disabled in home mode")
|
||||
_ = writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
if !s.managementRoutesEnabled.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
args, err := readRESPArray(reader)
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
_ = writeRedisError(writer, "ERR "+err.Error())
|
||||
_ = writer.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) == 0 {
|
||||
_ = writeRedisError(writer, "ERR empty command")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := strings.ToUpper(strings.TrimSpace(args[0]))
|
||||
|
||||
if cmd != "AUTH" && !authed {
|
||||
if s.mgmt != nil {
|
||||
_, statusCode, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, "")
|
||||
if statusCode == http.StatusForbidden && strings.HasPrefix(errMsg, "IP banned due to too many failed attempts") {
|
||||
_ = writeRedisError(writer, "ERR "+errMsg)
|
||||
} else {
|
||||
_ = writeRedisError(writer, "NOAUTH Authentication required.")
|
||||
}
|
||||
} else {
|
||||
_ = writeRedisError(writer, "NOAUTH Authentication required.")
|
||||
}
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "AUTH":
|
||||
password, ok := parseAuthPassword(args)
|
||||
if !ok {
|
||||
if s.mgmt != nil {
|
||||
_, statusCode, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, "")
|
||||
if statusCode == http.StatusForbidden && strings.HasPrefix(errMsg, "IP banned due to too many failed attempts") {
|
||||
_ = writeRedisError(writer, "ERR "+errMsg)
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = writeRedisError(writer, "ERR wrong number of arguments for 'auth' command")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.mgmt == nil {
|
||||
_ = writeRedisError(writer, "ERR remote management disabled")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
allowed, _, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, password)
|
||||
if !allowed {
|
||||
_ = writeRedisError(writer, "ERR "+errMsg)
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
authed = true
|
||||
_ = writeRedisSimpleString(writer, "OK")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
case "SUBSCRIBE":
|
||||
if !authed {
|
||||
_ = writeRedisError(writer, "NOAUTH Authentication required.")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
channel, ok := parseSubscribeChannel(args)
|
||||
if !ok {
|
||||
_ = writeRedisError(writer, "ERR wrong number of arguments for 'subscribe' command")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(channel, redisUsageChannel) {
|
||||
_ = writeRedisError(writer, fmt.Sprintf("ERR unsupported channel '%s'", channel))
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
messages, unsubscribe := redisqueue.SubscribeUsage()
|
||||
if errWrite := writeRedisPubSubSubscribe(writer, redisUsageChannel, 1); errWrite != nil {
|
||||
unsubscribe()
|
||||
log.Errorf("redis protocol subscribe response error: %v", errWrite)
|
||||
return
|
||||
}
|
||||
if !flush() {
|
||||
unsubscribe()
|
||||
return
|
||||
}
|
||||
s.streamRedisUsageSubscription(reader, writer, messages, unsubscribe)
|
||||
return
|
||||
case "LPOP", "RPOP":
|
||||
if !authed {
|
||||
_ = writeRedisError(writer, "NOAUTH Authentication required.")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
count, hasCount, ok := parsePopCount(args)
|
||||
if !ok {
|
||||
_ = writeRedisError(writer, "ERR wrong number of arguments for '"+strings.ToLower(cmd)+"' command")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if count <= 0 {
|
||||
_ = writeRedisError(writer, "ERR value is not an integer or out of range")
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
items := redisqueue.PopOldest(count)
|
||||
if hasCount {
|
||||
_ = writeRedisArrayOfBulkStrings(writer, items)
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(items) == 0 {
|
||||
_ = writeRedisNilBulkString(writer)
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
_ = writeRedisBulkString(writer, items[0])
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
default:
|
||||
_ = writeRedisError(writer, fmt.Sprintf("ERR unknown command '%s'", strings.ToLower(cmd)))
|
||||
if !flush() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) streamRedisUsageSubscription(reader *bufio.Reader, writer *bufio.Writer, messages <-chan []byte, unsubscribe func()) {
|
||||
if unsubscribe == nil {
|
||||
return
|
||||
}
|
||||
defer unsubscribe()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
commands := make(chan redisSubscriptionCommand, 1)
|
||||
go readRedisSubscriptionCommands(reader, commands, done)
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-messages:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if errWrite := writeRedisPubSubMessage(writer, redisUsageChannel, msg); errWrite != nil {
|
||||
log.Errorf("redis protocol publish message error: %v", errWrite)
|
||||
return
|
||||
}
|
||||
if errFlush := writer.Flush(); errFlush != nil {
|
||||
log.Errorf("redis protocol flush error: %v", errFlush)
|
||||
return
|
||||
}
|
||||
case command, ok := <-commands:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keepOpen := handleRedisSubscriptionCommand(writer, command)
|
||||
if errFlush := writer.Flush(); errFlush != nil {
|
||||
log.Errorf("redis protocol flush error: %v", errFlush)
|
||||
return
|
||||
}
|
||||
if !keepOpen {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readRedisSubscriptionCommands(reader *bufio.Reader, commands chan<- redisSubscriptionCommand, done <-chan struct{}) {
|
||||
defer close(commands)
|
||||
|
||||
for {
|
||||
args, err := readRESPArray(reader)
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
select {
|
||||
case commands <- redisSubscriptionCommand{err: err}:
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case commands <- redisSubscriptionCommand{args: args}:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleRedisSubscriptionCommand(writer *bufio.Writer, command redisSubscriptionCommand) bool {
|
||||
if command.err != nil {
|
||||
_ = writeRedisError(writer, "ERR "+command.err.Error())
|
||||
return false
|
||||
}
|
||||
if len(command.args) == 0 {
|
||||
_ = writeRedisError(writer, "ERR empty command")
|
||||
return true
|
||||
}
|
||||
|
||||
cmd := strings.ToUpper(strings.TrimSpace(command.args[0]))
|
||||
switch cmd {
|
||||
case "PING":
|
||||
payload := []byte(nil)
|
||||
if len(command.args) > 1 {
|
||||
payload = []byte(command.args[1])
|
||||
}
|
||||
_ = writeRedisPubSubPong(writer, payload)
|
||||
return true
|
||||
case "UNSUBSCRIBE":
|
||||
_ = writeRedisPubSubUnsubscribe(writer, redisUsageChannel, 0)
|
||||
return false
|
||||
case "QUIT":
|
||||
_ = writeRedisSimpleString(writer, "OK")
|
||||
return false
|
||||
default:
|
||||
_ = writeRedisError(writer, fmt.Sprintf("ERR unknown command '%s'", strings.ToLower(cmd)))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRemoteIP(addr net.Addr) (ip string, localClient bool) {
|
||||
if addr == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var host string
|
||||
switch a := addr.(type) {
|
||||
case *net.TCPAddr:
|
||||
if a != nil && a.IP != nil {
|
||||
if ip4 := a.IP.To4(); ip4 != nil {
|
||||
host = ip4.String()
|
||||
} else {
|
||||
host = a.IP.String()
|
||||
}
|
||||
}
|
||||
default:
|
||||
host = addr.String()
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if raw, _, ok := strings.Cut(host, "%"); ok {
|
||||
host = raw
|
||||
}
|
||||
if parsed := net.ParseIP(host); parsed != nil {
|
||||
if ip4 := parsed.To4(); ip4 != nil {
|
||||
host = ip4.String()
|
||||
} else {
|
||||
host = parsed.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host = strings.TrimSpace(host)
|
||||
localClient = host == "127.0.0.1" || host == "::1"
|
||||
return host, localClient
|
||||
}
|
||||
|
||||
func parseAuthPassword(args []string) (string, bool) {
|
||||
switch len(args) {
|
||||
case 2:
|
||||
return args[1], true
|
||||
case 3:
|
||||
// Support AUTH <username> <password> by ignoring username for compatibility.
|
||||
return args[2], true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func parseSubscribeChannel(args []string) (string, bool) {
|
||||
if len(args) != 2 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(args[1]), true
|
||||
}
|
||||
|
||||
func parsePopCount(args []string) (count int, hasCount bool, ok bool) {
|
||||
if len(args) != 2 && len(args) != 3 {
|
||||
return 0, false, false
|
||||
}
|
||||
if len(args) == 2 {
|
||||
return 1, false, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(args[2]))
|
||||
if err != nil {
|
||||
return 0, true, true
|
||||
}
|
||||
return parsed, true, true
|
||||
}
|
||||
|
||||
func readRESPArray(reader *bufio.Reader) ([]string, error) {
|
||||
prefix, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prefix != '*' {
|
||||
return nil, fmt.Errorf("protocol error")
|
||||
}
|
||||
line, err := readRESPLine(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := strconv.Atoi(line)
|
||||
if err != nil || count < 0 {
|
||||
return nil, fmt.Errorf("protocol error")
|
||||
}
|
||||
args := make([]string, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
value, err := readRESPString(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, value)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func readRESPString(reader *bufio.Reader) (string, error) {
|
||||
prefix, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch prefix {
|
||||
case '$':
|
||||
return readRESPBulkString(reader)
|
||||
case '+', ':':
|
||||
return readRESPLine(reader)
|
||||
default:
|
||||
return "", fmt.Errorf("protocol error")
|
||||
}
|
||||
}
|
||||
|
||||
func readRESPBulkString(reader *bufio.Reader) (string, error) {
|
||||
line, err := readRESPLine(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
length, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("protocol error")
|
||||
}
|
||||
if length < 0 {
|
||||
return "", nil
|
||||
}
|
||||
buf := make([]byte, length+2)
|
||||
if _, err := io.ReadFull(reader, buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if length+2 < 2 || buf[length] != '\r' || buf[length+1] != '\n' {
|
||||
return "", fmt.Errorf("protocol error")
|
||||
}
|
||||
return string(buf[:length]), nil
|
||||
}
|
||||
|
||||
func readRESPLine(reader *bufio.Reader) (string, error) {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
line = strings.TrimSuffix(line, "\n")
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
return line, nil
|
||||
}
|
||||
|
||||
func writeRedisSimpleString(writer *bufio.Writer, value string) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
_, err := writer.WriteString("+" + value + "\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeRedisError(writer *bufio.Writer, message string) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
_, err := writer.WriteString("-" + message + "\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeRedisNilBulkString(writer *bufio.Writer) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
_, err := writer.WriteString("$-1\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeRedisBulkString(writer *bufio.Writer, payload []byte) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
if payload == nil {
|
||||
return writeRedisNilBulkString(writer)
|
||||
}
|
||||
if _, err := writer.WriteString("$" + strconv.Itoa(len(payload)) + "\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := writer.WriteString("\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeRedisArrayOfBulkStrings(writer *bufio.Writer, items [][]byte) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
if _, err := writer.WriteString("*" + strconv.Itoa(len(items)) + "\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range items {
|
||||
if err := writeRedisBulkString(writer, items[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRedisInteger(writer *bufio.Writer, value int) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
_, err := writer.WriteString(":" + strconv.Itoa(value) + "\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeRedisArrayHeader(writer *bufio.Writer, count int) error {
|
||||
if writer == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
_, err := writer.WriteString("*" + strconv.Itoa(count) + "\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func writeRedisPubSubSubscribe(writer *bufio.Writer, channel string, count int) error {
|
||||
if err := writeRedisArrayHeader(writer, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte("subscribe")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte(channel)); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRedisInteger(writer, count)
|
||||
}
|
||||
|
||||
func writeRedisPubSubUnsubscribe(writer *bufio.Writer, channel string, count int) error {
|
||||
if err := writeRedisArrayHeader(writer, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte("unsubscribe")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte(channel)); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRedisInteger(writer, count)
|
||||
}
|
||||
|
||||
func writeRedisPubSubMessage(writer *bufio.Writer, channel string, payload []byte) error {
|
||||
if err := writeRedisArrayHeader(writer, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte("message")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte(channel)); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRedisBulkString(writer, payload)
|
||||
}
|
||||
|
||||
func writeRedisPubSubPong(writer *bufio.Writer, payload []byte) error {
|
||||
if err := writeRedisArrayHeader(writer, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeRedisBulkString(writer, []byte("pong")); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRedisBulkString(writer, payload)
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
)
|
||||
|
||||
type remoteAddrConn struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
func (c *remoteAddrConn) RemoteAddr() net.Addr {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
func startRedisMuxListener(t *testing.T, server *Server) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
|
||||
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
||||
if errListen != nil {
|
||||
t.Fatalf("failed to listen: %v", errListen)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.acceptMuxConnections(listener, nil)
|
||||
}()
|
||||
|
||||
stop = func() {
|
||||
_ = listener.Close()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
t.Errorf("accept loop returned unexpected error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Errorf("timeout waiting for accept loop to exit")
|
||||
}
|
||||
}
|
||||
|
||||
return listener.Addr().String(), stop
|
||||
}
|
||||
|
||||
func writeTestRESPCommand(conn net.Conn, args ...string) error {
|
||||
if conn == nil {
|
||||
return net.ErrClosed
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "*%d\r\n", len(args))
|
||||
for _, arg := range args {
|
||||
fmt.Fprintf(&buf, "$%d\r\n%s\r\n", len(arg), arg)
|
||||
}
|
||||
_, err := conn.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func readTestRESPLine(r *bufio.Reader) (string, error) {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasSuffix(line, "\r\n") {
|
||||
return "", fmt.Errorf("invalid RESP line terminator: %q", line)
|
||||
}
|
||||
return strings.TrimSuffix(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
func readTestRESPSimpleString(r *bufio.Reader) (string, error) {
|
||||
prefix, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if prefix != '+' {
|
||||
return "", fmt.Errorf("expected simple string prefix '+', got %q", prefix)
|
||||
}
|
||||
return readTestRESPLine(r)
|
||||
}
|
||||
|
||||
func readTestRESPError(r *bufio.Reader) (string, error) {
|
||||
prefix, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if prefix != '-' {
|
||||
return "", fmt.Errorf("expected error prefix '-', got %q", prefix)
|
||||
}
|
||||
return readTestRESPLine(r)
|
||||
}
|
||||
|
||||
func readTestRESPBulkString(r *bufio.Reader) ([]byte, error) {
|
||||
prefix, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prefix != '$' {
|
||||
return nil, fmt.Errorf("expected bulk string prefix '$', got %q", prefix)
|
||||
}
|
||||
|
||||
line, err := readTestRESPLine(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
length, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bulk string length %q: %v", line, err)
|
||||
}
|
||||
if length == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
if length < -1 {
|
||||
return nil, fmt.Errorf("invalid bulk string length %d", length)
|
||||
}
|
||||
|
||||
payload := make([]byte, length+2)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload[length] != '\r' || payload[length+1] != '\n' {
|
||||
return nil, fmt.Errorf("invalid bulk string terminator")
|
||||
}
|
||||
return payload[:length], nil
|
||||
}
|
||||
|
||||
func readRESPArrayOfBulkStrings(r *bufio.Reader) ([][]byte, error) {
|
||||
prefix, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prefix != '*' {
|
||||
return nil, fmt.Errorf("expected array prefix '*', got %q", prefix)
|
||||
}
|
||||
|
||||
line, err := readTestRESPLine(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid array length %q: %v", line, err)
|
||||
}
|
||||
if count < 0 {
|
||||
return nil, fmt.Errorf("invalid array length %d", count)
|
||||
}
|
||||
|
||||
out := make([][]byte, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
item, err := readTestRESPBulkString(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readTestRESPInteger(r *bufio.Reader) (int, error) {
|
||||
prefix, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if prefix != ':' {
|
||||
return 0, fmt.Errorf("expected integer prefix ':', got %q", prefix)
|
||||
}
|
||||
|
||||
line, err := readTestRESPLine(r)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer %q: %v", line, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readTestRESPArrayHeader(r *bufio.Reader) (int, error) {
|
||||
prefix, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if prefix != '*' {
|
||||
return 0, fmt.Errorf("expected array prefix '*', got %q", prefix)
|
||||
}
|
||||
|
||||
line, err := readTestRESPLine(r)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid array length %q: %v", line, err)
|
||||
}
|
||||
if count < 0 {
|
||||
return 0, fmt.Errorf("invalid array length %d", count)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func readTestRESPPubSubSubscribe(r *bufio.Reader) (string, int, error) {
|
||||
count, err := readTestRESPArrayHeader(r)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if count != 3 {
|
||||
return "", 0, fmt.Errorf("subscribe array length = %d, want 3", count)
|
||||
}
|
||||
|
||||
kind, err := readTestRESPBulkString(r)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if string(kind) != "subscribe" {
|
||||
return "", 0, fmt.Errorf("pubsub kind = %q, want subscribe", string(kind))
|
||||
}
|
||||
|
||||
channel, err := readTestRESPBulkString(r)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
subscriptions, err := readTestRESPInteger(r)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return string(channel), subscriptions, nil
|
||||
}
|
||||
|
||||
func readTestRESPPubSubMessage(r *bufio.Reader) (string, []byte, error) {
|
||||
count, err := readTestRESPArrayHeader(r)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if count != 3 {
|
||||
return "", nil, fmt.Errorf("message array length = %d, want 3", count)
|
||||
}
|
||||
|
||||
kind, err := readTestRESPBulkString(r)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if string(kind) != "message" {
|
||||
return "", nil, fmt.Errorf("pubsub kind = %q, want message", string(kind))
|
||||
}
|
||||
|
||||
channel, err := readTestRESPBulkString(r)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
payload, err := readTestRESPBulkString(r)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(channel), payload, nil
|
||||
}
|
||||
|
||||
func TestRedisProtocol_ManagementDisabled_RejectsConnection(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
redisqueue.SetEnabled(false)
|
||||
|
||||
server := newTestServer(t)
|
||||
if server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be false")
|
||||
}
|
||||
|
||||
addr, stop := startRedisMuxListener(t, server)
|
||||
t.Cleanup(stop)
|
||||
|
||||
conn, errDial := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDial != nil {
|
||||
t.Fatalf("failed to dial redis listener: %v", errDial)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if errWrite := writeTestRESPCommand(conn, "PING"); errWrite != nil {
|
||||
t.Fatalf("failed to write RESP command: %v", errWrite)
|
||||
}
|
||||
|
||||
buf := make([]byte, 1)
|
||||
_, errRead := conn.Read(buf)
|
||||
if errRead == nil {
|
||||
t.Fatalf("expected connection to be closed when management is disabled")
|
||||
}
|
||||
if ne, ok := errRead.(net.Error); ok && ne.Timeout() {
|
||||
t.Fatalf("expected connection to be closed when management is disabled, got timeout: %v", errRead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisProtocol_HomeEnabled_DisablesConnection(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "test-management-password")
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() { redisqueue.SetEnabled(false) })
|
||||
|
||||
server := newTestServer(t)
|
||||
if !server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be true")
|
||||
}
|
||||
if server.cfg == nil {
|
||||
t.Fatalf("expected server cfg to be non-nil")
|
||||
}
|
||||
server.cfg.Home.Enabled = true
|
||||
redisqueue.SetEnabled(true)
|
||||
|
||||
addr, stop := startRedisMuxListener(t, server)
|
||||
t.Cleanup(stop)
|
||||
|
||||
conn, errDial := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDial != nil {
|
||||
t.Fatalf("failed to dial redis listener: %v", errDial)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
_ = writeTestRESPCommand(conn, "PING")
|
||||
|
||||
buf := make([]byte, 1)
|
||||
_, errRead := conn.Read(buf)
|
||||
if errRead == nil {
|
||||
t.Fatalf("expected connection to be closed when home mode is enabled")
|
||||
}
|
||||
if ne, ok := errRead.(net.Error); ok && ne.Timeout() {
|
||||
t.Fatalf("expected connection to be closed when home mode is enabled, got timeout: %v", errRead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) {
|
||||
const managementPassword = "test-management-password"
|
||||
|
||||
t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() { redisqueue.SetEnabled(false) })
|
||||
|
||||
server := newTestServer(t)
|
||||
if !server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be true")
|
||||
}
|
||||
|
||||
addr, stop := startRedisMuxListener(t, server)
|
||||
t.Cleanup(stop)
|
||||
|
||||
conn, errDial := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDial != nil {
|
||||
t.Fatalf("failed to dial redis listener: %v", errDial)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "AUTH", "test-key"); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPError(reader); err != nil {
|
||||
t.Fatalf("failed to read AUTH error: %v", err)
|
||||
} else if msg != "ERR invalid management key" {
|
||||
t.Fatalf("unexpected AUTH error: %q", msg)
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil {
|
||||
t.Fatalf("failed to write LPOP command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPError(reader); err != nil {
|
||||
t.Fatalf("failed to read LPOP NOAUTH error: %v", err)
|
||||
} else if msg != "NOAUTH Authentication required." {
|
||||
t.Fatalf("unexpected LPOP NOAUTH error: %q", msg)
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPSimpleString(reader); err != nil {
|
||||
t.Fatalf("failed to read AUTH response: %v", err)
|
||||
} else if msg != "OK" {
|
||||
t.Fatalf("unexpected AUTH response: %q", msg)
|
||||
}
|
||||
|
||||
if !redisqueue.Enabled() {
|
||||
t.Fatalf("expected redisqueue to be enabled")
|
||||
}
|
||||
redisqueue.Enqueue([]byte("a"))
|
||||
redisqueue.Enqueue([]byte("b"))
|
||||
redisqueue.Enqueue([]byte("c"))
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "RPOP", "queue"); errWrite != nil {
|
||||
t.Fatalf("failed to write RPOP command: %v", errWrite)
|
||||
}
|
||||
if item, err := readTestRESPBulkString(reader); err != nil {
|
||||
t.Fatalf("failed to read RPOP response: %v", err)
|
||||
} else if string(item) != "a" {
|
||||
t.Fatalf("unexpected RPOP item: %q", string(item))
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil {
|
||||
t.Fatalf("failed to write LPOP command: %v", errWrite)
|
||||
}
|
||||
if item, err := readTestRESPBulkString(reader); err != nil {
|
||||
t.Fatalf("failed to read LPOP response: %v", err)
|
||||
} else if string(item) != "b" {
|
||||
t.Fatalf("unexpected LPOP item: %q", string(item))
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "RPOP", "queue", "10"); errWrite != nil {
|
||||
t.Fatalf("failed to write RPOP count command: %v", errWrite)
|
||||
}
|
||||
items, errItems := readRESPArrayOfBulkStrings(reader)
|
||||
if errItems != nil {
|
||||
t.Fatalf("failed to read RPOP count response: %v", errItems)
|
||||
}
|
||||
if len(items) != 1 || string(items[0]) != "c" {
|
||||
t.Fatalf("unexpected RPOP count items: %#v", items)
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil {
|
||||
t.Fatalf("failed to write LPOP empty command: %v", errWrite)
|
||||
}
|
||||
item, errItem := readTestRESPBulkString(reader)
|
||||
if errItem != nil {
|
||||
t.Fatalf("failed to read LPOP empty response: %v", errItem)
|
||||
}
|
||||
if item != nil {
|
||||
t.Fatalf("expected nil bulk string for empty queue, got %q", string(item))
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "RPOP", "queue", "2"); errWrite != nil {
|
||||
t.Fatalf("failed to write RPOP empty count command: %v", errWrite)
|
||||
}
|
||||
emptyItems, errEmpty := readRESPArrayOfBulkStrings(reader)
|
||||
if errEmpty != nil {
|
||||
t.Fatalf("failed to read RPOP empty count response: %v", errEmpty)
|
||||
}
|
||||
if len(emptyItems) != 0 {
|
||||
t.Fatalf("expected empty array for empty queue with count, got %#v", emptyItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisProtocol_SubscribeUsageBroadcastsAndSkipsQueue(t *testing.T) {
|
||||
const managementPassword = "test-management-password"
|
||||
|
||||
t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() { redisqueue.SetEnabled(false) })
|
||||
|
||||
server := newTestServer(t)
|
||||
if !server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be true")
|
||||
}
|
||||
|
||||
addr, stop := startRedisMuxListener(t, server)
|
||||
t.Cleanup(stop)
|
||||
|
||||
firstConn, errDialFirst := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDialFirst != nil {
|
||||
t.Fatalf("failed to dial first redis listener: %v", errDialFirst)
|
||||
}
|
||||
t.Cleanup(func() { _ = firstConn.Close() })
|
||||
firstReader := bufio.NewReader(firstConn)
|
||||
_ = firstConn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if errWrite := writeTestRESPCommand(firstConn, "AUTH", managementPassword); errWrite != nil {
|
||||
t.Fatalf("failed to write first AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPSimpleString(firstReader); err != nil {
|
||||
t.Fatalf("failed to read first AUTH response: %v", err)
|
||||
} else if msg != "OK" {
|
||||
t.Fatalf("unexpected first AUTH response: %q", msg)
|
||||
}
|
||||
if errWrite := writeTestRESPCommand(firstConn, "SUBSCRIBE", "usage"); errWrite != nil {
|
||||
t.Fatalf("failed to write first SUBSCRIBE command: %v", errWrite)
|
||||
}
|
||||
if channel, count, err := readTestRESPPubSubSubscribe(firstReader); err != nil {
|
||||
t.Fatalf("failed to read first SUBSCRIBE response: %v", err)
|
||||
} else if channel != "usage" || count != 1 {
|
||||
t.Fatalf("unexpected first SUBSCRIBE response channel=%q count=%d", channel, count)
|
||||
}
|
||||
|
||||
secondConn, errDialSecond := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDialSecond != nil {
|
||||
t.Fatalf("failed to dial second redis listener: %v", errDialSecond)
|
||||
}
|
||||
t.Cleanup(func() { _ = secondConn.Close() })
|
||||
secondReader := bufio.NewReader(secondConn)
|
||||
_ = secondConn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if errWrite := writeTestRESPCommand(secondConn, "AUTH", managementPassword); errWrite != nil {
|
||||
t.Fatalf("failed to write second AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPSimpleString(secondReader); err != nil {
|
||||
t.Fatalf("failed to read second AUTH response: %v", err)
|
||||
} else if msg != "OK" {
|
||||
t.Fatalf("unexpected second AUTH response: %q", msg)
|
||||
}
|
||||
if errWrite := writeTestRESPCommand(secondConn, "SUBSCRIBE", "usage"); errWrite != nil {
|
||||
t.Fatalf("failed to write second SUBSCRIBE command: %v", errWrite)
|
||||
}
|
||||
if channel, count, err := readTestRESPPubSubSubscribe(secondReader); err != nil {
|
||||
t.Fatalf("failed to read second SUBSCRIBE response: %v", err)
|
||||
} else if channel != "usage" || count != 1 {
|
||||
t.Fatalf("unexpected second SUBSCRIBE response channel=%q count=%d", channel, count)
|
||||
}
|
||||
|
||||
redisqueue.Enqueue([]byte(`{"id":1}`))
|
||||
|
||||
if channel, payload, err := readTestRESPPubSubMessage(firstReader); err != nil {
|
||||
t.Fatalf("failed to read first pubsub message: %v", err)
|
||||
} else if channel != "usage" || string(payload) != `{"id":1}` {
|
||||
t.Fatalf("unexpected first pubsub message channel=%q payload=%q", channel, string(payload))
|
||||
}
|
||||
if channel, payload, err := readTestRESPPubSubMessage(secondReader); err != nil {
|
||||
t.Fatalf("failed to read second pubsub message: %v", err)
|
||||
} else if channel != "usage" || string(payload) != `{"id":1}` {
|
||||
t.Fatalf("unexpected second pubsub message channel=%q payload=%q", channel, string(payload))
|
||||
}
|
||||
|
||||
popConn, errDialPop := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDialPop != nil {
|
||||
t.Fatalf("failed to dial pop redis listener: %v", errDialPop)
|
||||
}
|
||||
t.Cleanup(func() { _ = popConn.Close() })
|
||||
popReader := bufio.NewReader(popConn)
|
||||
_ = popConn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
if errWrite := writeTestRESPCommand(popConn, "AUTH", managementPassword); errWrite != nil {
|
||||
t.Fatalf("failed to write pop AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPSimpleString(popReader); err != nil {
|
||||
t.Fatalf("failed to read pop AUTH response: %v", err)
|
||||
} else if msg != "OK" {
|
||||
t.Fatalf("unexpected pop AUTH response: %q", msg)
|
||||
}
|
||||
if errWrite := writeTestRESPCommand(popConn, "LPOP", "usage"); errWrite != nil {
|
||||
t.Fatalf("failed to write pop LPOP command: %v", errWrite)
|
||||
}
|
||||
item, errItem := readTestRESPBulkString(popReader)
|
||||
if errItem != nil {
|
||||
t.Fatalf("failed to read pop LPOP response: %v", errItem)
|
||||
}
|
||||
if item != nil {
|
||||
t.Fatalf("expected subscribed usage to skip queue, got %q", string(item))
|
||||
}
|
||||
|
||||
managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=1", nil)
|
||||
managementReq.Header.Set("Authorization", "Bearer "+managementPassword)
|
||||
managementRR := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(managementRR, managementReq)
|
||||
if managementRR.Code != http.StatusOK {
|
||||
t.Fatalf("management usage status = %d, want %d body=%s", managementRR.Code, http.StatusOK, managementRR.Body.String())
|
||||
}
|
||||
var managementPayload []json.RawMessage
|
||||
if errUnmarshal := json.Unmarshal(managementRR.Body.Bytes(), &managementPayload); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal management usage response: %v", errUnmarshal)
|
||||
}
|
||||
if len(managementPayload) != 0 {
|
||||
t.Fatalf("expected management usage queue to be empty, got %s", managementRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisProtocol_IPBan_MirrorsManagementPolicy(t *testing.T) {
|
||||
const managementPassword = "test-management-password"
|
||||
|
||||
t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() { redisqueue.SetEnabled(false) })
|
||||
|
||||
server := newTestServer(t)
|
||||
if !server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be true")
|
||||
}
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() { _ = clientConn.Close() })
|
||||
t.Cleanup(func() { _ = serverConn.Close() })
|
||||
|
||||
fakeRemote := &net.TCPAddr{
|
||||
IP: net.ParseIP("1.2.3.4"),
|
||||
Port: 1234,
|
||||
}
|
||||
wrappedConn := &remoteAddrConn{Conn: serverConn, remoteAddr: fakeRemote}
|
||||
|
||||
go server.handleRedisConnection(wrappedConn, bufio.NewReader(wrappedConn))
|
||||
|
||||
reader := bufio.NewReader(clientConn)
|
||||
_ = clientConn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if errWrite := writeTestRESPCommand(clientConn, "LPOP", "queue"); errWrite != nil {
|
||||
t.Fatalf("failed to write LPOP command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPError(reader); err != nil {
|
||||
t.Fatalf("failed to read LPOP NOAUTH error: %v", err)
|
||||
} else if msg != "NOAUTH Authentication required." {
|
||||
t.Fatalf("unexpected LPOP NOAUTH error at attempt %d: %q", i+1, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(clientConn, "LPOP", "queue"); errWrite != nil {
|
||||
t.Fatalf("failed to write LPOP command after failures: %v", errWrite)
|
||||
}
|
||||
msg, err := readTestRESPError(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read LPOP banned error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
|
||||
t.Fatalf("unexpected LPOP banned error: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisProtocol_AUTH_IPBan_BlocksCorrectPasswordDuringBan(t *testing.T) {
|
||||
const managementPassword = "test-management-password"
|
||||
|
||||
t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() { redisqueue.SetEnabled(false) })
|
||||
|
||||
server := newTestServer(t)
|
||||
if !server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be true")
|
||||
}
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() { _ = clientConn.Close() })
|
||||
t.Cleanup(func() { _ = serverConn.Close() })
|
||||
|
||||
fakeRemote := &net.TCPAddr{
|
||||
IP: net.ParseIP("1.2.3.4"),
|
||||
Port: 1234,
|
||||
}
|
||||
wrappedConn := &remoteAddrConn{Conn: serverConn, remoteAddr: fakeRemote}
|
||||
|
||||
go server.handleRedisConnection(wrappedConn, bufio.NewReader(wrappedConn))
|
||||
|
||||
reader := bufio.NewReader(clientConn)
|
||||
_ = clientConn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if errWrite := writeTestRESPCommand(clientConn, "AUTH", "wrong-password"); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPError(reader); err != nil {
|
||||
t.Fatalf("failed to read AUTH error: %v", err)
|
||||
} else if msg != "ERR invalid management key" {
|
||||
t.Fatalf("unexpected AUTH error at attempt %d: %q", i+1, msg)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if errWrite := writeTestRESPCommand(clientConn, "AUTH", "wrong-password"); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command after failures: %v", errWrite)
|
||||
}
|
||||
msg, err := readTestRESPError(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read AUTH banned error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
|
||||
t.Fatalf("unexpected AUTH banned error at attempt %d: %q", i+6, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(clientConn, "AUTH", managementPassword); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command with correct password: %v", errWrite)
|
||||
}
|
||||
msg, err := readTestRESPError(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read AUTH banned error for correct password: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
|
||||
t.Fatalf("unexpected AUTH banned error for correct password: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisProtocol_LOCALHOST_AUTH_IPBan_BlocksCorrectPasswordDuringBan(t *testing.T) {
|
||||
const managementPassword = "test-management-password"
|
||||
|
||||
t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() { redisqueue.SetEnabled(false) })
|
||||
|
||||
server := newTestServer(t)
|
||||
if !server.managementRoutesEnabled.Load() {
|
||||
t.Fatalf("expected managementRoutesEnabled to be true")
|
||||
}
|
||||
|
||||
addr, stop := startRedisMuxListener(t, server)
|
||||
t.Cleanup(stop)
|
||||
|
||||
conn, errDial := net.DialTimeout("tcp", addr, time.Second)
|
||||
if errDial != nil {
|
||||
t.Fatalf("failed to dial redis listener: %v", errDial)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if errWrite := writeTestRESPCommand(conn, "AUTH", "wrong-password"); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command: %v", errWrite)
|
||||
}
|
||||
if msg, err := readTestRESPError(reader); err != nil {
|
||||
t.Fatalf("failed to read AUTH error: %v", err)
|
||||
} else if msg != "ERR invalid management key" {
|
||||
t.Fatalf("unexpected AUTH error at attempt %d: %q", i+1, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil {
|
||||
t.Fatalf("failed to write AUTH command with correct password: %v", errWrite)
|
||||
}
|
||||
msg, err := readTestRESPError(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read AUTH banned error for correct password: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
|
||||
t.Fatalf("unexpected AUTH banned error for correct password: %q", msg)
|
||||
}
|
||||
}
|
||||
+594
-65
@@ -7,36 +7,43 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/access"
|
||||
managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
|
||||
ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/access"
|
||||
managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api/middleware"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules"
|
||||
ampmodule "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules/amp"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/http2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -59,10 +66,10 @@ type ServerOption func(*serverOptionConfig)
|
||||
|
||||
func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger {
|
||||
configDir := filepath.Dir(configPath)
|
||||
if base := util.WritablePath(); base != "" {
|
||||
return logging.NewFileRequestLogger(cfg.RequestLog, filepath.Join(base, "logs"), configDir, cfg.ErrorLogsMaxFiles)
|
||||
}
|
||||
return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir, cfg.ErrorLogsMaxFiles)
|
||||
logsDir := logging.ResolveLogDirectory(cfg)
|
||||
logger := logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles)
|
||||
logger.SetHomeEnabled(cfg != nil && cfg.Home.Enabled)
|
||||
return logger
|
||||
}
|
||||
|
||||
// WithMiddleware appends additional Gin middleware during server construction.
|
||||
@@ -128,6 +135,12 @@ type Server struct {
|
||||
// server is the underlying HTTP server.
|
||||
server *http.Server
|
||||
|
||||
// muxBaseListener is the shared TCP listener used to serve both HTTP and Redis protocol traffic.
|
||||
muxBaseListener net.Listener
|
||||
|
||||
// muxHTTPListener receives HTTP connections selected by the multiplexer.
|
||||
muxHTTPListener *muxListener
|
||||
|
||||
// handlers contains the API handlers for processing requests.
|
||||
handlers *handlers.BaseAPIHandler
|
||||
|
||||
@@ -204,6 +217,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
|
||||
// Create gin engine
|
||||
engine := gin.New()
|
||||
if errSetTrustedProxies := engine.SetTrustedProxies(nil); errSetTrustedProxies != nil {
|
||||
log.Warnf("failed to disable trusted proxy headers: %v", errSetTrustedProxies)
|
||||
}
|
||||
if optionState.engineConfigurator != nil {
|
||||
optionState.engineConfigurator(engine)
|
||||
}
|
||||
@@ -259,10 +275,11 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
s.applyAccessConfig(nil, cfg)
|
||||
if authManager != nil {
|
||||
authManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second)
|
||||
authManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
||||
}
|
||||
managementasset.SetCurrentConfig(cfg)
|
||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
applySignatureCacheConfig(nil, cfg)
|
||||
// Initialize management handler
|
||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||
if optionState.localPassword != "" {
|
||||
@@ -275,6 +292,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
}
|
||||
s.localPassword = optionState.localPassword
|
||||
|
||||
// Home heartbeat gate: when home is enabled, block all endpoints with 503 until the
|
||||
// subscribe-config heartbeat connection is healthy.
|
||||
engine.Use(s.homeHeartbeatMiddleware())
|
||||
|
||||
// Setup routes
|
||||
s.setupRoutes()
|
||||
|
||||
@@ -299,6 +320,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
// or when a local management password is provided (e.g. TUI mode).
|
||||
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != ""
|
||||
s.managementRoutesEnabled.Store(hasManagementSecret)
|
||||
redisqueue.SetEnabled(hasManagementSecret || (cfg != nil && cfg.Home.Enabled))
|
||||
if hasManagementSecret {
|
||||
s.registerManagementRoutes()
|
||||
}
|
||||
@@ -316,9 +338,42 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if s == nil || s.cfg == nil || !s.cfg.Home.Enabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if c != nil && c.Request != nil {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
client := home.Current()
|
||||
if client == nil || !client.HeartbeatOK() {
|
||||
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// setupRoutes configures the API routes for the server.
|
||||
// It defines the endpoints and associates them with their respective handlers.
|
||||
func (s *Server) setupRoutes() {
|
||||
healthzHandler := func(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
s.engine.GET("/healthz", healthzHandler)
|
||||
s.engine.HEAD("/healthz", healthzHandler)
|
||||
|
||||
s.engine.GET("/management.html", s.serveManagementControlPanel)
|
||||
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
|
||||
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
|
||||
@@ -333,6 +388,13 @@ func (s *Server) setupRoutes() {
|
||||
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
|
||||
v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
|
||||
v1.POST("/completions", openaiHandlers.Completions)
|
||||
v1.POST("/images/generations", openaiHandlers.ImagesGenerations)
|
||||
v1.POST("/images/edits", openaiHandlers.ImagesEdits)
|
||||
v1.POST("/videos", openaiHandlers.VideosCreate)
|
||||
v1.POST("/videos/generations", openaiHandlers.XAIVideosGenerations)
|
||||
v1.POST("/videos/edits", openaiHandlers.XAIVideosEdits)
|
||||
v1.POST("/videos/extensions", openaiHandlers.XAIVideosExtensions)
|
||||
v1.GET("/videos/:request_id", openaiHandlers.XAIVideosRetrieve)
|
||||
v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
|
||||
v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
|
||||
v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
|
||||
@@ -340,13 +402,22 @@ func (s *Server) setupRoutes() {
|
||||
v1.POST("/responses/compact", openaiResponsesHandlers.Compact)
|
||||
}
|
||||
|
||||
// Codex CLI direct route aliases (chatgpt_base_url compatible)
|
||||
codexDirect := s.engine.Group("/backend-api/codex")
|
||||
codexDirect.Use(AuthMiddleware(s.accessManager))
|
||||
{
|
||||
codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
|
||||
codexDirect.POST("/responses", openaiResponsesHandlers.Responses)
|
||||
codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact)
|
||||
}
|
||||
|
||||
// Gemini compatible API routes
|
||||
v1beta := s.engine.Group("/v1beta")
|
||||
v1beta.Use(AuthMiddleware(s.accessManager))
|
||||
{
|
||||
v1beta.GET("/models", geminiHandlers.GeminiModels)
|
||||
v1beta.GET("/models", s.geminiModelsHandler(geminiHandlers))
|
||||
v1beta.POST("/models/*action", geminiHandlers.GeminiHandler)
|
||||
v1beta.GET("/models/*action", geminiHandlers.GeminiGetHandler)
|
||||
v1beta.GET("/models/*action", s.geminiGetHandler(geminiHandlers))
|
||||
}
|
||||
|
||||
// Root endpoint
|
||||
@@ -407,20 +478,6 @@ func (s *Server) setupRoutes() {
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/iflow/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
errStr := c.Query("error")
|
||||
if errStr == "" {
|
||||
errStr = c.Query("error_description")
|
||||
}
|
||||
if state != "" {
|
||||
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "iflow", state, code, errStr)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/antigravity/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
@@ -435,6 +492,20 @@ func (s *Server) setupRoutes() {
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/xai/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
errStr := c.Query("error")
|
||||
if errStr == "" {
|
||||
errStr = c.Query("error_description")
|
||||
}
|
||||
if state != "" {
|
||||
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "xai", state, code, errStr)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
// Management routes are registered lazily by registerManagementRoutes when a secret is configured.
|
||||
}
|
||||
|
||||
@@ -488,9 +559,6 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt := s.engine.Group("/v0/management")
|
||||
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
|
||||
{
|
||||
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||
mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics)
|
||||
mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics)
|
||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
|
||||
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
||||
@@ -535,6 +603,8 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
|
||||
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
||||
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
||||
mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage)
|
||||
mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue)
|
||||
|
||||
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
|
||||
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
|
||||
@@ -636,10 +706,8 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
|
||||
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
|
||||
mgmt.GET("/xai-auth-url", s.mgmt.RequestXAIToken)
|
||||
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
|
||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||
}
|
||||
@@ -647,6 +715,14 @@ func (s *Server) registerManagementRoutes() {
|
||||
|
||||
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if s == nil || s.cfg == nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if s.cfg.Home.Enabled {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !s.managementRoutesEnabled.Load() {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
@@ -657,7 +733,7 @@ func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
|
||||
|
||||
func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
||||
cfg := s.cfg
|
||||
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
|
||||
if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -769,6 +845,20 @@ func (s *Server) watchKeepAlive() {
|
||||
// otherwise it routes to OpenAI handler.
|
||||
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, ok := c.Request.URL.Query()["client_version"]; ok {
|
||||
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
||||
s.handleHomeCodexClientModels(c)
|
||||
return
|
||||
}
|
||||
openaiHandler.OpenAIModels(c)
|
||||
return
|
||||
}
|
||||
|
||||
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
||||
s.handleHomeModels(c)
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// Route to Claude handler if User-Agent starts with "claude-cli"
|
||||
@@ -782,6 +872,307 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeCodexClientModels(c *gin.Context) {
|
||||
entries, ok := s.loadHomeModelEntries(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
models := make([]map[string]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
model := map[string]any{
|
||||
"id": entry.id,
|
||||
"object": "model",
|
||||
}
|
||||
if entry.created > 0 {
|
||||
model["created"] = entry.created
|
||||
}
|
||||
if entry.ownedBy != "" {
|
||||
model["owned_by"] = entry.ownedBy
|
||||
}
|
||||
if entry.displayName != "" {
|
||||
model["display_name"] = entry.displayName
|
||||
model["description"] = entry.displayName
|
||||
}
|
||||
models = append(models, model)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, openai.CodexClientModelsResponse(models))
|
||||
}
|
||||
|
||||
func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
||||
s.handleHomeGeminiModels(c)
|
||||
return
|
||||
}
|
||||
|
||||
geminiHandler.GeminiModels(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) geminiGetHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
|
||||
s.handleHomeGeminiModel(c)
|
||||
return
|
||||
}
|
||||
|
||||
geminiHandler.GeminiGetHandler(c)
|
||||
}
|
||||
}
|
||||
|
||||
type homeModelEntry struct {
|
||||
id string
|
||||
created int64
|
||||
ownedBy string
|
||||
displayName string
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeModels(c *gin.Context) {
|
||||
entries, ok := s.loadHomeModelEntries(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
isClaude := strings.HasPrefix(userAgent, "claude-cli")
|
||||
|
||||
if isClaude {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
model := map[string]any{
|
||||
"id": entry.id,
|
||||
"object": "model",
|
||||
"owned_by": entry.ownedBy,
|
||||
}
|
||||
if entry.created > 0 {
|
||||
model["created_at"] = entry.created
|
||||
}
|
||||
if entry.displayName != "" {
|
||||
model["display_name"] = entry.displayName
|
||||
}
|
||||
out = append(out, model)
|
||||
}
|
||||
firstID := ""
|
||||
lastID := ""
|
||||
if len(out) > 0 {
|
||||
if id, okID := out[0]["id"].(string); okID {
|
||||
firstID = id
|
||||
}
|
||||
if id, okID := out[len(out)-1]["id"].(string); okID {
|
||||
lastID = id
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": out,
|
||||
"has_more": false,
|
||||
"first_id": firstID,
|
||||
"last_id": lastID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
model := map[string]any{
|
||||
"id": entry.id,
|
||||
"object": "model",
|
||||
}
|
||||
if entry.created > 0 {
|
||||
model["created"] = entry.created
|
||||
}
|
||||
if entry.ownedBy != "" {
|
||||
model["owned_by"] = entry.ownedBy
|
||||
}
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": filtered,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeGeminiModels(c *gin.Context) {
|
||||
entries, ok := s.loadHomeModelEntries(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"models": formatHomeGeminiModels(entries),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeGeminiModel(c *gin.Context) {
|
||||
entries, ok := s.loadHomeModelEntries(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.TrimPrefix(c.Param("action"), "/")
|
||||
action = strings.TrimSpace(action)
|
||||
for _, entry := range entries {
|
||||
if homeGeminiModelMatches(entry, action) {
|
||||
c.JSON(http.StatusOK, formatHomeGeminiModel(entry))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Not Found",
|
||||
Type: "not_found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) loadHomeModelEntries(c *gin.Context) ([]homeModelEntry, bool) {
|
||||
if s == nil || c == nil || c.Request == nil {
|
||||
return nil, false
|
||||
}
|
||||
client := home.Current()
|
||||
if client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "home control center unavailable",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
raw, errGet := client.GetModels(c.Request.Context())
|
||||
if errGet != nil {
|
||||
c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: errGet.Error(),
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
entries, errDecode := decodeHomeModels(raw)
|
||||
if errDecode != nil {
|
||||
c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: errDecode.Error(),
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entries, true
|
||||
}
|
||||
|
||||
func formatHomeGeminiModels(entries []homeModelEntry) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, formatHomeGeminiModel(entry))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatHomeGeminiModel(entry homeModelEntry) map[string]any {
|
||||
name := entry.id
|
||||
if !strings.HasPrefix(name, "models/") {
|
||||
name = "models/" + name
|
||||
}
|
||||
displayName := entry.displayName
|
||||
if displayName == "" {
|
||||
displayName = entry.id
|
||||
}
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"displayName": displayName,
|
||||
"description": displayName,
|
||||
"supportedGenerationMethods": []string{"generateContent"},
|
||||
}
|
||||
}
|
||||
|
||||
func homeGeminiModelMatches(entry homeModelEntry, action string) bool {
|
||||
id := strings.TrimSpace(entry.id)
|
||||
if id == "" || action == "" {
|
||||
return false
|
||||
}
|
||||
normalizedAction := strings.TrimPrefix(action, "models/")
|
||||
normalizedID := strings.TrimPrefix(id, "models/")
|
||||
return action == id || action == "models/"+id || normalizedAction == normalizedID
|
||||
}
|
||||
|
||||
func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("home models payload is empty")
|
||||
}
|
||||
|
||||
var bySection map[string][]map[string]any
|
||||
if err := json.Unmarshal(raw, &bySection); err != nil {
|
||||
return nil, fmt.Errorf("parse home models payload: %w", err)
|
||||
}
|
||||
if len(bySection) == 0 {
|
||||
return nil, fmt.Errorf("home models payload has no sections")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]homeModelEntry, 0, 256)
|
||||
for _, models := range bySection {
|
||||
for _, model := range models {
|
||||
id, _ := model["id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
name, _ := model["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
id = strings.TrimPrefix(name, "models/")
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
|
||||
created := int64(0)
|
||||
switch v := model["created"].(type) {
|
||||
case float64:
|
||||
created = int64(v)
|
||||
case int64:
|
||||
created = v
|
||||
case int:
|
||||
created = int64(v)
|
||||
case json.Number:
|
||||
if n, err := v.Int64(); err == nil {
|
||||
created = n
|
||||
}
|
||||
}
|
||||
|
||||
ownedBy, _ := model["owned_by"].(string)
|
||||
ownedBy = strings.TrimSpace(ownedBy)
|
||||
displayName, _ := model["display_name"].(string)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if displayName == "" {
|
||||
displayName, _ = model["displayName"].(string)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
}
|
||||
|
||||
out = append(out, homeModelEntry{
|
||||
id: id,
|
||||
created: created,
|
||||
ownedBy: ownedBy,
|
||||
displayName: displayName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("home models payload contains no models")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Start begins listening for and serving HTTP or HTTPS requests.
|
||||
// It's a blocking call and will only return on an unrecoverable error.
|
||||
//
|
||||
@@ -792,26 +1183,98 @@ func (s *Server) Start() error {
|
||||
return fmt.Errorf("failed to start HTTP server: server not initialized")
|
||||
}
|
||||
|
||||
addr := s.server.Addr
|
||||
listener, errListen := net.Listen("tcp", addr)
|
||||
if errListen != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errListen)
|
||||
}
|
||||
|
||||
useTLS := s.cfg != nil && s.cfg.TLS.Enable
|
||||
if useTLS {
|
||||
cert := strings.TrimSpace(s.cfg.TLS.Cert)
|
||||
key := strings.TrimSpace(s.cfg.TLS.Key)
|
||||
if cert == "" || key == "" {
|
||||
certPath := strings.TrimSpace(s.cfg.TLS.Cert)
|
||||
keyPath := strings.TrimSpace(s.cfg.TLS.Key)
|
||||
if certPath == "" || keyPath == "" {
|
||||
if errClose := listener.Close(); errClose != nil {
|
||||
log.Errorf("failed to close listener after TLS validation failure: %v", errClose)
|
||||
}
|
||||
return fmt.Errorf("failed to start HTTPS server: tls.cert or tls.key is empty")
|
||||
}
|
||||
log.Debugf("Starting API server on %s with TLS", s.server.Addr)
|
||||
if errServeTLS := s.server.ListenAndServeTLS(cert, key); errServeTLS != nil && !errors.Is(errServeTLS, http.ErrServerClosed) {
|
||||
return fmt.Errorf("failed to start HTTPS server: %v", errServeTLS)
|
||||
certPair, errLoad := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if errLoad != nil {
|
||||
if errClose := listener.Close(); errClose != nil {
|
||||
log.Errorf("failed to close listener after TLS key pair load failure: %v", errClose)
|
||||
}
|
||||
return fmt.Errorf("failed to start HTTPS server: %v", errLoad)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{certPair},
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
s.server.TLSConfig = tlsConfig
|
||||
if errHTTP2 := http2.ConfigureServer(s.server, &http2.Server{}); errHTTP2 != nil {
|
||||
log.Warnf("failed to configure HTTP/2: %v", errHTTP2)
|
||||
}
|
||||
listener = tls.NewListener(listener, tlsConfig)
|
||||
log.Debugf("Starting API server on %s with TLS", addr)
|
||||
} else {
|
||||
log.Debugf("Starting API server on %s", addr)
|
||||
}
|
||||
|
||||
httpListener := newMuxListener(listener.Addr(), 1024)
|
||||
s.muxBaseListener = listener
|
||||
s.muxHTTPListener = httpListener
|
||||
|
||||
httpErrCh := make(chan error, 1)
|
||||
acceptErrCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
httpErrCh <- s.server.Serve(httpListener)
|
||||
}()
|
||||
go func() {
|
||||
acceptErrCh <- s.acceptMuxConnections(listener, httpListener)
|
||||
}()
|
||||
|
||||
select {
|
||||
case errServe := <-httpErrCh:
|
||||
if s.muxBaseListener != nil {
|
||||
if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.Debugf("failed to close shared listener after HTTP serve exit: %v", errClose)
|
||||
}
|
||||
}
|
||||
if s.muxHTTPListener != nil {
|
||||
_ = s.muxHTTPListener.Close()
|
||||
}
|
||||
errAccept := <-acceptErrCh
|
||||
errServe = normalizeHTTPServeError(errServe)
|
||||
errAccept = normalizeListenerError(errAccept)
|
||||
if errServe != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errServe)
|
||||
}
|
||||
if errAccept != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errAccept)
|
||||
}
|
||||
return nil
|
||||
case errAccept := <-acceptErrCh:
|
||||
if s.muxHTTPListener != nil {
|
||||
_ = s.muxHTTPListener.Close()
|
||||
}
|
||||
if s.muxBaseListener != nil {
|
||||
if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.Debugf("failed to close shared listener after accept loop exit: %v", errClose)
|
||||
}
|
||||
}
|
||||
errServe := <-httpErrCh
|
||||
errServe = normalizeHTTPServeError(errServe)
|
||||
errAccept = normalizeListenerError(errAccept)
|
||||
if errAccept != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errAccept)
|
||||
}
|
||||
if errServe != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errServe)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debugf("Starting API server on %s", s.server.Addr)
|
||||
if errServe := s.server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errServe)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the API server without interrupting any
|
||||
@@ -832,6 +1295,15 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if s.muxHTTPListener != nil {
|
||||
_ = s.muxHTTPListener.Close()
|
||||
}
|
||||
if s.muxBaseListener != nil {
|
||||
if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.Debugf("failed to close shared listener: %v", errClose)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown the HTTP server.
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
|
||||
@@ -896,6 +1368,12 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.Home.Enabled != cfg.Home.Enabled {
|
||||
if setter, ok := s.requestLogger.(interface{ SetHomeEnabled(bool) }); ok {
|
||||
setter.SetHomeEnabled(cfg.Home.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.LoggingToFile != cfg.LoggingToFile || oldCfg.LogsMaxTotalSizeMB != cfg.LogsMaxTotalSizeMB {
|
||||
if err := logging.ConfigureLogOutput(cfg); err != nil {
|
||||
log.Errorf("failed to reconfigure log output: %v", err)
|
||||
@@ -903,7 +1381,11 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.RedisUsageQueueRetentionSeconds != cfg.RedisUsageQueueRetentionSeconds {
|
||||
redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
|
||||
}
|
||||
|
||||
if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) {
|
||||
@@ -916,8 +1398,14 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
}
|
||||
|
||||
if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration {
|
||||
log.Infof("disable-image-generation updated: %v -> %v", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration)
|
||||
}
|
||||
|
||||
applySignatureCacheConfig(oldCfg, cfg)
|
||||
|
||||
if s.handlers != nil && s.handlers.AuthManager != nil {
|
||||
s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second)
|
||||
s.handlers.AuthManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
||||
}
|
||||
|
||||
// Update log level dynamically when debug flag changes
|
||||
@@ -956,6 +1444,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
s.managementRoutesEnabled.Store(!newSecretEmpty)
|
||||
}
|
||||
}
|
||||
redisqueue.SetEnabled(s.managementRoutesEnabled.Load() || (cfg != nil && cfg.Home.Enabled))
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
s.cfg = cfg
|
||||
@@ -988,11 +1477,14 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
}
|
||||
|
||||
// Count client sources from configuration and auth store.
|
||||
tokenStore := sdkAuth.GetTokenStore()
|
||||
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
authEntries := 0
|
||||
if cfg != nil && !cfg.Home.Enabled {
|
||||
tokenStore := sdkAuth.GetTokenStore()
|
||||
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
authEntries = util.CountAuthFiles(context.Background(), tokenStore)
|
||||
}
|
||||
authEntries := util.CountAuthFiles(context.Background(), tokenStore)
|
||||
geminiAPIKeyCount := len(cfg.GeminiKey)
|
||||
claudeAPIKeyCount := len(cfg.ClaudeKey)
|
||||
codexAPIKeyCount := len(cfg.CodexKey)
|
||||
@@ -1000,6 +1492,9 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
openAICompatCount := 0
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
entry := cfg.OpenAICompatibility[i]
|
||||
if entry.Disabled {
|
||||
continue
|
||||
}
|
||||
openAICompatCount += len(entry.APIKeyEntries)
|
||||
}
|
||||
|
||||
@@ -1037,7 +1532,7 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
||||
result, err := manager.Authenticate(c.Request.Context(), c.Request)
|
||||
if err == nil {
|
||||
if result != nil {
|
||||
c.Set("apiKey", result.Principal)
|
||||
c.Set("userApiKey", result.Principal)
|
||||
c.Set("accessProvider", result.Provider)
|
||||
if len(result.Metadata) > 0 {
|
||||
c.Set("accessMetadata", result.Metadata)
|
||||
@@ -1054,3 +1549,37 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
|
||||
}
|
||||
}
|
||||
|
||||
func configuredSignatureCacheEnabled(cfg *config.Config) bool {
|
||||
if cfg != nil && cfg.AntigravitySignatureCacheEnabled != nil {
|
||||
return *cfg.AntigravitySignatureCacheEnabled
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func applySignatureCacheConfig(oldCfg, cfg *config.Config) {
|
||||
newVal := configuredSignatureCacheEnabled(cfg)
|
||||
newStrict := configuredSignatureBypassStrict(cfg)
|
||||
if oldCfg == nil {
|
||||
cache.SetSignatureCacheEnabled(newVal)
|
||||
cache.SetSignatureBypassStrictMode(newStrict)
|
||||
return
|
||||
}
|
||||
|
||||
oldVal := configuredSignatureCacheEnabled(oldCfg)
|
||||
if oldVal != newVal {
|
||||
cache.SetSignatureCacheEnabled(newVal)
|
||||
}
|
||||
|
||||
oldStrict := configuredSignatureBypassStrict(oldCfg)
|
||||
if oldStrict != newStrict {
|
||||
cache.SetSignatureBypassStrictMode(newStrict)
|
||||
}
|
||||
}
|
||||
|
||||
func configuredSignatureBypassStrict(cfg *config.Config) bool {
|
||||
if cfg != nil && cfg.AntigravitySignatureBypassStrict != nil {
|
||||
return *cfg.AntigravitySignatureBypassStrict
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+394
-5
@@ -1,21 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gin "github.com/gin-gonic/gin"
|
||||
proxyconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
proxyconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
return newTestServerWithOptions(t)
|
||||
}
|
||||
|
||||
func newTestServerWithOptions(t *testing.T, opts ...ServerOption) *Server {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -41,7 +50,152 @@ func newTestServer(t *testing.T) *Server {
|
||||
accessManager := sdkaccess.NewManager()
|
||||
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
return NewServer(cfg, authManager, accessManager, configPath)
|
||||
return NewServer(cfg, authManager, accessManager, configPath, opts...)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
|
||||
t.Run("GET", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HEAD", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodHead, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
if rr.Body.Len() != 0 {
|
||||
t.Fatalf("expected empty body for HEAD request, got %q", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManagementUsageRequiresManagementAuthAndPopsArray(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
|
||||
|
||||
prevQueueEnabled := redisqueue.Enabled()
|
||||
redisqueue.SetEnabled(false)
|
||||
t.Cleanup(func() {
|
||||
redisqueue.SetEnabled(false)
|
||||
redisqueue.SetEnabled(prevQueueEnabled)
|
||||
})
|
||||
|
||||
server := newTestServer(t)
|
||||
|
||||
redisqueue.Enqueue([]byte(`{"id":1}`))
|
||||
redisqueue.Enqueue([]byte(`{"id":2}`))
|
||||
|
||||
missingKeyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
|
||||
missingKeyRR := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(missingKeyRR, missingKeyReq)
|
||||
if missingKeyRR.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing key status = %d, want %d body=%s", missingKeyRR.Code, http.StatusUnauthorized, missingKeyRR.Body.String())
|
||||
}
|
||||
|
||||
legacyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil)
|
||||
legacyReq.Header.Set("Authorization", "Bearer test-management-key")
|
||||
legacyRR := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(legacyRR, legacyReq)
|
||||
if legacyRR.Code != http.StatusNotFound {
|
||||
t.Fatalf("legacy usage status = %d, want %d body=%s", legacyRR.Code, http.StatusNotFound, legacyRR.Body.String())
|
||||
}
|
||||
|
||||
authReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
|
||||
authReq.Header.Set("Authorization", "Bearer test-management-key")
|
||||
authRR := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(authRR, authReq)
|
||||
if authRR.Code != http.StatusOK {
|
||||
t.Fatalf("authenticated status = %d, want %d body=%s", authRR.Code, http.StatusOK, authRR.Body.String())
|
||||
}
|
||||
|
||||
var payload []json.RawMessage
|
||||
if errUnmarshal := json.Unmarshal(authRR.Body.Bytes(), &payload); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal response: %v body=%s", errUnmarshal, authRR.Body.String())
|
||||
}
|
||||
if len(payload) != 2 {
|
||||
t.Fatalf("response records = %d, want 2", len(payload))
|
||||
}
|
||||
for i, raw := range payload {
|
||||
var record struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(raw, &record); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal record %d: %v", i, errUnmarshal)
|
||||
}
|
||||
if record.ID != i+1 {
|
||||
t.Fatalf("record %d id = %d, want %d", i, record.ID, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if remaining := redisqueue.PopOldest(1); len(remaining) != 0 {
|
||||
t.Fatalf("remaining queue = %q, want empty", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementLocalPasswordRejectsSpoofedForwardedFor(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||
|
||||
server := newTestServerWithOptions(t, WithLocalManagementPassword("test-local-key"))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/config", nil)
|
||||
req.RemoteAddr = "203.0.113.10:45678"
|
||||
req.Header.Set("X-Forwarded-For", "127.0.0.1")
|
||||
req.Header.Set("Authorization", "Bearer test-local-key")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusForbidden, rr.Body.String())
|
||||
}
|
||||
if body := rr.Body.String(); !strings.Contains(body, "remote management disabled") {
|
||||
t.Fatalf("body = %q, want remote management disabled", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
|
||||
|
||||
server := newTestServer(t)
|
||||
server.cfg.Home.Enabled = true
|
||||
|
||||
t.Run("management endpoints return 404", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/config", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-management-key")
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("management control panel returns 404", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/management.html", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAmpProviderModelRoutes(t *testing.T) {
|
||||
@@ -109,3 +263,238 @@ func TestAmpProviderModelRoutes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
|
||||
modelRegistry := registry.GetGlobalRegistry()
|
||||
clientID := "test-client-version-catalog"
|
||||
modelRegistry.RegisterClient(clientID, "openai", []*registry.ModelInfo{
|
||||
{
|
||||
ID: "gpt-5.5",
|
||||
Object: "model",
|
||||
Created: 1776902400,
|
||||
OwnedBy: "openai",
|
||||
Type: "openai",
|
||||
DisplayName: "GPT 5.5",
|
||||
Description: "Frontier model for complex coding, research, and real-world work.",
|
||||
ContextLength: 272000,
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}},
|
||||
},
|
||||
{
|
||||
ID: "custom-codex-model-test",
|
||||
Object: "model",
|
||||
OwnedBy: "test",
|
||||
Type: "openai",
|
||||
DisplayName: "Custom Codex Model",
|
||||
Description: "Custom model from registry",
|
||||
ContextLength: 123456,
|
||||
Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium"}},
|
||||
},
|
||||
{ID: "grok-imagine-image-quality", Object: "model", OwnedBy: "xai", Type: "openai"},
|
||||
{ID: "gpt-image-2", Object: "model", OwnedBy: "openai", Type: "openai"},
|
||||
{ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", Type: "openai"},
|
||||
{ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", Type: "openai"},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
modelRegistry.UnregisterClient(clientID)
|
||||
})
|
||||
|
||||
server := newTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
req.Header.Set("User-Agent", "claude-cli/1.0")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
Object string `json:"object"`
|
||||
Data []any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
|
||||
}
|
||||
if resp.Object != "" || resp.Data != nil {
|
||||
t.Fatalf("expected codex catalog format without object/data, got object=%q data=%v", resp.Object, resp.Data)
|
||||
}
|
||||
if len(resp.Models) == 0 {
|
||||
t.Fatal("expected codex catalog models")
|
||||
}
|
||||
|
||||
var gpt55 map[string]any
|
||||
var custom map[string]any
|
||||
for _, model := range resp.Models {
|
||||
switch slug, _ := model["slug"].(string); slug {
|
||||
case "gpt-5.5":
|
||||
gpt55 = model
|
||||
case "custom-codex-model-test":
|
||||
custom = model
|
||||
}
|
||||
}
|
||||
if gpt55 == nil {
|
||||
t.Fatal("expected gpt-5.5 codex catalog entry")
|
||||
}
|
||||
if _, ok := gpt55["minimal_client_version"]; !ok {
|
||||
t.Fatal("expected minimal_client_version in codex catalog")
|
||||
}
|
||||
serviceTiers, ok := gpt55["service_tiers"].([]any)
|
||||
if !ok || len(serviceTiers) != 1 {
|
||||
t.Fatalf("expected gpt-5.5 priority service tier, got %#v", gpt55["service_tiers"])
|
||||
}
|
||||
if custom == nil {
|
||||
t.Fatal("expected custom model codex catalog entry")
|
||||
}
|
||||
if got, _ := custom["display_name"].(string); got != "Custom Codex Model" {
|
||||
t.Fatalf("custom display_name = %q, want Custom Codex Model", got)
|
||||
}
|
||||
if got, _ := custom["description"].(string); got != "Custom model from registry" {
|
||||
t.Fatalf("custom description = %q, want Custom model from registry", got)
|
||||
}
|
||||
if got, _ := custom["context_window"].(float64); got != 123456 {
|
||||
t.Fatalf("custom context_window = %v, want 123456", custom["context_window"])
|
||||
}
|
||||
if custom["base_instructions"] != gpt55["base_instructions"] {
|
||||
t.Fatal("expected custom model to use gpt-5.5 base_instructions fallback")
|
||||
}
|
||||
if _, ok := custom["available_in_plans"].([]any); !ok {
|
||||
t.Fatalf("expected custom model to use gpt-5.5 available_in_plans fallback, got %#v", custom["available_in_plans"])
|
||||
}
|
||||
if got, _ := custom["prefer_websockets"].(bool); got {
|
||||
t.Fatalf("custom prefer_websockets = %v, want false", custom["prefer_websockets"])
|
||||
}
|
||||
if _, ok := custom["apply_patch_tool_type"]; ok {
|
||||
t.Fatal("expected custom model to omit apply_patch_tool_type")
|
||||
}
|
||||
if _, ok := custom["upgrade"]; ok {
|
||||
t.Fatal("expected custom model to omit upgrade")
|
||||
}
|
||||
if _, ok := custom["availability_nux"]; ok {
|
||||
t.Fatal("expected custom model to omit availability_nux")
|
||||
}
|
||||
|
||||
hiddenModels := map[string]bool{
|
||||
"grok-imagine-image-quality": false,
|
||||
"gpt-image-2": false,
|
||||
"grok-imagine-image": false,
|
||||
"grok-imagine-video": false,
|
||||
}
|
||||
for _, model := range resp.Models {
|
||||
slug, _ := model["slug"].(string)
|
||||
if _, ok := hiddenModels[slug]; !ok {
|
||||
continue
|
||||
}
|
||||
if visibility, _ := model["visibility"].(string); visibility != "hide" {
|
||||
t.Fatalf("%s visibility = %q, want hide", slug, visibility)
|
||||
}
|
||||
hiddenModels[slug] = true
|
||||
}
|
||||
for slug, found := range hiddenModels {
|
||||
if !found {
|
||||
t.Fatalf("expected hidden model %s in codex catalog", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRequestLoggerFactory_UsesResolvedLogDirectory(t *testing.T) {
|
||||
t.Setenv("WRITABLE_PATH", "")
|
||||
t.Setenv("writable_path", "")
|
||||
|
||||
originalWD, errGetwd := os.Getwd()
|
||||
if errGetwd != nil {
|
||||
t.Fatalf("failed to get current working directory: %v", errGetwd)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if errChdir := os.Chdir(tmpDir); errChdir != nil {
|
||||
t.Fatalf("failed to switch working directory: %v", errChdir)
|
||||
}
|
||||
defer func() {
|
||||
if errChdirBack := os.Chdir(originalWD); errChdirBack != nil {
|
||||
t.Fatalf("failed to restore working directory: %v", errChdirBack)
|
||||
}
|
||||
}()
|
||||
|
||||
// Force ResolveLogDirectory to fallback to auth-dir/logs by making ./logs not a writable directory.
|
||||
if errWriteFile := os.WriteFile(filepath.Join(tmpDir, "logs"), []byte("not-a-directory"), 0o644); errWriteFile != nil {
|
||||
t.Fatalf("failed to create blocking logs file: %v", errWriteFile)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(tmpDir, "config")
|
||||
if errMkdirConfig := os.MkdirAll(configDir, 0o755); errMkdirConfig != nil {
|
||||
t.Fatalf("failed to create config dir: %v", errMkdirConfig)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "config.yaml")
|
||||
|
||||
authDir := filepath.Join(tmpDir, "auth")
|
||||
if errMkdirAuth := os.MkdirAll(authDir, 0o700); errMkdirAuth != nil {
|
||||
t.Fatalf("failed to create auth dir: %v", errMkdirAuth)
|
||||
}
|
||||
|
||||
cfg := &proxyconfig.Config{
|
||||
SDKConfig: proxyconfig.SDKConfig{
|
||||
RequestLog: false,
|
||||
},
|
||||
AuthDir: authDir,
|
||||
ErrorLogsMaxFiles: 10,
|
||||
}
|
||||
|
||||
logger := defaultRequestLoggerFactory(cfg, configPath)
|
||||
fileLogger, ok := logger.(*internallogging.FileRequestLogger)
|
||||
if !ok {
|
||||
t.Fatalf("expected *FileRequestLogger, got %T", logger)
|
||||
}
|
||||
|
||||
errLog := fileLogger.LogRequestWithOptions(
|
||||
"/v1/chat/completions",
|
||||
http.MethodPost,
|
||||
map[string][]string{"Content-Type": []string{"application/json"}},
|
||||
[]byte(`{"input":"hello"}`),
|
||||
http.StatusBadGateway,
|
||||
map[string][]string{"Content-Type": []string{"application/json"}},
|
||||
[]byte(`{"error":"upstream failure"}`),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
"issue-1711",
|
||||
time.Now(),
|
||||
time.Now(),
|
||||
)
|
||||
if errLog != nil {
|
||||
t.Fatalf("failed to write forced error request log: %v", errLog)
|
||||
}
|
||||
|
||||
authLogsDir := filepath.Join(authDir, "logs")
|
||||
authEntries, errReadAuthDir := os.ReadDir(authLogsDir)
|
||||
if errReadAuthDir != nil {
|
||||
t.Fatalf("failed to read auth logs dir %s: %v", authLogsDir, errReadAuthDir)
|
||||
}
|
||||
foundErrorLogInAuthDir := false
|
||||
for _, entry := range authEntries {
|
||||
if strings.HasPrefix(entry.Name(), "error-") && strings.HasSuffix(entry.Name(), ".log") {
|
||||
foundErrorLogInAuthDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundErrorLogInAuthDir {
|
||||
t.Fatalf("expected forced error log in auth fallback dir %s, got entries: %+v", authLogsDir, authEntries)
|
||||
}
|
||||
|
||||
configLogsDir := filepath.Join(configDir, "logs")
|
||||
configEntries, errReadConfigDir := os.ReadDir(configLogsDir)
|
||||
if errReadConfigDir != nil && !os.IsNotExist(errReadConfigDir) {
|
||||
t.Fatalf("failed to inspect config logs dir %s: %v", configLogsDir, errReadConfigDir)
|
||||
}
|
||||
for _, entry := range configEntries {
|
||||
if strings.HasPrefix(entry.Name(), "error-") && strings.HasSuffix(entry.Name(), ".log") {
|
||||
t.Fatalf("unexpected forced error log in config dir %s", configLogsDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -36,17 +37,21 @@ type AntigravityAuth struct {
|
||||
|
||||
// NewAntigravityAuth creates a new Antigravity auth service.
|
||||
func NewAntigravityAuth(cfg *config.Config, httpClient *http.Client) *AntigravityAuth {
|
||||
if httpClient != nil {
|
||||
return &AntigravityAuth{httpClient: httpClient}
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
if httpClient != nil {
|
||||
return &AntigravityAuth{httpClient: httpClient}
|
||||
}
|
||||
return &AntigravityAuth{
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *AntigravityAuth) loadCodeAssistUserAgent() string {
|
||||
return misc.AntigravityLoadCodeAssistUserAgent("")
|
||||
}
|
||||
|
||||
// BuildAuthURL generates the OAuth authorization URL.
|
||||
func (o *AntigravityAuth) BuildAuthURL(state, redirectURI string) string {
|
||||
if strings.TrimSpace(redirectURI) == "" {
|
||||
@@ -118,6 +123,7 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
|
||||
return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", o.loadCodeAssistUserAgent())
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
@@ -153,11 +159,12 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
|
||||
|
||||
// FetchProjectID retrieves the project ID for the authenticated user via loadCodeAssist
|
||||
func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string) (string, error) {
|
||||
userAgent := o.loadCodeAssistUserAgent()
|
||||
loadReqBody := map[string]any{
|
||||
"metadata": map[string]string{
|
||||
"ideType": "ANTIGRAVITY",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
"ide_type": "ANTIGRAVITY",
|
||||
"ide_version": misc.AntigravityVersionFromUserAgent(userAgent),
|
||||
"ide_name": "antigravity",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -173,9 +180,8 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", APIUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", APIClient)
|
||||
req.Header.Set("Client-Metadata", ClientMetadata)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA)
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
@@ -244,12 +250,13 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string
|
||||
// OnboardUser attempts to fetch the project ID via onboardUser by polling for completion
|
||||
func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID string) (string, error) {
|
||||
log.Infof("Antigravity: onboarding user with tier: %s", tierID)
|
||||
userAgent := o.loadCodeAssistUserAgent()
|
||||
requestBody := map[string]any{
|
||||
"tierId": tierID,
|
||||
"metadata": map[string]string{
|
||||
"ideType": "ANTIGRAVITY",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
"ide_type": "ANTIGRAVITY",
|
||||
"ide_version": misc.AntigravityVersionFromUserAgent(userAgent),
|
||||
"ide_name": "antigravity",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -277,9 +284,8 @@ func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID s
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", APIUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", APIClient)
|
||||
req.Header.Set("Client-Metadata", ClientMetadata)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA)
|
||||
|
||||
resp, errDo := o.httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
|
||||
@@ -21,14 +21,11 @@ var Scopes = []string{
|
||||
const (
|
||||
TokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
UserInfoEndpoint = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
|
||||
UserInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo?alt=json"
|
||||
)
|
||||
|
||||
// Antigravity API configuration
|
||||
const (
|
||||
APIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||
APIVersion = "v1internal"
|
||||
APIUserAgent = "google-api-nodejs-client/9.15.1"
|
||||
APIClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||
ClientMetadata = `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`
|
||||
APIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||
APIVersion = "v1internal"
|
||||
)
|
||||
|
||||
@@ -6,15 +6,18 @@ package claude
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// OAuth configuration constants for Claude/Anthropic
|
||||
@@ -23,8 +26,94 @@ const (
|
||||
TokenURL = "https://api.anthropic.com/v1/oauth/token"
|
||||
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
RedirectURI = "http://localhost:54545/callback"
|
||||
|
||||
claudeRefreshMinBackoff = 5 * time.Second
|
||||
claudeRefreshMaxBackoff = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
claudeRefreshGroup singleflight.Group
|
||||
claudeRefreshMu sync.Mutex
|
||||
claudeRefreshBlock = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
type refreshHTTPError struct {
|
||||
status int
|
||||
message string
|
||||
retryable bool
|
||||
}
|
||||
|
||||
func (e *refreshHTTPError) Error() string {
|
||||
return fmt.Sprintf("token refresh failed with status %d: %s", e.status, e.message)
|
||||
}
|
||||
|
||||
func (e *refreshHTTPError) Retryable() bool {
|
||||
return e != nil && e.retryable
|
||||
}
|
||||
|
||||
func resetClaudeRefreshState() {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
claudeRefreshBlock = make(map[string]time.Time)
|
||||
claudeRefreshGroup = singleflight.Group{}
|
||||
}
|
||||
|
||||
func claudeRefreshBlockedUntil(refreshToken string) time.Time {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
return claudeRefreshBlock[refreshToken]
|
||||
}
|
||||
|
||||
func setClaudeRefreshBlockedUntil(refreshToken string, until time.Time) {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
claudeRefreshBlock[refreshToken] = until
|
||||
}
|
||||
|
||||
func clearClaudeRefreshBlockedUntil(refreshToken string) {
|
||||
claudeRefreshMu.Lock()
|
||||
defer claudeRefreshMu.Unlock()
|
||||
delete(claudeRefreshBlock, refreshToken)
|
||||
}
|
||||
|
||||
func clampClaudeRefreshBackoff(d time.Duration) time.Duration {
|
||||
if d < claudeRefreshMinBackoff {
|
||||
return claudeRefreshMinBackoff
|
||||
}
|
||||
if d > claudeRefreshMaxBackoff {
|
||||
return claudeRefreshMaxBackoff
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func parseClaudeRetryAfter(resp *http.Response) time.Duration {
|
||||
if resp == nil {
|
||||
return claudeRefreshMinBackoff
|
||||
}
|
||||
if raw := strings.TrimSpace(resp.Header.Get("Retry-After")); raw != "" {
|
||||
if seconds, err := time.ParseDuration(raw + "s"); err == nil {
|
||||
return clampClaudeRefreshBackoff(seconds)
|
||||
}
|
||||
if when, err := http.ParseTime(raw); err == nil {
|
||||
return clampClaudeRefreshBackoff(time.Until(when))
|
||||
}
|
||||
}
|
||||
if raw := strings.TrimSpace(resp.Header.Get("Retry-After-Ms")); raw != "" {
|
||||
if ms, err := time.ParseDuration(raw + "ms"); err == nil {
|
||||
return clampClaudeRefreshBackoff(ms)
|
||||
}
|
||||
}
|
||||
return claudeRefreshMinBackoff
|
||||
}
|
||||
|
||||
func isClaudeRefreshRetryable(err error) bool {
|
||||
var httpErr *refreshHTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr.Retryable()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
|
||||
// It contains access token, refresh token, and associated user/organization information.
|
||||
type tokenResponse struct {
|
||||
@@ -59,10 +148,30 @@ type ClaudeAuth struct {
|
||||
// Returns:
|
||||
// - *ClaudeAuth: A new Claude authentication service instance
|
||||
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
|
||||
return NewClaudeAuthWithProxyURL(cfg, "")
|
||||
}
|
||||
|
||||
// NewClaudeAuthWithProxyURL creates a new Anthropic authentication service with a proxy override.
|
||||
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
|
||||
func NewClaudeAuthWithProxyURL(cfg *config.Config, proxyURL string) *ClaudeAuth {
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg *config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfgCopy := cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
sdkCfgCopy.ProxyURL = effectiveProxyURL
|
||||
sdkCfg = &sdkCfgCopy
|
||||
} else if effectiveProxyURL != "" {
|
||||
sdkCfgCopy := config.SDKConfig{ProxyURL: effectiveProxyURL}
|
||||
sdkCfg = &sdkCfgCopy
|
||||
}
|
||||
|
||||
// Use custom HTTP client with Firefox TLS fingerprint to bypass
|
||||
// Cloudflare's bot detection on Anthropic domains
|
||||
return &ClaudeAuth{
|
||||
httpClient: NewAnthropicHttpClient(&cfg.SDKConfig),
|
||||
httpClient: NewAnthropicHttpClient(sdkCfg),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +197,7 @@ func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string
|
||||
"client_id": {ClientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {RedirectURI},
|
||||
"scope": {"org:create_api_key user:profile user:inference"},
|
||||
"scope": {"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"},
|
||||
"code_challenge": {pkceCodes.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
@@ -222,6 +331,35 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
|
||||
return nil, &refreshHTTPError{
|
||||
status: http.StatusTooManyRequests,
|
||||
message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) {
|
||||
return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenData, ok := result.(*ClaudeTokenData)
|
||||
if !ok || tokenData == nil {
|
||||
return nil, fmt.Errorf("token refresh failed: invalid single-flight result")
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
|
||||
if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
|
||||
return nil, &refreshHTTPError{
|
||||
status: http.StatusTooManyRequests,
|
||||
message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"client_id": ClientID,
|
||||
@@ -256,7 +394,17 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||
message := string(body)
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := parseClaudeRetryAfter(resp)
|
||||
setClaudeRefreshBlockedUntil(refreshToken, time.Now().Add(retryAfter))
|
||||
return nil, &refreshHTTPError{status: resp.StatusCode, message: message, retryable: false}
|
||||
}
|
||||
return nil, &refreshHTTPError{
|
||||
status: resp.StatusCode,
|
||||
message: message,
|
||||
retryable: resp.StatusCode >= http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
@@ -267,6 +415,8 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
|
||||
}
|
||||
|
||||
// Create token data
|
||||
clearClaudeRefreshBlockedUntil(refreshToken)
|
||||
|
||||
return &ClaudeTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
@@ -328,6 +478,9 @@ func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken st
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
if !isClaudeRefreshRetryable(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func TestNewClaudeAuthWithProxyURL_OverrideDirectTakesPrecedence(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "socks5://proxy.example.com:1080"}}
|
||||
auth := NewClaudeAuthWithProxyURL(cfg, "direct")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*utlsRoundTripper)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
if transport.dialer != proxy.Direct {
|
||||
t.Fatalf("expected proxy.Direct, got %T", transport.dialer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClaudeAuthWithProxyURL_OverrideProxyAppliedWithoutConfig(t *testing.T) {
|
||||
auth := NewClaudeAuthWithProxyURL(nil, "socks5://proxy.example.com:1080")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*utlsRoundTripper)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
if transport.dialer == proxy.Direct {
|
||||
t.Fatalf("expected proxy dialer, got %T", transport.dialer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) {
|
||||
resetClaudeRefreshState()
|
||||
defer resetClaudeRefreshState()
|
||||
|
||||
var calls int32
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"rate_limited"}`)),
|
||||
Header: http.Header{"Retry-After": []string{"60"}},
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
|
||||
if err == nil {
|
||||
t.Fatalf("expected 429 refresh error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 429") {
|
||||
t.Fatalf("expected status 429 in error, got %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected 1 refresh attempt after 429, got %d", got)
|
||||
}
|
||||
|
||||
_, err = auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
|
||||
if err == nil {
|
||||
t.Fatalf("expected immediate blocked refresh error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected blocked retry to avoid a second refresh call, got %d attempts", got)
|
||||
}
|
||||
if blockedUntil := claudeRefreshBlockedUntil("dummy_refresh_token"); !blockedUntil.After(time.Now()) {
|
||||
t.Fatalf("expected blocked-until timestamp to be set, got %v", blockedUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
|
||||
resetClaudeRefreshState()
|
||||
defer resetClaudeRefreshState()
|
||||
|
||||
var calls int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
auth := &ClaudeAuth{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"access_token":"new-access",
|
||||
"refresh_token":"new-refresh",
|
||||
"token_type":"Bearer",
|
||||
"expires_in":3600,
|
||||
"account":{"email_address":"shared@example.com"}
|
||||
}`)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
results := make(chan *ClaudeTokenData, 2)
|
||||
errs := make(chan error, 2)
|
||||
runRefresh := func() {
|
||||
td, err := auth.RefreshTokens(context.Background(), "shared-refresh-token")
|
||||
results <- td
|
||||
errs <- err
|
||||
}
|
||||
|
||||
go runRefresh()
|
||||
go runRefresh()
|
||||
|
||||
<-started
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
|
||||
}
|
||||
close(release)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("expected refresh to succeed, got %v", err)
|
||||
}
|
||||
td := <-results
|
||||
if td == nil || td.AccessToken != "new-access" {
|
||||
t.Fatalf("expected refreshed access token, got %#v", td)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected exactly 1 upstream refresh call, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
)
|
||||
|
||||
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
|
||||
|
||||
@@ -4,18 +4,18 @@ package claude
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tls "github.com/refraction-networking/utls"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// utlsRoundTripper implements http.RoundTripper using utls with Firefox fingerprint
|
||||
// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint
|
||||
// to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
|
||||
type utlsRoundTripper struct {
|
||||
// mu protects the connections map and pending map
|
||||
@@ -31,17 +31,12 @@ type utlsRoundTripper struct {
|
||||
// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support
|
||||
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
|
||||
var dialer proxy.Dialer = proxy.Direct
|
||||
if cfg != nil && cfg.ProxyURL != "" {
|
||||
proxyURL, err := url.Parse(cfg.ProxyURL)
|
||||
if err != nil {
|
||||
log.Errorf("failed to parse proxy URL %q: %v", cfg.ProxyURL, err)
|
||||
} else {
|
||||
pDialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create proxy dialer for %q: %v", cfg.ProxyURL, err)
|
||||
} else {
|
||||
dialer = pDialer
|
||||
}
|
||||
if cfg != nil {
|
||||
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
|
||||
if errBuild != nil {
|
||||
log.Errorf("failed to configure proxy dialer for %q: %v", cfg.ProxyURL, errBuild)
|
||||
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
||||
dialer = proxyDialer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +95,9 @@ func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.Clie
|
||||
return h2Conn, nil
|
||||
}
|
||||
|
||||
// createConnection creates a new HTTP/2 connection with Firefox TLS fingerprint
|
||||
// createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint.
|
||||
// Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses)
|
||||
// than Firefox, reducing the mismatch between TLS layer and HTTP headers.
|
||||
func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) {
|
||||
conn, err := t.dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -108,7 +105,7 @@ func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientCon
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{ServerName: host}
|
||||
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloFirefox_Auto)
|
||||
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
@@ -156,7 +153,7 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// NewAnthropicHttpClient creates an HTTP client that bypasses TLS fingerprinting
|
||||
// for Anthropic domains by using utls with Firefox fingerprint.
|
||||
// for Anthropic domains by using utls with Chrome fingerprint.
|
||||
// It accepts optional SDK configuration for proxy settings.
|
||||
func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client {
|
||||
return &http.Client{
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -37,8 +37,23 @@ type CodexAuth struct {
|
||||
// NewCodexAuth creates a new CodexAuth service instance.
|
||||
// It initializes an HTTP client with proxy settings from the provided configuration.
|
||||
func NewCodexAuth(cfg *config.Config) *CodexAuth {
|
||||
return NewCodexAuthWithProxyURL(cfg, "")
|
||||
}
|
||||
|
||||
// NewCodexAuthWithProxyURL creates a new CodexAuth service instance.
|
||||
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
|
||||
func NewCodexAuthWithProxyURL(cfg *config.Config, proxyURL string) *CodexAuth {
|
||||
effectiveProxyURL := strings.TrimSpace(proxyURL)
|
||||
var sdkCfg config.SDKConfig
|
||||
if cfg != nil {
|
||||
sdkCfg = cfg.SDKConfig
|
||||
if effectiveProxyURL == "" {
|
||||
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||
}
|
||||
}
|
||||
sdkCfg.ProxyURL = effectiveProxyURL
|
||||
return &CodexAuth{
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
httpClient: util.SetProxy(&sdkCfg, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -42,3 +44,37 @@ func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) {
|
||||
t.Fatalf("expected 1 refresh attempt, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodexAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}}
|
||||
auth := NewCodexAuthWithProxyURL(cfg, "direct")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("expected direct transport to disable proxy function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodexAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) {
|
||||
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}}
|
||||
auth := NewCodexAuthWithProxyURL(cfg, "http://override.example.com:8081")
|
||||
|
||||
transport, ok := auth.httpClient.Transport.(*http.Transport)
|
||||
if !ok || transport == nil {
|
||||
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
|
||||
}
|
||||
req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if errReq != nil {
|
||||
t.Fatalf("new request: %v", errReq)
|
||||
}
|
||||
proxyURL, errProxy := transport.Proxy(req)
|
||||
if errProxy != nil {
|
||||
t.Fatalf("proxy func: %v", errProxy)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" {
|
||||
t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
)
|
||||
|
||||
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
||||
|
||||
@@ -10,19 +10,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
@@ -80,36 +78,16 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
|
||||
}
|
||||
callbackURL := fmt.Sprintf("http://localhost:%d/oauth2callback", callbackPort)
|
||||
|
||||
// Configure proxy settings for the HTTP client if a proxy URL is provided.
|
||||
proxyURL, err := url.Parse(cfg.ProxyURL)
|
||||
if err == nil {
|
||||
var transport *http.Transport
|
||||
if proxyURL.Scheme == "socks5" {
|
||||
// Handle SOCKS5 proxy.
|
||||
username := proxyURL.User.Username()
|
||||
password, _ := proxyURL.User.Password()
|
||||
auth := &proxy.Auth{User: username, Password: password}
|
||||
dialer, errSOCKS5 := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct)
|
||||
if errSOCKS5 != nil {
|
||||
log.Errorf("create SOCKS5 dialer failed: %v", errSOCKS5)
|
||||
return nil, fmt.Errorf("create SOCKS5 dialer failed: %w", errSOCKS5)
|
||||
}
|
||||
transport = &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
}
|
||||
} else if proxyURL.Scheme == "http" || proxyURL.Scheme == "https" {
|
||||
// Handle HTTP/HTTPS proxy.
|
||||
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
}
|
||||
|
||||
if transport != nil {
|
||||
proxyClient := &http.Client{Transport: transport}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
||||
}
|
||||
transport, _, errBuild := proxyutil.BuildHTTPTransport(cfg.ProxyURL)
|
||||
if errBuild != nil {
|
||||
log.Errorf("%v", errBuild)
|
||||
} else if transport != nil {
|
||||
proxyClient := &http.Client{Transport: transport}
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, proxyClient)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Configure the OAuth2 client.
|
||||
conf := &oauth2.Config{
|
||||
ClientID: ClientID,
|
||||
@@ -327,6 +305,9 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
defer manualPromptTimer.Stop()
|
||||
}
|
||||
|
||||
var manualInputCh <-chan string
|
||||
var manualInputErrCh <-chan error
|
||||
|
||||
waitForCallback:
|
||||
for {
|
||||
select {
|
||||
@@ -348,13 +329,14 @@ waitForCallback:
|
||||
return nil, err
|
||||
default:
|
||||
}
|
||||
input, err := opts.Prompt("Paste the Gemini callback URL (or press Enter to keep waiting): ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := misc.ParseOAuthCallback(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
manualInputCh, manualInputErrCh = misc.AsyncPrompt(opts.Prompt, "Paste the Gemini callback URL (or press Enter to keep waiting): ")
|
||||
continue
|
||||
case input := <-manualInputCh:
|
||||
manualInputCh = nil
|
||||
manualInputErrCh = nil
|
||||
parsed, errParse := misc.ParseOAuthCallback(input)
|
||||
if errParse != nil {
|
||||
return nil, errParse
|
||||
}
|
||||
if parsed == nil {
|
||||
continue
|
||||
@@ -367,6 +349,8 @@ waitForCallback:
|
||||
}
|
||||
authCode = parsed.Code
|
||||
break waitForCallback
|
||||
case errManual := <-manualInputErrCh:
|
||||
return nil, errManual
|
||||
case <-timeoutTimer.C:
|
||||
return nil, fmt.Errorf("oauth flow timed out")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user