mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 12:30:50 +08:00
Compare commits
591 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd98f4f9b | ||
|
|
75b57bc112 | ||
|
|
a7d2f669e7 | ||
|
|
ce569ab36e | ||
|
|
d0aa741d59 | ||
|
|
592f6fc66b | ||
|
|
09ecba6dab | ||
|
|
d6bd6f3fb9 | ||
|
|
92f4278039 | ||
|
|
8ae8a5c296 | ||
|
|
dc804e96fb | ||
|
|
ab76cb3662 | ||
|
|
2965bdadc1 | ||
|
|
40f7061b04 | ||
|
|
8c947cafbe | ||
|
|
717eadf128 | ||
|
|
9e105738fd | ||
|
|
5d806fcefc | ||
|
|
6ae1dd78ed | ||
|
|
43095de162 | ||
|
|
ef7e8206d3 | ||
|
|
87291c0d75 | ||
|
|
51d2766d5c | ||
|
|
a00ba77604 | ||
|
|
3264605c2d | ||
|
|
cfb9cb8951 | ||
|
|
bb00436509 | ||
|
|
1afbc4dd96 | ||
|
|
d745f07044 | ||
|
|
695eaa5450 | ||
|
|
67ad26c35a | ||
|
|
30d448e73c | ||
|
|
d4064e3df4 | ||
|
|
ec354f7a1a | ||
|
|
240e782606 | ||
|
|
fcb0293c0d | ||
|
|
682c4598ee | ||
|
|
a7d105bd69 | ||
|
|
b9eef45305 | ||
|
|
c8f20a66a8 | ||
|
|
1f6a384c9a | ||
|
|
c9fc033cf5 | ||
|
|
32c964d310 | ||
|
|
d60040b222 | ||
|
|
3ce1b4159b | ||
|
|
7516ac4ce7 | ||
|
|
2a73d8c4a3 | ||
|
|
a318dff8b0 | ||
|
|
4a159d5bf5 | ||
|
|
734b040a48 | ||
|
|
10be026ace | ||
|
|
848a620568 | ||
|
|
e18e288fda | ||
|
|
38cfbac8f0 | ||
|
|
5be4d22b9b | ||
|
|
64774a5786 | ||
|
|
16b0a561d7 | ||
|
|
21dde0e352 | ||
|
|
b040a43b81 | ||
|
|
bccefb2905 | ||
|
|
b26ec8162d | ||
|
|
ee0a91f539 | ||
|
|
89b0d53a09 | ||
|
|
fd2b23592e | ||
|
|
4d0804687c | ||
|
|
2021ae3891 | ||
|
|
4883349795 | ||
|
|
5c65938113 | ||
|
|
16be3f0a12 | ||
|
|
7c1c4ee60b | ||
|
|
96c7271448 | ||
|
|
07da781336 | ||
|
|
a53c84d0d1 | ||
|
|
a517290726 | ||
|
|
af3fbd134d | ||
|
|
2f477df97e | ||
|
|
3e7b645346 | ||
|
|
24446a4dc4 | ||
|
|
475f473dab | ||
|
|
8dba32a077 | ||
|
|
1bbbd16df6 | ||
|
|
5cb378256b | ||
|
|
3ac5f05e8c | ||
|
|
58d30369b4 | ||
|
|
7dd93a4a25 | ||
|
|
2a3ee8d0e3 | ||
|
|
41577bce07 | ||
|
|
3d7aca22c0 | ||
|
|
680b3f5010 | ||
|
|
9d42e4b239 | ||
|
|
97af785aad | ||
|
|
0defb68c6c | ||
|
|
d6272d3300 | ||
|
|
c99d0dfb33 | ||
|
|
663b9b35ab | ||
|
|
5dced4c0a6 | ||
|
|
5891785125 | ||
|
|
ac3d47e8c0 | ||
|
|
e5ed2cba4a | ||
|
|
847c2502a5 | ||
|
|
c7196ba7dc | ||
|
|
6f9c23af5e | ||
|
|
2d5d06c809 | ||
|
|
3e20b00357 | ||
|
|
e370f86f63 | ||
|
|
7f266aa19e | ||
|
|
f3f31274e8 | ||
|
|
7061cd6058 | ||
|
|
5da5674ae2 | ||
|
|
7459c2c81a | ||
|
|
cd4706f60e | ||
|
|
359b8de44e | ||
|
|
ea6065f1b1 | ||
|
|
8aaed4cf09 | ||
|
|
c32e013605 | ||
|
|
3839d93ba0 | ||
|
|
a552a45b81 | ||
|
|
f6cf784cd1 | ||
|
|
e783923464 | ||
|
|
e6d7677373 | ||
|
|
d225558dae | ||
|
|
9678be7aa4 | ||
|
|
243bf5c108 | ||
|
|
3569e5779a | ||
|
|
20985d1a10 | ||
|
|
67f553806b | ||
|
|
29044312a4 | ||
|
|
5b3fc092ee | ||
|
|
792e8d09d7 | ||
|
|
eadccb229f | ||
|
|
fed6f3ecd7 | ||
|
|
f8dcd707a6 | ||
|
|
0e91e95287 | ||
|
|
c5dcbc1c1a | ||
|
|
4504ba5329 | ||
|
|
d16599fa1d | ||
|
|
674393ec12 | ||
|
|
9f45806106 | ||
|
|
307ae76ed4 | ||
|
|
735b21394c | ||
|
|
9cdef937af | ||
|
|
3dd0844b98 | ||
|
|
4477c729a4 | ||
|
|
0d89a22aa0 | ||
|
|
9319602812 | ||
|
|
8e95c5e0a8 | ||
|
|
93f0e65cef | ||
|
|
c75e524fe5 | ||
|
|
f58d0faf8c | ||
|
|
df3b00621a | ||
|
|
72cb2689e8 | ||
|
|
ade279d1f2 | ||
|
|
9c5ac2927a | ||
|
|
7980f055fa | ||
|
|
eb2549a782 | ||
|
|
c419264a70 | ||
|
|
6b23e2da74 | ||
|
|
5ab0854b5b | ||
|
|
15981aa412 | ||
|
|
ac4f52c532 | ||
|
|
84fa497169 | ||
|
|
b641d90287 | ||
|
|
32d01a6a7c | ||
|
|
9ef76dcc61 | ||
|
|
4576f9915b | ||
|
|
c945e35983 | ||
|
|
1cd275f4c1 | ||
|
|
4bc1ed6031 | ||
|
|
78989d6c0d | ||
|
|
d6aa1e5ba0 | ||
|
|
50c1c50dbd | ||
|
|
5123cfd47e | ||
|
|
9072accc43 | ||
|
|
0d8134aabe | ||
|
|
4fdbdf7925 | ||
|
|
50c84485c3 | ||
|
|
f335aeeedb | ||
|
|
32a8102d71 | ||
|
|
61f6a612e3 | ||
|
|
42087d5387 | ||
|
|
f2710c03ab | ||
|
|
39abde2413 | ||
|
|
0aa8706ef7 | ||
|
|
5fd4a8b974 | ||
|
|
06e6f0a5f2 | ||
|
|
80f6d6fe7c | ||
|
|
3be6175aec | ||
|
|
599986495b | ||
|
|
cb83985cc7 | ||
|
|
6ec028808f | ||
|
|
71faa19bb4 | ||
|
|
b5ad978d44 | ||
|
|
0508c9fbce | ||
|
|
92bb642e98 | ||
|
|
af82855bed | ||
|
|
a83978f769 | ||
|
|
2513d908be | ||
|
|
4c033b3af7 | ||
|
|
843a81f68d | ||
|
|
f6e713ab6b | ||
|
|
1834c65116 | ||
|
|
fc6aa8ef77 | ||
|
|
c3f88126e6 | ||
|
|
b895018ff5 | ||
|
|
9c6832cc22 | ||
|
|
1ada33ab1d | ||
|
|
78738ca3f0 | ||
|
|
ac01c74c02 | ||
|
|
02e28bbbe9 | ||
|
|
b9c7b9eea5 | ||
|
|
57195fa0f5 | ||
|
|
11f090c223 | ||
|
|
829dd06b42 | ||
|
|
20787cd107 | ||
|
|
1aa568ce45 | ||
|
|
b2cdbbdd47 | ||
|
|
8056af42a3 | ||
|
|
01be94a0de | ||
|
|
d1933075c3 | ||
|
|
a602ae859b | ||
|
|
c5d7137d66 | ||
|
|
d45ebff66b | ||
|
|
d6f671250e | ||
|
|
6d822cf309 | ||
|
|
d03a75dba5 | ||
|
|
9ff21b67a8 | ||
|
|
5546c9d872 | ||
|
|
fb760718e2 | ||
|
|
d6721e4e75 | ||
|
|
514f5a8ad4 | ||
|
|
a68e0dd8aa | ||
|
|
75d7763c5c | ||
|
|
9bb7df7af7 | ||
|
|
43665cb649 | ||
|
|
39337627b9 | ||
|
|
4bc8a52771 | ||
|
|
b727e4e12e | ||
|
|
93588919e5 | ||
|
|
31659c790d | ||
|
|
c62ecc2442 | ||
|
|
b1fee5d266 | ||
|
|
4a10cfacc3 | ||
|
|
bbdd68a8b4 | ||
|
|
ac3ecd567c | ||
|
|
4fd70d5f1a | ||
|
|
49c52a01b0 | ||
|
|
389c8ecef1 | ||
|
|
f1f24f542a | ||
|
|
8ca041cfcf | ||
|
|
eac8b1a27f | ||
|
|
c8029b7166 | ||
|
|
64f4c18fea | ||
|
|
9abcaf177f | ||
|
|
b839e351c4 | ||
|
|
6b413a299b | ||
|
|
4657c98821 | ||
|
|
dd1e0da155 | ||
|
|
cf5476eb23 | ||
|
|
cf9a748159 | ||
|
|
2e328dd462 | ||
|
|
edd4b4d97f | ||
|
|
608d745159 | ||
|
|
fd795caf76 | ||
|
|
9e2d76f3ce | ||
|
|
ae646fba4b | ||
|
|
2eef6875e9 | ||
|
|
12c09f1a46 | ||
|
|
4a31f763af | ||
|
|
6629cadb87 | ||
|
|
41975c9e2b | ||
|
|
c589c0d998 | ||
|
|
7c157d6ab1 | ||
|
|
7c642bee09 | ||
|
|
beba2a7aa0 | ||
|
|
f2201dabfa | ||
|
|
108dcb7f70 | ||
|
|
8858e07d8b | ||
|
|
d33a89b89f | ||
|
|
1d70336a91 | ||
|
|
6080527e9e | ||
|
|
82187bffba | ||
|
|
f4977e5ef6 | ||
|
|
832268cae7 | ||
|
|
f6de2a709f | ||
|
|
de796ac1c2 | ||
|
|
6b5aefc27a | ||
|
|
5010b09329 | ||
|
|
368fd27393 | ||
|
|
b2ca49376c | ||
|
|
6d98a71796 | ||
|
|
1c91823308 | ||
|
|
352a67857b | ||
|
|
644a3ad220 | ||
|
|
19c32f58b2 | ||
|
|
d01c4904ff | ||
|
|
8cfa2282ef | ||
|
|
8e88a61021 | ||
|
|
ad4d045101 | ||
|
|
5888e04654 | ||
|
|
19b10cb894 | ||
|
|
aa25820698 | ||
|
|
9e3b84939f | ||
|
|
1dbb930660 | ||
|
|
6557d9b728 | ||
|
|
250628dae3 | ||
|
|
da72ac1f6d | ||
|
|
f9a170a3c4 | ||
|
|
88f06fc305 | ||
|
|
562a49a194 | ||
|
|
6136a77eb3 | ||
|
|
afff9216ea | ||
|
|
b56edd4db0 | ||
|
|
d512f20c56 | ||
|
|
57c9ba49f4 | ||
|
|
40255b128e | ||
|
|
6524d3a51e | ||
|
|
92c8cd7c72 | ||
|
|
c678ca21d5 | ||
|
|
6d4b43dd7a | ||
|
|
b0f2ad7cfe | ||
|
|
cd0b1be46c | ||
|
|
08856a97fb | ||
|
|
b6d5ce2d4d | ||
|
|
0f55e550cf | ||
|
|
e1de04230f | ||
|
|
a887a337a5 | ||
|
|
2717ba3e50 | ||
|
|
63af4c551d | ||
|
|
c675cf5e72 | ||
|
|
4fd95ead3b | ||
|
|
514add4b85 | ||
|
|
3ca01b60a5 | ||
|
|
39e398ae02 | ||
|
|
9bbe64489f | ||
|
|
7e54156f2f | ||
|
|
9b80820b17 | ||
|
|
e836b4ac10 | ||
|
|
f228a4dcca | ||
|
|
3297f75edd | ||
|
|
25ba042493 | ||
|
|
483229779c | ||
|
|
5a50856fc1 | ||
|
|
cf734f7e7b | ||
|
|
72325f792c | ||
|
|
9761ac5045 | ||
|
|
8fa52e9d31 | ||
|
|
80b6a95eba | ||
|
|
96cebd2a35 | ||
|
|
fc103f6c17 | ||
|
|
a45d2109f3 | ||
|
|
7a30e65175 | ||
|
|
c63dc7fe2f | ||
|
|
830b51c75b | ||
|
|
cc8c46d5de | ||
|
|
a4767fdd8e | ||
|
|
2a274d4a08 | ||
|
|
2175a10932 | ||
|
|
20f3e62529 | ||
|
|
7f2e2fee56 | ||
|
|
9810834f20 | ||
|
|
0d4cb9e9fb | ||
|
|
f5dc380b63 | ||
|
|
3f69254f43 | ||
|
|
84248b6ec2 | ||
|
|
688547b063 | ||
|
|
ac93641946 | ||
|
|
58f74ebad1 | ||
|
|
e3be548e8d | ||
|
|
2724630430 | ||
|
|
bb8f93146f | ||
|
|
8fc73874de | ||
|
|
19609db13c | ||
|
|
0db0b03db9 | ||
|
|
3c5390a87e | ||
|
|
e9d2905a82 | ||
|
|
48bbd9e214 | ||
|
|
4ecc798b1b | ||
|
|
68be2f023f | ||
|
|
c76b8785f8 | ||
|
|
d4f5ec2492 | ||
|
|
06a3e9792d | ||
|
|
e9707c2f9e | ||
|
|
ab55373bc5 | ||
|
|
a2c5fdaf66 | ||
|
|
b86ed46845 | ||
|
|
3dd5095792 | ||
|
|
582677d067 | ||
|
|
3ade03f3b3 | ||
|
|
5090d9853b | ||
|
|
d41ff2076f | ||
|
|
b018072914 | ||
|
|
361a69f4de | ||
|
|
73cf491478 | ||
|
|
9df04d71e2 | ||
|
|
c159180589 | ||
|
|
8e485e5868 | ||
|
|
11b0efc38f | ||
|
|
45d382f344 | ||
|
|
5bf7a9575c | ||
|
|
50c8f7f96f | ||
|
|
e8e00d4cb8 | ||
|
|
49232372a7 | ||
|
|
72ffeb73d3 | ||
|
|
e68a6037e2 | ||
|
|
ec08500924 | ||
|
|
6046a8c95b | ||
|
|
792ec49e5b | ||
|
|
3ffd87d8de | ||
|
|
e313d39be8 | ||
|
|
ac59023abb | ||
|
|
d32fc0400e | ||
|
|
7ea88358f0 | ||
|
|
c5df806ad2 | ||
|
|
c6b391304d | ||
|
|
2e836cee88 | ||
|
|
e41d127732 | ||
|
|
f1c4caf14a | ||
|
|
c9ce3a5464 | ||
|
|
22a69333a0 | ||
|
|
ed87dda0a6 | ||
|
|
053134f66e | ||
|
|
837ae1b1b3 | ||
|
|
4008be19f4 | ||
|
|
c28a5d24f8 | ||
|
|
314125e7ec | ||
|
|
759bb88a90 | ||
|
|
0607e52767 | ||
|
|
d6bb143978 | ||
|
|
f81898c906 | ||
|
|
d5ad5fab87 | ||
|
|
d9ad65622a | ||
|
|
4999fce7f4 | ||
|
|
e5a6fd2d4f | ||
|
|
83a1fa618d | ||
|
|
9253bdbf77 | ||
|
|
41effa5aeb | ||
|
|
b07ed71de2 | ||
|
|
deaa64b080 | ||
|
|
d42384cdb7 | ||
|
|
24f243a1bc | ||
|
|
67a4fe703c | ||
|
|
c16a989287 | ||
|
|
bc376ad419 | ||
|
|
aba719f5fe | ||
|
|
1d7abc95b8 | ||
|
|
1dccdb7ff2 | ||
|
|
395164e2d4 | ||
|
|
b449d17124 | ||
|
|
6ad5e0709c | ||
|
|
4bfafbe3aa | ||
|
|
2274d7488b | ||
|
|
39518ec633 | ||
|
|
6bd37b2a2b | ||
|
|
f17ec7ffd8 | ||
|
|
d9f8129a32 | ||
|
|
8f0a345e2a | ||
|
|
56b2dabcca | ||
|
|
7632204966 | ||
|
|
c0fbc1979e | ||
|
|
d00604dd28 | ||
|
|
869a3dfbb4 | ||
|
|
df66046b14 | ||
|
|
9ec8478b41 | ||
|
|
bb6ec7ca81 | ||
|
|
1b2e3dc7af | ||
|
|
580ec737d3 | ||
|
|
e4dd22b260 | ||
|
|
172f282e9e | ||
|
|
7f0c9b1942 | ||
|
|
8c2063aeea | ||
|
|
ed6e7750e2 | ||
|
|
a0c389a854 | ||
|
|
e9037fceb0 | ||
|
|
2406cc775e | ||
|
|
b84cbee77a | ||
|
|
fa762e69a4 | ||
|
|
7e0fd1e260 | ||
|
|
d6037e5549 | ||
|
|
9fce13fe03 | ||
|
|
4375822cbb | ||
|
|
e0d13148ef | ||
|
|
bd68472d3c | ||
|
|
b3c534bae5 | ||
|
|
b7d6ae1b48 | ||
|
|
aacfcae382 | ||
|
|
1c92034191 | ||
|
|
ef8820e4e4 | ||
|
|
35daffdb2f | ||
|
|
0983119ae2 | ||
|
|
0371062e86 | ||
|
|
74bae32c83 | ||
|
|
4e67cd4baf | ||
|
|
0449fefa60 | ||
|
|
156e3b017d | ||
|
|
d4dc7b0a34 | ||
|
|
ebf2a26e72 | ||
|
|
545dff8b64 | ||
|
|
7353bc0b2b | ||
|
|
99c9f3069c | ||
|
|
f9f2333997 | ||
|
|
179b8aa88f | ||
|
|
040d66f0bb | ||
|
|
c875088be2 | ||
|
|
46fa32f087 | ||
|
|
551bc1a4a8 | ||
|
|
1305f2f6dc | ||
|
|
2a2a276e3b | ||
|
|
5aba4ca1b1 | ||
|
|
47b5ebfc43 | ||
|
|
1bb0d11f62 | ||
|
|
6164f5c35b | ||
|
|
c263398423 | ||
|
|
ef922b29c2 | ||
|
|
d10ef7b58a | ||
|
|
e074e957d1 | ||
|
|
7b546ea2ee | ||
|
|
506e2e12a6 | ||
|
|
c52255e2a4 | ||
|
|
b05d00ede9 | ||
|
|
8d05489973 | ||
|
|
4f18809500 | ||
|
|
28218ec550 | ||
|
|
f97954c811 | ||
|
|
798f65b35e | ||
|
|
57484b97bb | ||
|
|
0e0602c553 | ||
|
|
54ffb52838 | ||
|
|
c62e45ee88 | ||
|
|
56a05d2cce | ||
|
|
3e09bc9470 | ||
|
|
5ed79e5aa3 | ||
|
|
f38b78dbe6 | ||
|
|
f1d6f01585 | ||
|
|
9b627a93ac | ||
|
|
d4709ffcf9 | ||
|
|
ad943b2d4d | ||
|
|
7209fa233f | ||
|
|
7b9cfbc3f7 | ||
|
|
70e916942e | ||
|
|
f60ef0b2e7 | ||
|
|
6d2f7e3ce0 | ||
|
|
caf386c877 | ||
|
|
c4a42eb1f0 | ||
|
|
b6f8677b01 | ||
|
|
36ee21ea8f | ||
|
|
30d5d87ca6 | ||
|
|
67e0b71c18 | ||
|
|
b0f72736b0 | ||
|
|
ae06f13e0e | ||
|
|
0652241519 | ||
|
|
edf9d9b747 | ||
|
|
3acdec51bd | ||
|
|
ce5d2bad97 | ||
|
|
34855bc647 | ||
|
|
56c8297f6b | ||
|
|
e11637dc62 | ||
|
|
e0bff9f212 | ||
|
|
bff6f6679b | ||
|
|
305916f5a9 | ||
|
|
1f46dc2715 | ||
|
|
e3994ace33 | ||
|
|
bdac24bb4e | ||
|
|
6d30faf9c9 | ||
|
|
c0eaa41c7a | ||
|
|
8a2285e706 | ||
|
|
db43930b98 | ||
|
|
b1254106ee | ||
|
|
9c9ea99380 | ||
|
|
ba4c11428c | ||
|
|
0331660fe2 | ||
|
|
3f7840188e | ||
|
|
512c8b600a | ||
|
|
1aad033fec | ||
|
|
f1d9364ef4 | ||
|
|
c2b2c9eafe | ||
|
|
09b9d3b3fa | ||
|
|
e9e0016a63 | ||
|
|
3704dae342 | ||
|
|
bea5f97cbf | ||
|
|
7a6adfa97e | ||
|
|
1c4183d943 | ||
|
|
dff31a7a4c | ||
|
|
ed8873fbb0 | ||
|
|
9102ff031d | ||
|
|
8c555c4e69 | ||
|
|
2b1762be16 | ||
|
|
aa2f37d54d | ||
|
|
d58cc55cb2 | ||
|
|
c5cc238308 | ||
|
|
6bbdf67f96 | ||
|
|
fcadf08921 |
30
.dockerignore
Normal file
30
.dockerignore
Normal file
@@ -0,0 +1,30 @@
|
||||
# Git and GitHub folders
|
||||
.git/*
|
||||
.github/*
|
||||
|
||||
# Docker and CI/CD related files
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.goreleaser.yml
|
||||
Dockerfile
|
||||
|
||||
# Documentation and license
|
||||
docs/*
|
||||
README.md
|
||||
README_CN.md
|
||||
MANAGEMENT_API.md
|
||||
MANAGEMENT_API_CN.md
|
||||
LICENSE
|
||||
|
||||
# Runtime data folders (should be mounted as volumes)
|
||||
auths/*
|
||||
logs/*
|
||||
conv/*
|
||||
config.yaml
|
||||
|
||||
# Development/editor
|
||||
bin/*
|
||||
.claude/*
|
||||
.vscode/*
|
||||
.serena/*
|
||||
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Example environment configuration for CLIProxyAPI.
|
||||
# Copy this file to `.env` and uncomment the variables you need.
|
||||
#
|
||||
# NOTE: Environment variables are only required when using remote storage options.
|
||||
# For local file-based storage (default), no environment variables need to be set.
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Management Web UI
|
||||
# ------------------------------------------------------------------------------
|
||||
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Postgres Token Store (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
|
||||
# PGSTORE_SCHEMA=public
|
||||
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Git-Backed Config Store (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
|
||||
# GITSTORE_GIT_USERNAME=git-user
|
||||
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
|
||||
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Object Store Token Store (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
|
||||
# OBJECTSTORE_BUCKET=cli-proxy-config
|
||||
# OBJECTSTORE_ACCESS_KEY=your_access_key
|
||||
# OBJECTSTORE_SECRET_KEY=your_secret_key
|
||||
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [router-for-me]
|
||||
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**CLI Type**
|
||||
What type of CLI account do you use? (gemini-cli, gemini, codex, claude code or openai-compatibility)
|
||||
|
||||
**Model Name**
|
||||
What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
|
||||
|
||||
**LLM Client**
|
||||
What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
|
||||
|
||||
**Request Information**
|
||||
The best way is to paste the cURL command of the HTTP request here.
|
||||
Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**OS Type**
|
||||
- OS: [e.g. macOS]
|
||||
- Version [e.g. 15.6.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
14
.github/workflows/docker-image.yml
vendored
14
.github/workflows/docker-image.yml
vendored
@@ -24,8 +24,11 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Generate App Version
|
||||
run: echo APP_VERSION=`git describe --tags --always` >> $GITHUB_ENV
|
||||
- name: Generate Build Metadata
|
||||
run: |
|
||||
echo VERSION=`git describe --tags --always --dirty` >> $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
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -35,8 +38,9 @@ jobs:
|
||||
linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
APP_NAME=${{ env.APP_NAME }}
|
||||
APP_VERSION=${{ env.APP_VERSION }}
|
||||
VERSION=${{ env.VERSION }}
|
||||
COMMIT=${{ env.COMMIT }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
tags: |
|
||||
${{ env.DOCKERHUB_REPO }}:latest
|
||||
${{ env.DOCKERHUB_REPO }}:${{ env.APP_VERSION }}
|
||||
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}
|
||||
|
||||
28
.github/workflows/pr-path-guard.yml
vendored
Normal file
28
.github/workflows/pr-path-guard.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: translator-path-guard
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
ensure-no-translator-changes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Detect internal/translator changes
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files: |
|
||||
internal/translator/**
|
||||
- name: Fail when restricted paths change
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
echo "Changes under internal/translator are not allowed in pull requests."
|
||||
echo "You need to create an issue for our maintenance team to make the necessary changes."
|
||||
exit 1
|
||||
14
.github/workflows/release.yaml
vendored
14
.github/workflows/release.yaml
vendored
@@ -13,18 +13,26 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '>=1.24.0'
|
||||
cache: true
|
||||
- uses: goreleaser/goreleaser-action@v3
|
||||
- name: Generate Build Metadata
|
||||
run: |
|
||||
echo VERSION=`git describe --tags --always --dirty` >> $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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ env.VERSION }}
|
||||
COMMIT: ${{ env.COMMIT }}
|
||||
BUILD_DATE: ${{ env.BUILD_DATE }}
|
||||
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -1,2 +1,32 @@
|
||||
# Binaries
|
||||
cli-proxy-api
|
||||
*.exe
|
||||
|
||||
# Configuration
|
||||
config.yaml
|
||||
docs/
|
||||
.env
|
||||
|
||||
# Generated content
|
||||
bin/*
|
||||
logs/*
|
||||
conv/*
|
||||
temp/*
|
||||
pgstore/*
|
||||
gitstore/*
|
||||
objectstore/*
|
||||
static/*
|
||||
|
||||
# Authentication data
|
||||
auths/*
|
||||
!auths/.gitkeep
|
||||
|
||||
# Documentation
|
||||
docs/*
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
|
||||
# Tooling metadata
|
||||
.vscode/*
|
||||
.claude/*
|
||||
.serena/*
|
||||
|
||||
@@ -9,6 +9,8 @@ builds:
|
||||
- arm64
|
||||
main: ./cmd/server/
|
||||
binary: cli-proxy-api
|
||||
ldflags:
|
||||
- -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
|
||||
archives:
|
||||
- id: "cli-proxy-api"
|
||||
format: tar.gz
|
||||
@@ -19,4 +21,17 @@ archives:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- README_CN.md
|
||||
- config.example.yaml
|
||||
- config.example.yaml
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
14
Dockerfile
14
Dockerfile
@@ -8,16 +8,28 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o ./CLIProxyAPI ./cmd/server/
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=none
|
||||
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
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
RUN mkdir /CLIProxyAPI
|
||||
|
||||
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
|
||||
|
||||
COPY config.example.yaml /CLIProxyAPI/config.example.yaml
|
||||
|
||||
WORKDIR /CLIProxyAPI
|
||||
|
||||
EXPOSE 8317
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
|
||||
|
||||
CMD ["./CLIProxyAPI"]
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Luis Pater
|
||||
Copyright (c) 2025-2005.9 Luis Pater
|
||||
Copyright (c) 2025.9-present Router-For.ME
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
335
README.md
335
README.md
@@ -2,305 +2,59 @@
|
||||
|
||||
English | [中文](README_CN.md)
|
||||
|
||||
A proxy server that provides OpenAI/Gemini/Claude compatible API interfaces for CLI.
|
||||
A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
|
||||
|
||||
It now also supports OpenAI Codex (GPT models) via OAuth.
|
||||
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
|
||||
|
||||
so you can use local or multi‑account CLI access with OpenAI‑compatible clients and SDKs.
|
||||
So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
|
||||
|
||||
## Features
|
||||
## Sponsor
|
||||
|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
|
||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
|
||||
|
||||
GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
|
||||
|
||||
Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
|
||||
|
||||
## Overview
|
||||
|
||||
- OpenAI/Gemini/Claude 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
|
||||
- Streaming and non-streaming responses
|
||||
- Function calling/tools support
|
||||
- Multimodal input support (text and images)
|
||||
- Multiple accounts with round‑robin load balancing (Gemini and OpenAI)
|
||||
- Simple CLI authentication flows (Gemini and OpenAI)
|
||||
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
|
||||
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
|
||||
- Generative Language API Key support
|
||||
- Gemini CLI multi‑account load balancing
|
||||
- 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
|
||||
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
|
||||
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`)
|
||||
|
||||
## Installation
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
|
||||
|
||||
- Go 1.24 or higher
|
||||
- A Google account with access to Gemini CLI models (optional)
|
||||
- An OpenAI account for Codex/GPT access (optional)
|
||||
## Management API
|
||||
|
||||
### Building from Source
|
||||
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/luispater/CLIProxyAPI.git
|
||||
cd CLIProxyAPI
|
||||
```
|
||||
## SDK Docs
|
||||
|
||||
2. Build the application:
|
||||
```bash
|
||||
go build -o cli-proxy-api ./cmd/server
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Authentication
|
||||
|
||||
You can authenticate for Gemini and/or OpenAI. Both can coexist in the same `auth-dir` and will be load balanced.
|
||||
|
||||
- Gemini (Google):
|
||||
```bash
|
||||
./cli-proxy-api --login
|
||||
```
|
||||
If you are an old gemini code user, you may need to specify a project ID:
|
||||
```bash
|
||||
./cli-proxy-api --login --project_id <your_project_id>
|
||||
```
|
||||
The local OAuth callback uses port `8085`.
|
||||
|
||||
- OpenAI (Codex/GPT via OAuth):
|
||||
```bash
|
||||
./cli-proxy-api --codex-login
|
||||
```
|
||||
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `1455`.
|
||||
|
||||
### Starting the Server
|
||||
|
||||
Once authenticated, start the server:
|
||||
|
||||
```bash
|
||||
./cli-proxy-api
|
||||
```
|
||||
|
||||
By default, the server runs on port 8317.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### List Models
|
||||
|
||||
```
|
||||
GET http://localhost:8317/v1/models
|
||||
```
|
||||
|
||||
#### Chat Completions
|
||||
|
||||
```
|
||||
POST http://localhost:8317/v1/chat/completions
|
||||
```
|
||||
|
||||
Request body example:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gemini-2.5-pro",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, how are you?"
|
||||
}
|
||||
],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Use a `gemini-*` model for Gemini (e.g., `gemini-2.5-pro`) or a `gpt-*` model for OpenAI (e.g., `gpt-5`). The proxy will route to the correct provider automatically.
|
||||
|
||||
#### Claude Messages (SSE-compatible)
|
||||
|
||||
```
|
||||
POST http://localhost:8317/v1/messages
|
||||
```
|
||||
|
||||
### Using with OpenAI Libraries
|
||||
|
||||
You can use this proxy with any OpenAI-compatible library by setting the base URL to your local server:
|
||||
|
||||
#### Python (with OpenAI library)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="dummy", # Not used but required
|
||||
base_url="http://localhost:8317/v1"
|
||||
)
|
||||
|
||||
# Gemini example
|
||||
gemini = client.chat.completions.create(
|
||||
model="gemini-2.5-pro",
|
||||
messages=[{"role": "user", "content": "Hello, how are you?"}]
|
||||
)
|
||||
|
||||
# Codex/GPT example
|
||||
gpt = client.chat.completions.create(
|
||||
model="gpt-5",
|
||||
messages=[{"role": "user", "content": "Summarize this project in one sentence."}]
|
||||
)
|
||||
print(gemini.choices[0].message.content)
|
||||
print(gpt.choices[0].message.content)
|
||||
```
|
||||
|
||||
#### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'dummy', // Not used but required
|
||||
baseURL: 'http://localhost:8317/v1',
|
||||
});
|
||||
|
||||
// Gemini
|
||||
const gemini = await openai.chat.completions.create({
|
||||
model: 'gemini-2.5-pro',
|
||||
messages: [{ role: 'user', content: 'Hello, how are you?' }],
|
||||
});
|
||||
|
||||
// Codex/GPT
|
||||
const gpt = await openai.chat.completions.create({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'Summarize this project in one sentence.' }],
|
||||
});
|
||||
|
||||
console.log(gemini.choices[0].message.content);
|
||||
console.log(gpt.choices[0].message.content);
|
||||
```
|
||||
|
||||
## Supported Models
|
||||
|
||||
- gemini-2.5-pro
|
||||
- gemini-2.5-flash
|
||||
- gpt-5
|
||||
- Gemini models auto‑switch to preview variants when needed
|
||||
|
||||
## Configuration
|
||||
|
||||
The server uses a YAML configuration file (`config.yaml`) located in the project root directory by default. You can specify a different configuration file path using the `--config` flag:
|
||||
|
||||
```bash
|
||||
./cli-proxy-api --config /path/to/your/config.yaml
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------------------------------|----------|--------------------|----------------------------------------------------------------------------------------------|
|
||||
| `port` | integer | 8317 | The port number on which the server will listen |
|
||||
| `auth-dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for home directory |
|
||||
| `proxy-url` | string | "" | Proxy url, support socks5/http/https protocol, example: socks5://user:pass@192.168.1.1:1080/ |
|
||||
| `quota-exceeded` | object | {} | Configuration for handling quota exceeded |
|
||||
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded |
|
||||
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded |
|
||||
| `debug` | boolean | false | Enable debug mode for verbose logging |
|
||||
| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests |
|
||||
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys |
|
||||
|
||||
### Example Configuration File
|
||||
|
||||
```yaml
|
||||
# Server port
|
||||
port: 8317
|
||||
|
||||
# Authentication directory (supports ~ for home directory)
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
# Enable debug logging
|
||||
debug: false
|
||||
|
||||
# Proxy url, support socks5/http/https protocol, example: socks5://user:pass@192.168.1.1:1080/
|
||||
proxy-url: ""
|
||||
|
||||
# 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
|
||||
|
||||
# API keys for authentication
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# API keys for official Generative Language API
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
- "AIzaSy...02"
|
||||
- "AIzaSy...03"
|
||||
- "AIzaSy...04"
|
||||
```
|
||||
|
||||
### Authentication Directory
|
||||
|
||||
The `auth-dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing.
|
||||
|
||||
### API Keys
|
||||
|
||||
The `api-keys` parameter allows you to define a list of API keys that can be used to authenticate requests to your proxy server. When making requests to the API, you can include one of these keys in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer your-api-key-1
|
||||
```
|
||||
|
||||
### Official Generative Language API
|
||||
|
||||
The `generative-language-api-key` parameter allows you to define a list of API keys that can be used to authenticate requests to the official Generative Language API.
|
||||
|
||||
## Hot Reloading
|
||||
|
||||
The server watches the config file and the `auth-dir` for changes and reloads clients and settings automatically. You can add or remove Gemini/OpenAI token JSON files while the server is running; no restart is required.
|
||||
|
||||
## Gemini CLI with multiple account load balancing
|
||||
|
||||
Start CLI Proxy API server, and then set the `CODE_ASSIST_ENDPOINT` environment variable to the URL of the CLI Proxy API server.
|
||||
|
||||
```bash
|
||||
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:8317"
|
||||
```
|
||||
|
||||
The server will relay the `loadCodeAssist`, `onboardUser`, and `countTokens` requests. And automatically load balance the text generation requests between the multiple accounts.
|
||||
|
||||
> [!NOTE]
|
||||
> This feature only allows local access because I couldn't find a way to authenticate the requests.
|
||||
> I hardcoded `127.0.0.1` into the load balancing.
|
||||
|
||||
## Claude Code with multiple account load balancing
|
||||
|
||||
Start CLI Proxy API server, and then set the `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL` environment variables.
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||
export ANTHROPIC_MODEL=gemini-2.5-pro
|
||||
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||
export ANTHROPIC_MODEL=gpt-5
|
||||
export ANTHROPIC_SMALL_FAST_MODEL=codex-mini-latest
|
||||
```
|
||||
|
||||
## Run with Docker
|
||||
|
||||
Run the following command to login (Gemini OAuth on port 8085):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8085:8085 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --login
|
||||
```
|
||||
|
||||
Run the following command to login (OpenAI OAuth on port 1455):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 1455:1455 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --codex-login
|
||||
```
|
||||
|
||||
Run the following command to start the server:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
|
||||
```
|
||||
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
|
||||
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
|
||||
- Access: [docs/sdk-access.md](docs/sdk-access.md)
|
||||
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
|
||||
- Custom Provider Example: `examples/custom-provider`
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -312,6 +66,21 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## Who is with us?
|
||||
|
||||
Those projects are based on CLIProxyAPI:
|
||||
|
||||
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
||||
|
||||
Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
|
||||
|
||||
### [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
|
||||
|
||||
> [!NOTE]
|
||||
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
343
README_CN.md
343
README_CN.md
@@ -2,307 +2,59 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
一个为 CLI 提供 OpenAI/Gemini/Claude 兼容 API 接口的代理服务器。
|
||||
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
|
||||
|
||||
现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)。
|
||||
现已支持通过 OAuth 登录接入 OpenAI Codex(GPT 系列)和 Claude Code。
|
||||
|
||||
可与本地或多账户方式配合,使用任何 OpenAI 兼容的客户端与 SDK。
|
||||
您可以使用本地或多账户的CLI方式,通过任何与 OpenAI(包括Responses)/Gemini/Claude 兼容的客户端和SDK进行访问。
|
||||
|
||||
## 赞助商
|
||||
|
||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||
|
||||
本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
|
||||
|
||||
GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.6,为开发者提供顶尖的编码体验。
|
||||
|
||||
智谱AI为本软件提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 为 CLI 模型提供 OpenAI/Gemini/Claude 兼容的 API 端点
|
||||
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
|
||||
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
|
||||
- 新增 Claude Code 支持(OAuth 登录)
|
||||
- 新增 Qwen Code 支持(OAuth 登录)
|
||||
- 新增 iFlow 支持(OAuth 登录)
|
||||
- 支持流式与非流式响应
|
||||
- 函数调用/工具支持
|
||||
- 多模态输入(文本、图片)
|
||||
- 多账户支持与轮询负载均衡(Gemini 与 OpenAI)
|
||||
- 简单的 CLI 身份验证流程(Gemini 与 OpenAI)
|
||||
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
||||
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
|
||||
- 支持 Gemini AIStudio API 密钥
|
||||
- 支持 AI Studio Build 多账户轮询
|
||||
- 支持 Gemini CLI 多账户轮询
|
||||
- 支持 Claude Code 多账户轮询
|
||||
- 支持 Qwen Code 多账户轮询
|
||||
- 支持 iFlow 多账户轮询
|
||||
- 支持 OpenAI Codex 多账户轮询
|
||||
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter)
|
||||
- 可复用的 Go SDK(见 `docs/sdk-usage_CN.md`)
|
||||
|
||||
## 安装
|
||||
## 新手入门
|
||||
|
||||
### 前置要求
|
||||
CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
|
||||
|
||||
- Go 1.24 或更高版本
|
||||
- 有权访问 Gemini CLI 模型的 Google 账户(可选)
|
||||
- 有权访问 OpenAI Codex/GPT 的 OpenAI 账户(可选)
|
||||
## 管理 API 文档
|
||||
|
||||
### 从源码构建
|
||||
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
|
||||
|
||||
1. 克隆仓库:
|
||||
```bash
|
||||
git clone https://github.com/luispater/CLIProxyAPI.git
|
||||
cd CLIProxyAPI
|
||||
```
|
||||
## SDK 文档
|
||||
|
||||
2. 构建应用程序:
|
||||
```bash
|
||||
go build -o cli-proxy-api ./cmd/server
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 身份验证
|
||||
|
||||
您可以分别为 Gemini 和 OpenAI 进行身份验证,二者可同时存在于同一个 `auth-dir` 中并参与负载均衡。
|
||||
|
||||
- Gemini(Google):
|
||||
```bash
|
||||
./cli-proxy-api --login
|
||||
```
|
||||
如果您是旧版 gemini code 用户,可能需要指定项目 ID:
|
||||
```bash
|
||||
./cli-proxy-api --login --project_id <your_project_id>
|
||||
```
|
||||
本地 OAuth 回调端口为 `8085`。
|
||||
|
||||
- OpenAI(Codex/GPT,OAuth):
|
||||
```bash
|
||||
./cli-proxy-api --codex-login
|
||||
```
|
||||
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `1455`。
|
||||
|
||||
### 启动服务器
|
||||
|
||||
身份验证完成后,启动服务器:
|
||||
|
||||
```bash
|
||||
./cli-proxy-api
|
||||
```
|
||||
|
||||
默认情况下,服务器在端口 8317 上运行。
|
||||
|
||||
### API 端点
|
||||
|
||||
#### 列出模型
|
||||
|
||||
```
|
||||
GET http://localhost:8317/v1/models
|
||||
```
|
||||
|
||||
#### 聊天补全
|
||||
|
||||
```
|
||||
POST http://localhost:8317/v1/chat/completions
|
||||
```
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gemini-2.5-pro",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "你好,你好吗?"
|
||||
}
|
||||
],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- 使用 `gemini-*` 模型(如 `gemini-2.5-pro`)走 Gemini,使用 `gpt-*` 模型(如 `gpt-5`)走 OpenAI,服务会自动路由到对应提供商。
|
||||
|
||||
#### Claude 消息(SSE 兼容)
|
||||
|
||||
```
|
||||
POST http://localhost:8317/v1/messages
|
||||
```
|
||||
|
||||
### 与 OpenAI 库一起使用
|
||||
|
||||
您可以通过将基础 URL 设置为本地服务器来将此代理与任何 OpenAI 兼容的库一起使用:
|
||||
|
||||
#### Python(使用 OpenAI 库)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key="dummy", # 不使用但必需
|
||||
base_url="http://localhost:8317/v1"
|
||||
)
|
||||
|
||||
# Gemini 示例
|
||||
gemini = client.chat.completions.create(
|
||||
model="gemini-2.5-pro",
|
||||
messages=[{"role": "user", "content": "你好,你好吗?"}]
|
||||
)
|
||||
|
||||
# Codex/GPT 示例
|
||||
gpt = client.chat.completions.create(
|
||||
model="gpt-5",
|
||||
messages=[{"role": "user", "content": "用一句话总结这个项目"}]
|
||||
)
|
||||
|
||||
print(gemini.choices[0].message.content)
|
||||
print(gpt.choices[0].message.content)
|
||||
```
|
||||
|
||||
#### JavaScript/TypeScript
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'dummy', // 不使用但必需
|
||||
baseURL: 'http://localhost:8317/v1',
|
||||
});
|
||||
|
||||
// Gemini
|
||||
const gemini = await openai.chat.completions.create({
|
||||
model: 'gemini-2.5-pro',
|
||||
messages: [{ role: 'user', content: '你好,你好吗?' }],
|
||||
});
|
||||
|
||||
// Codex/GPT
|
||||
const gpt = await openai.chat.completions.create({
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: '用一句话总结这个项目' }],
|
||||
});
|
||||
|
||||
console.log(gemini.choices[0].message.content);
|
||||
console.log(gpt.choices[0].message.content);
|
||||
```
|
||||
|
||||
## 支持的模型
|
||||
|
||||
- gemini-2.5-pro
|
||||
- gemini-2.5-flash
|
||||
- gpt-5
|
||||
- Gemini 模型在需要时自动切换到对应的 preview 版本
|
||||
|
||||
## 配置
|
||||
|
||||
服务器默认使用位于项目根目录的 YAML 配置文件(`config.yaml`)。您可以使用 `--config` 标志指定不同的配置文件路径:
|
||||
|
||||
```bash
|
||||
./cli-proxy-api --config /path/to/your/config.yaml
|
||||
```
|
||||
|
||||
### 配置选项
|
||||
|
||||
| 参数 | 类型 | 默认值 | 描述 |
|
||||
|---------------------------------------|----------|--------------------|------------------------------------------------------------------------|
|
||||
| `port` | integer | 8317 | 服务器监听的端口号 |
|
||||
| `auth-dir` | string | "~/.cli-proxy-api" | 存储身份验证令牌的目录。支持使用 `~` 表示主目录 |
|
||||
| `proxy-url` | string | "" | 代理 URL,支持 socks5/http/https 协议,示例:socks5://user:pass@192.168.1.1:1080/ |
|
||||
| `quota-exceeded` | object | {} | 处理配额超限的配置 |
|
||||
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时是否自动切换到另一个项目 |
|
||||
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时是否自动切换到预览模型 |
|
||||
| `debug` | boolean | false | 启用调试模式以进行详细日志记录 |
|
||||
| `api-keys` | string[] | [] | 可用于验证请求的 API 密钥列表 |
|
||||
| `generative-language-api-key` | string[] | [] | 生成式语言 API 密钥列表 |
|
||||
|
||||
### 配置文件示例
|
||||
|
||||
```yaml
|
||||
# 服务器端口
|
||||
port: 8317
|
||||
|
||||
# 身份验证目录(支持 ~ 表示主目录)
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
# 启用调试日志
|
||||
debug: false
|
||||
|
||||
# 代理 URL,支持 socks5/http/https 协议,示例:socks5://user:pass@192.168.1.1:1080/
|
||||
proxy-url: ""
|
||||
|
||||
# 配额超限行为
|
||||
quota-exceeded:
|
||||
switch-project: true # 当配额超限时是否自动切换到另一个项目
|
||||
switch-preview-model: true # 当配额超限时是否自动切换到预览模型
|
||||
|
||||
# 用于本地身份验证的 API 密钥
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# AIStduio Gemini API 的 API 密钥
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
- "AIzaSy...02"
|
||||
- "AIzaSy...03"
|
||||
- "AIzaSy...04"
|
||||
```
|
||||
|
||||
### 身份验证目录
|
||||
|
||||
`auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。
|
||||
|
||||
### API 密钥
|
||||
|
||||
`api-keys` 参数允许您定义可用于验证对代理服务器请求的 API 密钥列表。在向 API 发出请求时,您可以在 `Authorization` 标头中包含其中一个密钥:
|
||||
|
||||
```
|
||||
Authorization: Bearer your-api-key-1
|
||||
```
|
||||
|
||||
### 官方生成式语言 API
|
||||
|
||||
`generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。
|
||||
|
||||
## 热更新
|
||||
|
||||
服务会监听配置文件与 `auth-dir` 目录的变化并自动重新加载客户端与配置。您可以在运行中新增/移除 Gemini/OpenAI 的令牌 JSON 文件,无需重启服务。
|
||||
|
||||
## Gemini CLI 多账户负载均衡
|
||||
|
||||
启动 CLI 代理 API 服务器,然后将 `CODE_ASSIST_ENDPOINT` 环境变量设置为 CLI 代理 API 服务器的 URL。
|
||||
|
||||
```bash
|
||||
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:8317"
|
||||
```
|
||||
|
||||
服务器将中继 `loadCodeAssist`、`onboardUser` 和 `countTokens` 请求。并自动在多个账户之间轮询文本生成请求。
|
||||
|
||||
> [!NOTE]
|
||||
> 此功能仅允许本地访问,因为找不到一个可以验证请求的方法。
|
||||
> 所以只能强制只有 `127.0.0.1` 可以访问。
|
||||
|
||||
## Claude Code 的使用方法
|
||||
|
||||
启动 CLI Proxy API 服务器, 设置如下系统环境变量 `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||
export ANTHROPIC_MODEL=gemini-2.5-pro
|
||||
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
|
||||
```
|
||||
|
||||
或者
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-dummy
|
||||
export ANTHROPIC_MODEL=gpt-5
|
||||
export ANTHROPIC_SMALL_FAST_MODEL=codex-mini-latest
|
||||
```
|
||||
|
||||
|
||||
## 使用 Docker 运行
|
||||
|
||||
运行以下命令进行登录(Gemini OAuth,端口 8085):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8085:8085 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --login
|
||||
```
|
||||
|
||||
运行以下命令进行登录(OpenAI OAuth,端口 1455):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 1455:1455 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --codex-login
|
||||
```
|
||||
|
||||
运行以下命令启动服务器:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
|
||||
```
|
||||
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
|
||||
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
|
||||
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
|
||||
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
|
||||
- 自定义 Provider 示例:`examples/custom-provider`
|
||||
|
||||
## 贡献
|
||||
|
||||
@@ -314,6 +66,29 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
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)
|
||||
|
||||
一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
||||
|
||||
## 写给所有中国网友的
|
||||
|
||||
QQ 群:188637136
|
||||
|
||||
或
|
||||
|
||||
Telegram 群:https://t.me/CLIProxyAPI
|
||||
|
||||
0
auths/.gitkeep
Normal file
0
auths/.gitkeep
Normal file
@@ -4,131 +4,450 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/cmd"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"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/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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// LogFormatter defines a custom log format for logrus.
|
||||
// This formatter adds timestamp, log level, and source location information
|
||||
// to each log entry for better debugging and monitoring.
|
||||
type LogFormatter struct {
|
||||
}
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildDate = "unknown"
|
||||
DefaultConfigPath = ""
|
||||
)
|
||||
|
||||
// Format renders a single log entry with custom formatting.
|
||||
// It includes timestamp, log level, source file and line number, and the log message.
|
||||
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
||||
var b *bytes.Buffer
|
||||
if entry.Buffer != nil {
|
||||
b = entry.Buffer
|
||||
} else {
|
||||
b = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||
var newLog string
|
||||
// Customize the log format to include timestamp, level, caller file/line, and message.
|
||||
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, path.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
|
||||
|
||||
b.WriteString(newLog)
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// init initializes the logger configuration.
|
||||
// It sets up the custom log formatter, enables caller reporting,
|
||||
// and configures the log output destination.
|
||||
// init initializes the shared logger setup.
|
||||
func init() {
|
||||
// Set logger output to standard output.
|
||||
log.SetOutput(os.Stdout)
|
||||
// Enable reporting the caller function's file and line number.
|
||||
log.SetReportCaller(true)
|
||||
// Set the custom log formatter.
|
||||
log.SetFormatter(&LogFormatter{})
|
||||
logging.SetupBaseLogger()
|
||||
buildinfo.Version = Version
|
||||
buildinfo.Commit = Commit
|
||||
buildinfo.BuildDate = BuildDate
|
||||
}
|
||||
|
||||
// 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).
|
||||
func main() {
|
||||
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
||||
|
||||
// Command-line flags to control the application's behavior.
|
||||
var login bool
|
||||
var codexLogin bool
|
||||
var claudeLogin bool
|
||||
var qwenLogin bool
|
||||
var iflowLogin bool
|
||||
var noBrowser bool
|
||||
var projectID string
|
||||
var vertexImport string
|
||||
var configPath string
|
||||
var password string
|
||||
|
||||
// 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(&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(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
||||
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||
flag.StringVar(&configPath, "config", "", "Configure File Path")
|
||||
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
||||
flag.StringVar(&password, "password", "", "")
|
||||
|
||||
flag.CommandLine.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
|
||||
flag.CommandLine.VisitAll(func(f *flag.Flag) {
|
||||
if f.Name == "password" {
|
||||
return
|
||||
}
|
||||
s := fmt.Sprintf(" -%s", f.Name)
|
||||
name, unquoteUsage := flag.UnquoteUsage(f)
|
||||
if name != "" {
|
||||
s += " " + name
|
||||
}
|
||||
if len(s) <= 4 {
|
||||
s += " "
|
||||
} else {
|
||||
s += "\n "
|
||||
}
|
||||
if unquoteUsage != "" {
|
||||
s += unquoteUsage
|
||||
}
|
||||
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
|
||||
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
_, _ = fmt.Fprint(out, s+"\n")
|
||||
})
|
||||
}
|
||||
|
||||
// Parse the command-line flags.
|
||||
flag.Parse()
|
||||
|
||||
// Core application variables.
|
||||
var err error
|
||||
var cfg *config.Config
|
||||
var wd string
|
||||
var isCloudDeploy bool
|
||||
var (
|
||||
usePostgresStore bool
|
||||
pgStoreDSN string
|
||||
pgStoreSchema string
|
||||
pgStoreLocalPath string
|
||||
pgStoreInst *store.PostgresStore
|
||||
useGitStore bool
|
||||
gitStoreRemoteURL string
|
||||
gitStoreUser string
|
||||
gitStorePassword string
|
||||
gitStoreLocalPath string
|
||||
gitStoreInst *store.GitTokenStore
|
||||
gitStoreRoot string
|
||||
useObjectStore bool
|
||||
objectStoreEndpoint string
|
||||
objectStoreAccess string
|
||||
objectStoreSecret string
|
||||
objectStoreBucket string
|
||||
objectStoreLocalPath string
|
||||
objectStoreInst *store.ObjectTokenStore
|
||||
)
|
||||
|
||||
// Load configuration from the specified path or the default path.
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
// Load environment variables from .env if present.
|
||||
if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
|
||||
if !errors.Is(errLoad, os.ErrNotExist) {
|
||||
log.WithError(errLoad).Warn("failed to load .env file")
|
||||
}
|
||||
}
|
||||
|
||||
lookupEnv := func(keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
writableBase := util.WritablePath()
|
||||
if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
|
||||
usePostgresStore = true
|
||||
pgStoreDSN = value
|
||||
}
|
||||
if usePostgresStore {
|
||||
if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
|
||||
pgStoreSchema = value
|
||||
}
|
||||
if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
|
||||
pgStoreLocalPath = value
|
||||
}
|
||||
if pgStoreLocalPath == "" {
|
||||
if writableBase != "" {
|
||||
pgStoreLocalPath = writableBase
|
||||
} else {
|
||||
pgStoreLocalPath = wd
|
||||
}
|
||||
}
|
||||
useGitStore = false
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
|
||||
useGitStore = true
|
||||
gitStoreRemoteURL = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
|
||||
gitStoreUser = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
|
||||
gitStorePassword = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
|
||||
gitStoreLocalPath = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
|
||||
useObjectStore = true
|
||||
objectStoreEndpoint = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
|
||||
objectStoreAccess = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
|
||||
objectStoreSecret = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
|
||||
objectStoreBucket = value
|
||||
}
|
||||
if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
|
||||
objectStoreLocalPath = value
|
||||
}
|
||||
|
||||
// Check for cloud deploy mode only on first execution
|
||||
// Read env var name in uppercase: DEPLOY
|
||||
deployEnv := os.Getenv("DEPLOY")
|
||||
if deployEnv == "cloud" {
|
||||
isCloudDeploy = true
|
||||
}
|
||||
|
||||
// Determine and load the configuration file.
|
||||
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
|
||||
var configFilePath string
|
||||
if configPath != "" {
|
||||
if usePostgresStore {
|
||||
if pgStoreLocalPath == "" {
|
||||
pgStoreLocalPath = wd
|
||||
}
|
||||
pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
|
||||
DSN: pgStoreDSN,
|
||||
Schema: pgStoreSchema,
|
||||
SpoolDir: pgStoreLocalPath,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize postgres token store: %v", err)
|
||||
}
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
||||
cancel()
|
||||
log.Fatalf("failed to bootstrap postgres-backed config: %v", errBootstrap)
|
||||
}
|
||||
cancel()
|
||||
configFilePath = pgStoreInst.ConfigPath()
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
cfg.AuthDir = pgStoreInst.AuthDir()
|
||||
log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
|
||||
}
|
||||
} else if useObjectStore {
|
||||
if objectStoreLocalPath == "" {
|
||||
if writableBase != "" {
|
||||
objectStoreLocalPath = writableBase
|
||||
} else {
|
||||
objectStoreLocalPath = wd
|
||||
}
|
||||
}
|
||||
objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
|
||||
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
|
||||
useSSL := true
|
||||
if strings.Contains(resolvedEndpoint, "://") {
|
||||
parsed, errParse := url.Parse(resolvedEndpoint)
|
||||
if errParse != nil {
|
||||
log.Fatalf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http":
|
||||
useSSL = false
|
||||
case "https":
|
||||
useSSL = true
|
||||
default:
|
||||
log.Fatalf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
log.Fatalf("object store endpoint %q is missing host information", objectStoreEndpoint)
|
||||
}
|
||||
resolvedEndpoint = parsed.Host
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
|
||||
}
|
||||
}
|
||||
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
|
||||
objCfg := store.ObjectStoreConfig{
|
||||
Endpoint: resolvedEndpoint,
|
||||
Bucket: objectStoreBucket,
|
||||
AccessKey: objectStoreAccess,
|
||||
SecretKey: objectStoreSecret,
|
||||
LocalRoot: objectStoreRoot,
|
||||
UseSSL: useSSL,
|
||||
PathStyle: true,
|
||||
}
|
||||
objectStoreInst, err = store.NewObjectTokenStore(objCfg)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize object token store: %v", err)
|
||||
}
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
||||
cancel()
|
||||
log.Fatalf("failed to bootstrap object-backed config: %v", errBootstrap)
|
||||
}
|
||||
cancel()
|
||||
configFilePath = objectStoreInst.ConfigPath()
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
cfg.AuthDir = objectStoreInst.AuthDir()
|
||||
log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
|
||||
}
|
||||
} else if useGitStore {
|
||||
if gitStoreLocalPath == "" {
|
||||
if writableBase != "" {
|
||||
gitStoreLocalPath = writableBase
|
||||
} else {
|
||||
gitStoreLocalPath = wd
|
||||
}
|
||||
}
|
||||
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
|
||||
authDir := filepath.Join(gitStoreRoot, "auths")
|
||||
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
|
||||
gitStoreInst.SetBaseDir(authDir)
|
||||
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
|
||||
log.Fatalf("failed to prepare git token store: %v", errRepo)
|
||||
}
|
||||
configFilePath = gitStoreInst.ConfigPath()
|
||||
if configFilePath == "" {
|
||||
configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
|
||||
}
|
||||
if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
if _, errExample := os.Stat(examplePath); errExample != nil {
|
||||
log.Fatalf("failed to find template config file: %v", errExample)
|
||||
}
|
||||
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
|
||||
log.Fatalf("failed to bootstrap git-backed config: %v", errCopy)
|
||||
}
|
||||
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
|
||||
log.Fatalf("failed to commit initial git-backed config: %v", errCommit)
|
||||
}
|
||||
log.Infof("git-backed config initialized from template: %s", configFilePath)
|
||||
} else if statErr != nil {
|
||||
log.Fatalf("failed to inspect git-backed config: %v", statErr)
|
||||
}
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
cfg.AuthDir = gitStoreInst.AuthDir()
|
||||
log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
|
||||
}
|
||||
} else if configPath != "" {
|
||||
configFilePath = configPath
|
||||
cfg, err = config.LoadConfig(configPath)
|
||||
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
|
||||
} else {
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
configFilePath = path.Join(wd, "config.yaml")
|
||||
cfg, err = config.LoadConfig(configFilePath)
|
||||
configFilePath = filepath.Join(wd, "config.yaml")
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
|
||||
// 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
|
||||
} else {
|
||||
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
||||
configFileExists = true
|
||||
}
|
||||
}
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
|
||||
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||
log.Fatalf("failed to configure log output: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", buildinfo.Version, buildinfo.Commit, buildinfo.BuildDate)
|
||||
|
||||
// Set the log level based on the configuration.
|
||||
if cfg.Debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
util.SetLogLevel(cfg)
|
||||
|
||||
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
||||
log.Fatalf("failed to resolve auth directory: %v", errResolveAuthDir)
|
||||
} else {
|
||||
log.SetLevel(log.InfoLevel)
|
||||
cfg.AuthDir = resolvedAuthDir
|
||||
}
|
||||
managementasset.SetCurrentConfig(cfg)
|
||||
|
||||
// Expand the tilde (~) in the auth directory path to the user's home directory.
|
||||
if strings.HasPrefix(cfg.AuthDir, "~") {
|
||||
home, errUserHomeDir := os.UserHomeDir()
|
||||
if errUserHomeDir != nil {
|
||||
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
|
||||
}
|
||||
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
|
||||
if len(parts) > 1 {
|
||||
parts[0] = home
|
||||
cfg.AuthDir = path.Join(parts...)
|
||||
} else {
|
||||
cfg.AuthDir = home
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different command modes based on the provided flags.
|
||||
// Create login options to be used in authentication flows.
|
||||
options := &cmd.LoginOptions{
|
||||
NoBrowser: noBrowser,
|
||||
}
|
||||
|
||||
if login {
|
||||
// Register the shared token store once so all components use the same persistence backend.
|
||||
if usePostgresStore {
|
||||
sdkAuth.RegisterTokenStore(pgStoreInst)
|
||||
} else if useObjectStore {
|
||||
sdkAuth.RegisterTokenStore(objectStoreInst)
|
||||
} else if useGitStore {
|
||||
sdkAuth.RegisterTokenStore(gitStoreInst)
|
||||
} else {
|
||||
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
||||
}
|
||||
|
||||
// Register built-in access providers before constructing services.
|
||||
configaccess.Register()
|
||||
|
||||
// Handle different command modes based on the provided flags.
|
||||
|
||||
if vertexImport != "" {
|
||||
// Handle Vertex service account import
|
||||
cmd.DoVertexImport(cfg, vertexImport)
|
||||
} else if login {
|
||||
// Handle Google/Gemini login
|
||||
cmd.DoLogin(cfg, projectID, options)
|
||||
} else if codexLogin {
|
||||
// Handle Codex login
|
||||
cmd.DoCodexLogin(cfg, options)
|
||||
} 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 {
|
||||
// In cloud deploy mode without config file, just wait for shutdown signals
|
||||
if isCloudDeploy && !configFileExists {
|
||||
// No config file available, just wait for shutdown
|
||||
cmd.WaitForCloudDeploy()
|
||||
return
|
||||
}
|
||||
// Start the main proxy service
|
||||
cmd.StartService(cfg, configFilePath)
|
||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||
cmd.StartService(cfg, configFilePath, password)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,114 @@
|
||||
# Server configuration
|
||||
# Server port
|
||||
port: 8317
|
||||
|
||||
# Management API settings
|
||||
remote-management:
|
||||
# Whether to allow remote (non-localhost) management access.
|
||||
# When false, only localhost can access management endpoints (a key is still required).
|
||||
allow-remote: false
|
||||
|
||||
# Management key. If a plaintext value is provided here, it will be hashed on startup.
|
||||
# All management requests (even from localhost) require this key.
|
||||
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
|
||||
secret-key: ""
|
||||
|
||||
# Disable the bundled management control panel asset download and HTTP route when true.
|
||||
disable-control-panel: false
|
||||
|
||||
# Authentication directory (supports ~ for home directory)
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
debug: true
|
||||
|
||||
# API keys for authentication
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# Enable debug logging
|
||||
debug: false
|
||||
|
||||
# When true, write application logs to rotating files instead of stdout
|
||||
logging-to-file: false
|
||||
|
||||
# When false, disable in-memory usage statistics aggregation
|
||||
usage-statistics-enabled: false
|
||||
|
||||
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||
proxy-url: ""
|
||||
|
||||
# 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
|
||||
|
||||
# Quota exceeded behavior
|
||||
quota-exceeded:
|
||||
switch-project: true
|
||||
switch-preview-model: true
|
||||
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
|
||||
|
||||
# API keys for client authentication
|
||||
api-keys:
|
||||
- "12345"
|
||||
- "23456"
|
||||
# When true, enable authentication for the WebSocket API (/v1/ws).
|
||||
ws-auth: false
|
||||
|
||||
# Generative language API keys
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
- "AIzaSy...02"
|
||||
- "AIzaSy...03"
|
||||
- "AIzaSy...04"
|
||||
# Gemini API keys (preferred)
|
||||
#gemini-api-key:
|
||||
# - api-key: "AIzaSy...01"
|
||||
# base-url: "https://generativelanguage.googleapis.com"
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# proxy-url: "socks5://proxy.example.com:1080"
|
||||
# - api-key: "AIzaSy...02"
|
||||
|
||||
# API keys for official Generative Language API (legacy compatibility)
|
||||
#generative-language-api-key:
|
||||
# - "AIzaSy...01"
|
||||
# - "AIzaSy...02"
|
||||
|
||||
# Codex API keys
|
||||
#codex-api-key:
|
||||
# - api-key: "sk-atSM..."
|
||||
# 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
|
||||
|
||||
# Claude API keys
|
||||
#claude-api-key:
|
||||
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||
# - api-key: "sk-atSM..."
|
||||
# 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
|
||||
# models:
|
||||
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
||||
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
||||
|
||||
# OpenAI compatibility providers
|
||||
#openai-compatibility:
|
||||
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||
# headers:
|
||||
# X-Custom-Header: "custom-value"
|
||||
# # New format with per-key proxy support (recommended):
|
||||
# api-key-entries:
|
||||
# - api-key: "sk-or-v1-...b780"
|
||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
||||
# # Legacy format (still supported, but cannot specify proxy per key):
|
||||
# # api-keys:
|
||||
# # - "sk-or-v1-...b780"
|
||||
# # - "sk-or-v1-...b781"
|
||||
# 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.
|
||||
|
||||
#payload: # Optional payload configuration
|
||||
# default: # Default rules only set parameters when they are missing in the payload.
|
||||
# - 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
|
||||
# params: # JSON path (gjson/sjson syntax) -> value
|
||||
# "generationConfig.thinkingConfig.thinkingBudget": 32768
|
||||
# override: # Override rules always set parameters, overwriting any existing values.
|
||||
# - models:
|
||||
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
|
||||
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
||||
# params: # JSON path (gjson/sjson syntax) -> value
|
||||
# "reasoning.effort": "high"
|
||||
|
||||
53
docker-build.ps1
Normal file
53
docker-build.ps1
Normal file
@@ -0,0 +1,53 @@
|
||||
# build.ps1 - Windows PowerShell Build Script
|
||||
#
|
||||
# This script automates the process of building and running the Docker container
|
||||
# with version information dynamically injected at build time.
|
||||
|
||||
# Stop script execution on any error
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Step 1: Choose Environment ---
|
||||
Write-Host "Please select an option:"
|
||||
Write-Host "1) Run using Pre-built Image (Recommended)"
|
||||
Write-Host "2) Build from Source and Run (For Developers)"
|
||||
$choice = Read-Host -Prompt "Enter choice [1-2]"
|
||||
|
||||
# --- Step 2: Execute based on choice ---
|
||||
switch ($choice) {
|
||||
"1" {
|
||||
Write-Host "--- Running with Pre-built Image ---"
|
||||
docker compose up -d --remove-orphans --no-build
|
||||
Write-Host "Services are starting from remote image."
|
||||
Write-Host "Run 'docker compose logs -f' to see the logs."
|
||||
}
|
||||
"2" {
|
||||
Write-Host "--- Building from Source and Running ---"
|
||||
|
||||
# Get Version Information
|
||||
$VERSION = (git describe --tags --always --dirty)
|
||||
$COMMIT = (git rev-parse --short HEAD)
|
||||
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
|
||||
Write-Host "Building with the following info:"
|
||||
Write-Host " Version: $VERSION"
|
||||
Write-Host " Commit: $COMMIT"
|
||||
Write-Host " Build Date: $BUILD_DATE"
|
||||
Write-Host "----------------------------------------"
|
||||
|
||||
# Build and start the services with a local-only image tag
|
||||
$env:CLI_PROXY_IMAGE = "cli-proxy-api:local"
|
||||
|
||||
Write-Host "Building the Docker image..."
|
||||
docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
|
||||
|
||||
Write-Host "Starting the services..."
|
||||
docker compose up -d --remove-orphans --pull never
|
||||
|
||||
Write-Host "Build complete. Services are starting."
|
||||
Write-Host "Run 'docker compose logs -f' to see the logs."
|
||||
}
|
||||
default {
|
||||
Write-Host "Invalid choice. Please enter 1 or 2."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
58
docker-build.sh
Normal file
58
docker-build.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# build.sh - Linux/macOS Build Script
|
||||
#
|
||||
# This script automates the process of building and running the Docker container
|
||||
# with version information dynamically injected at build time.
|
||||
|
||||
# Exit immediately if a command exits with a non-zero status.
|
||||
set -euo pipefail
|
||||
|
||||
# --- Step 1: Choose Environment ---
|
||||
echo "Please select an option:"
|
||||
echo "1) Run using Pre-built Image (Recommended)"
|
||||
echo "2) Build from Source and Run (For Developers)"
|
||||
read -r -p "Enter choice [1-2]: " choice
|
||||
|
||||
# --- Step 2: Execute based on choice ---
|
||||
case "$choice" in
|
||||
1)
|
||||
echo "--- Running with Pre-built Image ---"
|
||||
docker compose up -d --remove-orphans --no-build
|
||||
echo "Services are starting from remote image."
|
||||
echo "Run 'docker compose logs -f' to see the logs."
|
||||
;;
|
||||
2)
|
||||
echo "--- Building from Source and Running ---"
|
||||
|
||||
# Get Version Information
|
||||
VERSION="$(git describe --tags --always --dirty)"
|
||||
COMMIT="$(git rev-parse --short HEAD)"
|
||||
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
echo "Building with the following info:"
|
||||
echo " Version: ${VERSION}"
|
||||
echo " Commit: ${COMMIT}"
|
||||
echo " Build Date: ${BUILD_DATE}"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Build and start the services with a local-only image tag
|
||||
export CLI_PROXY_IMAGE="cli-proxy-api:local"
|
||||
|
||||
echo "Building the Docker image..."
|
||||
docker compose build \
|
||||
--build-arg VERSION="${VERSION}" \
|
||||
--build-arg COMMIT="${COMMIT}" \
|
||||
--build-arg BUILD_DATE="${BUILD_DATE}"
|
||||
|
||||
echo "Starting the services..."
|
||||
docker compose up -d --remove-orphans --pull never
|
||||
|
||||
echo "Build complete. Services are starting."
|
||||
echo "Run 'docker compose logs -f' to see the logs."
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice. Please enter 1 or 2."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
cli-proxy-api:
|
||||
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
|
||||
pull_policy: always
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
COMMIT: ${COMMIT:-none}
|
||||
BUILD_DATE: ${BUILD_DATE:-unknown}
|
||||
container_name: cli-proxy-api
|
||||
# env_file:
|
||||
# - .env
|
||||
environment:
|
||||
DEPLOY: ${DEPLOY:-}
|
||||
ports:
|
||||
- "8317:8317"
|
||||
- "8085:8085"
|
||||
- "1455:1455"
|
||||
- "54545:54545"
|
||||
- "11451:11451"
|
||||
volumes:
|
||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
||||
- ./auths:/root/.cli-proxy-api
|
||||
- ./logs:/CLIProxyAPI/logs
|
||||
restart: unless-stopped
|
||||
176
docs/sdk-access.md
Normal file
176
docs/sdk-access.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# @sdk/access SDK Reference
|
||||
|
||||
The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime.
|
||||
|
||||
## Importing
|
||||
|
||||
```go
|
||||
import (
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
```
|
||||
|
||||
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
|
||||
|
||||
## Manager Lifecycle
|
||||
|
||||
```go
|
||||
manager := sdkaccess.NewManager()
|
||||
providers, err := sdkaccess.BuildProviders(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.SetProviders(providers)
|
||||
```
|
||||
|
||||
* `NewManager` constructs an empty manager.
|
||||
* `SetProviders` replaces the provider slice using a defensive copy.
|
||||
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
|
||||
* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider.
|
||||
|
||||
## Authenticating Requests
|
||||
|
||||
```go
|
||||
result, err := manager.Authenticate(ctx, req)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Authentication succeeded; result describes the provider and principal.
|
||||
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
||||
// No recognizable credentials were supplied.
|
||||
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
||||
// Supplied credentials were present but rejected.
|
||||
default:
|
||||
// Transport-level failure was returned by a provider.
|
||||
}
|
||||
```
|
||||
|
||||
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting.
|
||||
|
||||
If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors.
|
||||
|
||||
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
|
||||
|
||||
## Configuration Layout
|
||||
|
||||
The manager expects access providers under the `auth.providers` key inside `config.yaml`:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
- name: inline-api
|
||||
type: config-api-key
|
||||
api-keys:
|
||||
- sk-test-123
|
||||
- sk-prod-456
|
||||
```
|
||||
|
||||
Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options.
|
||||
|
||||
### Loading providers from external SDK modules
|
||||
|
||||
To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
- name: partner-auth
|
||||
type: partner-token
|
||||
sdk: github.com/acme/xplatform/sdk/access/providers/partner
|
||||
config:
|
||||
region: us-west-2
|
||||
audience: cli-proxy
|
||||
```
|
||||
|
||||
```go
|
||||
import (
|
||||
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
)
|
||||
```
|
||||
|
||||
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
|
||||
|
||||
## Built-in Providers
|
||||
|
||||
The SDK ships with one provider out of the box:
|
||||
|
||||
- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found.
|
||||
|
||||
Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
|
||||
|
||||
### Metadata and auditing
|
||||
|
||||
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing.
|
||||
|
||||
## Writing Custom Providers
|
||||
|
||||
```go
|
||||
type customProvider struct{}
|
||||
|
||||
func (p *customProvider) Identifier() string { return "my-provider" }
|
||||
|
||||
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
||||
token := r.Header.Get("X-Custom")
|
||||
if token == "" {
|
||||
return nil, sdkaccess.ErrNoCredentials
|
||||
}
|
||||
if token != "expected" {
|
||||
return nil, sdkaccess.ErrInvalidCredential
|
||||
}
|
||||
return &sdkaccess.Result{
|
||||
Provider: p.Identifier(),
|
||||
Principal: "service-user",
|
||||
Metadata: map[string]string{"source": "x-custom"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
||||
return &customProvider{}, nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs.
|
||||
|
||||
## Error Semantics
|
||||
|
||||
- `ErrNoCredentials`: no credentials were present or recognized by any provider.
|
||||
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
|
||||
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
|
||||
|
||||
Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked.
|
||||
|
||||
## Integration with cliproxy Service
|
||||
|
||||
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers:
|
||||
|
||||
```go
|
||||
coreCfg, _ := config.LoadConfig("config.yaml")
|
||||
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
||||
manager := sdkaccess.NewManager()
|
||||
manager.SetProviders(providers)
|
||||
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(coreCfg).
|
||||
WithAccessManager(manager).
|
||||
Build()
|
||||
```
|
||||
|
||||
The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary.
|
||||
|
||||
### Hot reloading providers
|
||||
|
||||
When configuration changes, rebuild providers and swap them into the manager:
|
||||
|
||||
```go
|
||||
providers, err := sdkaccess.BuildProviders(newCfg)
|
||||
if err != nil {
|
||||
log.Errorf("reload auth providers failed: %v", err)
|
||||
return
|
||||
}
|
||||
accessManager.SetProviders(providers)
|
||||
```
|
||||
|
||||
This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process.
|
||||
176
docs/sdk-access_CN.md
Normal file
176
docs/sdk-access_CN.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# @sdk/access 开发指引
|
||||
|
||||
`github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。
|
||||
|
||||
## 引用方式
|
||||
|
||||
```go
|
||||
import (
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
```
|
||||
|
||||
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
|
||||
|
||||
## 管理器生命周期
|
||||
|
||||
```go
|
||||
manager := sdkaccess.NewManager()
|
||||
providers, err := sdkaccess.BuildProviders(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.SetProviders(providers)
|
||||
```
|
||||
|
||||
- `NewManager` 创建空管理器。
|
||||
- `SetProviders` 替换提供者切片并做防御性拷贝。
|
||||
- `Providers` 返回适合并发读取的快照。
|
||||
- `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
|
||||
|
||||
## 认证请求
|
||||
|
||||
```go
|
||||
result, err := manager.Authenticate(ctx, req)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Authentication succeeded; result carries provider and principal.
|
||||
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
||||
// No recognizable credentials were supplied.
|
||||
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
||||
// Credentials were present but rejected.
|
||||
default:
|
||||
// Provider surfaced a transport-level failure.
|
||||
}
|
||||
```
|
||||
|
||||
`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。
|
||||
|
||||
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
|
||||
|
||||
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
|
||||
|
||||
## 配置结构
|
||||
|
||||
在 `config.yaml` 的 `auth.providers` 下定义访问提供者:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
- name: inline-api
|
||||
type: config-api-key
|
||||
api-keys:
|
||||
- sk-test-123
|
||||
- sk-prod-456
|
||||
```
|
||||
|
||||
条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
|
||||
|
||||
### 引入外部 SDK 提供者
|
||||
|
||||
若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
- name: partner-auth
|
||||
type: partner-token
|
||||
sdk: github.com/acme/xplatform/sdk/access/providers/partner
|
||||
config:
|
||||
region: us-west-2
|
||||
audience: cli-proxy
|
||||
```
|
||||
|
||||
```go
|
||||
import (
|
||||
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
)
|
||||
```
|
||||
|
||||
通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。
|
||||
|
||||
## 内建提供者
|
||||
|
||||
当前 SDK 默认内置:
|
||||
|
||||
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。
|
||||
|
||||
导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
|
||||
|
||||
### 元数据与审计
|
||||
|
||||
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key` 或 `query-key`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。
|
||||
|
||||
## 编写自定义提供者
|
||||
|
||||
```go
|
||||
type customProvider struct{}
|
||||
|
||||
func (p *customProvider) Identifier() string { return "my-provider" }
|
||||
|
||||
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
||||
token := r.Header.Get("X-Custom")
|
||||
if token == "" {
|
||||
return nil, sdkaccess.ErrNoCredentials
|
||||
}
|
||||
if token != "expected" {
|
||||
return nil, sdkaccess.ErrInvalidCredential
|
||||
}
|
||||
return &sdkaccess.Result{
|
||||
Provider: p.Identifier(),
|
||||
Principal: "service-user",
|
||||
Metadata: map[string]string{"source": "x-custom"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
||||
return &customProvider{}, nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。
|
||||
|
||||
## 错误语义
|
||||
|
||||
- `ErrNoCredentials`:任何提供者都未识别到凭证。
|
||||
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
|
||||
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。
|
||||
|
||||
自定义错误(例如网络异常)会马上冒泡返回。
|
||||
|
||||
## 与 cliproxy 集成
|
||||
|
||||
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
|
||||
|
||||
```go
|
||||
coreCfg, _ := config.LoadConfig("config.yaml")
|
||||
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
||||
manager := sdkaccess.NewManager()
|
||||
manager.SetProviders(providers)
|
||||
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(coreCfg).
|
||||
WithAccessManager(manager).
|
||||
Build()
|
||||
```
|
||||
|
||||
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。
|
||||
|
||||
### 动态热更新提供者
|
||||
|
||||
当配置发生变化时,可以重新构建提供者并替换当前列表:
|
||||
|
||||
```go
|
||||
providers, err := sdkaccess.BuildProviders(newCfg)
|
||||
if err != nil {
|
||||
log.Errorf("reload auth providers failed: %v", err)
|
||||
return
|
||||
}
|
||||
accessManager.SetProviders(providers)
|
||||
```
|
||||
|
||||
这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。
|
||||
138
docs/sdk-advanced.md
Normal file
138
docs/sdk-advanced.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# SDK Advanced: Executors & Translators
|
||||
|
||||
This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
|
||||
- Implement a provider executor that talks to your upstream API
|
||||
- Register request/response translators for schema conversion
|
||||
- Register models so they appear in `/v1/models`
|
||||
|
||||
The examples use Go 1.24+ and the v6 module path.
|
||||
|
||||
## Concepts
|
||||
|
||||
- Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests.
|
||||
- Translator registry: schema conversion functions routed by `sdk/translator`. The built‑in handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones.
|
||||
- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
|
||||
|
||||
## 1) Implement a Provider Executor
|
||||
|
||||
Create a type that satisfies `auth.ProviderExecutor`.
|
||||
|
||||
```go
|
||||
package myprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
)
|
||||
|
||||
type Executor struct{}
|
||||
|
||||
func (Executor) Identifier() string { return "myprov" }
|
||||
|
||||
// Optional: mutate outbound HTTP requests with credentials
|
||||
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||
// Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||
// Build HTTP request based on req.Payload (already translated into provider format)
|
||||
// Use per‑auth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
|
||||
// Perform call and return provider JSON payload
|
||||
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
||||
}
|
||||
|
||||
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||
ch := make(chan clipexec.StreamChunk, 1)
|
||||
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
||||
// Optionally refresh tokens and return updated auth
|
||||
return a, nil
|
||||
}
|
||||
```
|
||||
|
||||
Register the executor with the core manager before starting the service:
|
||||
|
||||
```go
|
||||
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||
core.RegisterExecutor(myprov.Executor{})
|
||||
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
||||
```
|
||||
|
||||
If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
|
||||
|
||||
## 2) Register Translators
|
||||
|
||||
The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`’s default registry.
|
||||
|
||||
Direction matters:
|
||||
- Request: register from inbound schema to provider schema
|
||||
- Response: register from provider schema back to inbound schema
|
||||
|
||||
Example: Convert OpenAI Chat → MyProv Chat and back.
|
||||
|
||||
```go
|
||||
package myprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
)
|
||||
|
||||
const (
|
||||
FOpenAI = sdktr.Format("openai.chat")
|
||||
FMyProv = sdktr.Format("myprov.chat")
|
||||
)
|
||||
|
||||
func init() {
|
||||
sdktr.Register(FOpenAI, FMyProv,
|
||||
// Request transform (model, rawJSON, stream)
|
||||
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
||||
// Response transform (stream & non‑stream)
|
||||
sdktr.ResponseTransform{
|
||||
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||
},
|
||||
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
|
||||
|
||||
## 3) Register Models
|
||||
|
||||
Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
|
||||
|
||||
```go
|
||||
models := []*cliproxy.ModelInfo{
|
||||
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
||||
}
|
||||
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
||||
```
|
||||
|
||||
The embedded server calls this automatically for built‑in providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks.
|
||||
|
||||
## Credentials & Transports
|
||||
|
||||
- Use `Manager.SetRoundTripperProvider` to inject per‑auth `*http.Transport` (e.g., proxy):
|
||||
```go
|
||||
core.SetRoundTripperProvider(myProvider) // returns transport per auth
|
||||
```
|
||||
- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
|
||||
|
||||
## Testing Tips
|
||||
|
||||
- Enable request logging: Management API GET/PUT `/v0/management/request-log`
|
||||
- Toggle debug logs: Management API GET/PUT `/v0/management/debug`
|
||||
- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher
|
||||
|
||||
131
docs/sdk-advanced_CN.md
Normal file
131
docs/sdk-advanced_CN.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# SDK 高级指南:执行器与翻译器
|
||||
|
||||
本文介绍如何使用 SDK 扩展内嵌代理:
|
||||
- 实现自定义 Provider 执行器以调用你的上游 API
|
||||
- 注册请求/响应翻译器进行协议转换
|
||||
- 注册模型以出现在 `/v1/models`
|
||||
|
||||
示例基于 Go 1.24+ 与 v6 模块路径。
|
||||
|
||||
## 概念
|
||||
|
||||
- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key(如 `gemini`、`claude`、`codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
|
||||
- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
|
||||
- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
|
||||
|
||||
## 1) 实现 Provider 执行器
|
||||
|
||||
创建类型满足 `auth.ProviderExecutor` 接口。
|
||||
|
||||
```go
|
||||
package myprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
)
|
||||
|
||||
type Executor struct{}
|
||||
|
||||
func (Executor) Identifier() string { return "myprov" }
|
||||
|
||||
// 可选:在原始 HTTP 请求上注入凭据
|
||||
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||
// 例如:req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||
// 基于 req.Payload 构造上游请求,返回上游 JSON 负载
|
||||
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
|
||||
}
|
||||
|
||||
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||
ch := make(chan clipexec.StreamChunk, 1)
|
||||
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
|
||||
```
|
||||
|
||||
在启动服务前将执行器注册到核心管理器:
|
||||
|
||||
```go
|
||||
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||
core.RegisterExecutor(myprov.Executor{})
|
||||
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
|
||||
```
|
||||
|
||||
当凭据的 `Provider` 为 `"myprov"` 时,管理器会将请求路由到你的执行器。
|
||||
|
||||
## 2) 注册翻译器
|
||||
|
||||
内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
|
||||
|
||||
方向很重要:
|
||||
- 请求:从“入站格式”转换为“provider 格式”
|
||||
- 响应:从“provider 格式”转换回“入站格式”
|
||||
|
||||
示例:OpenAI Chat → MyProv Chat 及其反向。
|
||||
|
||||
```go
|
||||
package myprov
|
||||
|
||||
import (
|
||||
"context"
|
||||
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
)
|
||||
|
||||
const (
|
||||
FOpenAI = sdktr.Format("openai.chat")
|
||||
FMyProv = sdktr.Format("myprov.chat")
|
||||
)
|
||||
|
||||
func init() {
|
||||
sdktr.Register(FOpenAI, FMyProv,
|
||||
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
|
||||
sdktr.ResponseTransform{
|
||||
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
|
||||
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||
},
|
||||
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
|
||||
|
||||
## 3) 注册模型
|
||||
|
||||
通过全局模型注册表将模型暴露到 `/v1/models`:
|
||||
|
||||
```go
|
||||
models := []*cliproxy.ModelInfo{
|
||||
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
|
||||
}
|
||||
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
|
||||
```
|
||||
|
||||
内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
|
||||
|
||||
## 凭据与传输
|
||||
|
||||
- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
|
||||
```go
|
||||
core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
|
||||
```
|
||||
- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
|
||||
|
||||
## 测试建议
|
||||
|
||||
- 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
|
||||
- 切换调试日志:管理 API GET/PUT `/v0/management/debug`
|
||||
- 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用
|
||||
|
||||
163
docs/sdk-usage.md
Normal file
163
docs/sdk-usage.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# CLI Proxy SDK Guide
|
||||
|
||||
The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hot‑reload, and translation layers without depending on the CLI binary.
|
||||
|
||||
## Install & Import
|
||||
|
||||
```bash
|
||||
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
||||
```
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||
)
|
||||
```
|
||||
|
||||
Note the `/v6` module path.
|
||||
|
||||
## Minimal Embed
|
||||
|
||||
```go
|
||||
cfg, err := config.LoadConfig("config.yaml")
|
||||
if err != nil { panic(err) }
|
||||
|
||||
svc, err := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml"). // absolute or working-dir relative
|
||||
Build()
|
||||
if err != nil { panic(err) }
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
|
||||
|
||||
## Server Options (middleware, routes, logs)
|
||||
|
||||
The server accepts options via `WithServerOptions`:
|
||||
|
||||
```go
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithServerOptions(
|
||||
// Add global middleware
|
||||
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
||||
// Tweak gin engine early (CORS, trusted proxies, etc.)
|
||||
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
||||
// Add your own routes after defaults
|
||||
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
||||
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
||||
}),
|
||||
// Override request log writer/dir
|
||||
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||
}),
|
||||
).
|
||||
Build()
|
||||
```
|
||||
|
||||
These options mirror the internals used by the CLI server.
|
||||
|
||||
## Management API (when embedded)
|
||||
|
||||
- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
|
||||
- Remote access additionally requires `remote-management.allow-remote: true`.
|
||||
- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
|
||||
|
||||
## Using the Core Auth Manager
|
||||
|
||||
The service uses a core `auth.Manager` for selection, execution, and auto‑refresh. When embedding, you can provide your own manager to customize transports or hooks:
|
||||
|
||||
```go
|
||||
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||
core.SetRoundTripperProvider(myRTProvider) // per‑auth *http.Transport
|
||||
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithCoreAuthManager(core).
|
||||
Build()
|
||||
```
|
||||
|
||||
Implement a custom per‑auth transport:
|
||||
|
||||
```go
|
||||
type myRTProvider struct{}
|
||||
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
||||
if a == nil || a.ProxyURL == "" { return nil }
|
||||
u, _ := url.Parse(a.ProxyURL)
|
||||
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
||||
}
|
||||
```
|
||||
|
||||
Programmatic execution is available on the manager:
|
||||
|
||||
```go
|
||||
// Non‑streaming
|
||||
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
||||
|
||||
// Streaming
|
||||
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
||||
for ch := range chunks { /* ... */ }
|
||||
```
|
||||
|
||||
Note: Built‑in provider executors are wired automatically when you run the `Service`. If you want to use `Manager` stand‑alone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`.
|
||||
|
||||
## Custom Client Sources
|
||||
|
||||
Replace the default loaders if your creds live outside the local filesystem:
|
||||
|
||||
```go
|
||||
type memoryTokenProvider struct{}
|
||||
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
||||
// Populate from memory/remote store and return counts
|
||||
return &cliproxy.TokenClientResult{}, nil
|
||||
}
|
||||
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithTokenClientProvider(&memoryTokenProvider{}).
|
||||
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
||||
Build()
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
Observe lifecycle without patching internals:
|
||||
|
||||
```go
|
||||
hooks := cliproxy.Hooks{
|
||||
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
||||
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
||||
}
|
||||
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
||||
```
|
||||
|
||||
## Shutdown
|
||||
|
||||
`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = svc.Shutdown(ctx)
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
|
||||
- Request logging can be toggled at runtime via the Management API.
|
||||
- Gemini Web features (`gemini-web.*`) are honored in the embedded server.
|
||||
164
docs/sdk-usage_CN.md
Normal file
164
docs/sdk-usage_CN.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# CLI Proxy SDK 使用指南
|
||||
|
||||
`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
|
||||
|
||||
## 安装与导入
|
||||
|
||||
```bash
|
||||
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
|
||||
```
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||
)
|
||||
```
|
||||
|
||||
注意模块路径包含 `/v6`。
|
||||
|
||||
## 最小可用示例
|
||||
|
||||
```go
|
||||
cfg, err := config.LoadConfig("config.yaml")
|
||||
if err != nil { panic(err) }
|
||||
|
||||
svc, err := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
|
||||
Build()
|
||||
if err != nil { panic(err) }
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
|
||||
|
||||
## 服务器可选项(中间件、路由、日志)
|
||||
|
||||
通过 `WithServerOptions` 自定义:
|
||||
|
||||
```go
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithServerOptions(
|
||||
// 追加全局中间件
|
||||
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
|
||||
// 提前调整 gin 引擎(如 CORS、trusted proxies)
|
||||
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
|
||||
// 在默认路由之后追加自定义路由
|
||||
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
|
||||
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
|
||||
}),
|
||||
// 覆盖请求日志的创建(启用/目录)
|
||||
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||
}),
|
||||
).
|
||||
Build()
|
||||
```
|
||||
|
||||
这些选项与 CLI 服务器内部用法保持一致。
|
||||
|
||||
## 管理 API(内嵌时)
|
||||
|
||||
- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
|
||||
- 远程访问还需要 `remote-management.allow-remote: true`。
|
||||
- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`。
|
||||
|
||||
## 使用核心鉴权管理器
|
||||
|
||||
服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
|
||||
|
||||
```go
|
||||
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
|
||||
core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
|
||||
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithCoreAuthManager(core).
|
||||
Build()
|
||||
```
|
||||
|
||||
实现每个账户的自定义传输:
|
||||
|
||||
```go
|
||||
type myRTProvider struct{}
|
||||
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
|
||||
if a == nil || a.ProxyURL == "" { return nil }
|
||||
u, _ := url.Parse(a.ProxyURL)
|
||||
return &http.Transport{ Proxy: http.ProxyURL(u) }
|
||||
}
|
||||
```
|
||||
|
||||
管理器提供编程式执行接口:
|
||||
|
||||
```go
|
||||
// 非流式
|
||||
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
|
||||
|
||||
// 流式
|
||||
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
|
||||
for ch := range chunks { /* ... */ }
|
||||
```
|
||||
|
||||
说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
|
||||
|
||||
## 自定义凭据来源
|
||||
|
||||
当凭据不在本地文件系统时,替换默认加载器:
|
||||
|
||||
```go
|
||||
type memoryTokenProvider struct{}
|
||||
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
|
||||
// 从内存/远端加载并返回数量统计
|
||||
return &cliproxy.TokenClientResult{}, nil
|
||||
}
|
||||
|
||||
svc, _ := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithTokenClientProvider(&memoryTokenProvider{}).
|
||||
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
|
||||
Build()
|
||||
```
|
||||
|
||||
## 启动钩子
|
||||
|
||||
无需修改内部代码即可观察生命周期:
|
||||
|
||||
```go
|
||||
hooks := cliproxy.Hooks{
|
||||
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
|
||||
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
|
||||
}
|
||||
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
|
||||
```
|
||||
|
||||
## 关闭
|
||||
|
||||
`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = svc.Shutdown(ctx)
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
- 热更新:`config.yaml` 与 `auths/` 变化会被自动侦测并应用。
|
||||
- 请求日志可通过管理 API 在运行时开关。
|
||||
- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。
|
||||
|
||||
32
docs/sdk-watcher.md
Normal file
32
docs/sdk-watcher.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# SDK Watcher Integration
|
||||
|
||||
The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled.
|
||||
|
||||
## Update Queue Contract
|
||||
|
||||
- `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`.
|
||||
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts.
|
||||
- The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop.
|
||||
|
||||
## Watcher Behaviour
|
||||
|
||||
- `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals.
|
||||
- Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream.
|
||||
- The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly.
|
||||
|
||||
## High-Frequency Change Handling
|
||||
|
||||
- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
|
||||
- Back-pressure is absorbed in two places:
|
||||
- The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
|
||||
- The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
|
||||
- If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states.
|
||||
|
||||
## Usage Checklist
|
||||
|
||||
1. Instantiate the SDK service (builder or manual construction).
|
||||
2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
|
||||
3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
|
||||
4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`.
|
||||
|
||||
Following this flow keeps auth changes responsive while avoiding full reloads for every edit.
|
||||
32
docs/sdk-watcher_CN.md
Normal file
32
docs/sdk-watcher_CN.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# SDK Watcher集成说明
|
||||
|
||||
本文档介绍SDK服务与文件监控器之间的增量更新队列,包括接口契约、高频变更下的处理策略以及接入步骤。
|
||||
|
||||
## 更新队列契约
|
||||
|
||||
- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add`、`modify`或`delete`,`ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`。
|
||||
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher,必须在watcher启动前完成。
|
||||
- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道,并在`consumeAuthUpdates`中使用专职goroutine消费;消费侧会主动“抽干”积压事件,降低切换开销。
|
||||
|
||||
## Watcher行为
|
||||
|
||||
- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
|
||||
- 以凭据ID为维度对更新进行合并,同一凭据在短时间内的多次变更只会保留最新状态(例如先写后删只会下发`delete`)。
|
||||
- watcher内部运行异步分发循环:生产者只向内存缓冲追加事件并唤醒分发协程,即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环,确保协程正常退出。
|
||||
|
||||
## 高频变更处理
|
||||
|
||||
- 分发循环与服务消费协程相互独立,因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
|
||||
- 背压通过两级缓冲吸收:
|
||||
- 分发缓冲(map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
|
||||
- 服务端通道的256容量加上消费侧的“抽干”逻辑,可平稳处理多个突发批次。
|
||||
- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
|
||||
|
||||
## 接入步骤
|
||||
|
||||
1. 实例化SDK Service(构建器或手工创建)。
|
||||
2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
|
||||
3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道,然后再启动watcher。
|
||||
4. Reload回调专注于配置更新;认证增量会通过队列送达,并由`handleAuthUpdate`自动应用。
|
||||
|
||||
遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。
|
||||
207
examples/custom-provider/main.go
Normal file
207
examples/custom-provider/main.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Package main demonstrates how to create a custom AI provider executor
|
||||
// and integrate it with the CLI Proxy API server. This example shows how to:
|
||||
// - Create a custom executor that implements the Executor interface
|
||||
// - Register custom translators for request/response transformation
|
||||
// - Integrate the custom provider with the SDK server
|
||||
// - Register custom models in the model registry
|
||||
//
|
||||
// This example uses a simple echo service (httpbin.org) as the upstream API
|
||||
// for demonstration purposes. In a real implementation, you would replace
|
||||
// this with your actual AI service provider.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
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"
|
||||
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
)
|
||||
|
||||
const (
|
||||
// providerKey is the identifier for our custom provider.
|
||||
providerKey = "myprov"
|
||||
|
||||
// fOpenAI represents the OpenAI chat format.
|
||||
fOpenAI = sdktr.Format("openai.chat")
|
||||
|
||||
// fMyProv represents our custom provider's chat format.
|
||||
fMyProv = sdktr.Format("myprov.chat")
|
||||
)
|
||||
|
||||
// init registers trivial translators for demonstration purposes.
|
||||
// In a real implementation, you would implement proper request/response
|
||||
// transformation logic between OpenAI format and your provider's format.
|
||||
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)}
|
||||
},
|
||||
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
|
||||
return string(raw)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// MyExecutor is a minimal provider implementation for demonstration purposes.
|
||||
// It implements the Executor interface to handle requests to a custom AI provider.
|
||||
type MyExecutor struct{}
|
||||
|
||||
// Identifier returns the unique identifier for this executor.
|
||||
func (MyExecutor) Identifier() string { return providerKey }
|
||||
|
||||
// PrepareRequest optionally injects credentials to raw HTTP requests.
|
||||
// This method is called before each request to allow the executor to modify
|
||||
// the HTTP request with authentication headers or other necessary modifications.
|
||||
//
|
||||
// Parameters:
|
||||
// - req: The HTTP request to prepare
|
||||
// - a: The authentication information
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if request preparation fails
|
||||
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
|
||||
if req == nil || a == nil {
|
||||
return nil
|
||||
}
|
||||
if a.Attributes != nil {
|
||||
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+ak)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildHTTPClient(a *coreauth.Auth) *http.Client {
|
||||
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
|
||||
return http.DefaultClient
|
||||
}
|
||||
u, err := url.Parse(a.ProxyURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return http.DefaultClient
|
||||
}
|
||||
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
|
||||
}
|
||||
|
||||
func upstreamEndpoint(a *coreauth.Auth) string {
|
||||
if a != nil && a.Attributes != nil {
|
||||
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
|
||||
return ep
|
||||
}
|
||||
}
|
||||
// Demo echo endpoint; replace with your upstream.
|
||||
return "https://httpbin.org/post"
|
||||
}
|
||||
|
||||
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||
client := buildHTTPClient(a)
|
||||
endpoint := upstreamEndpoint(a)
|
||||
|
||||
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
|
||||
if errNew != nil {
|
||||
return clipexec.Response{}, errNew
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Inject credentials via PrepareRequest hook.
|
||||
_ = (MyExecutor{}).PrepareRequest(httpReq, a)
|
||||
|
||||
resp, errDo := client.Do(httpReq)
|
||||
if errDo != nil {
|
||||
return clipexec.Response{}, errDo
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
// Best-effort close; log if needed in real projects.
|
||||
}
|
||||
}()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return clipexec.Response{Payload: body}, nil
|
||||
}
|
||||
|
||||
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
|
||||
ch := make(chan clipexec.StreamChunk, 1)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (MyExecutor) CountTokens(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
|
||||
return clipexec.Response{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.LoadConfig("config.yaml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tokenStore := sdkAuth.GetTokenStore()
|
||||
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
core := coreauth.NewManager(tokenStore, nil, nil)
|
||||
core.RegisterExecutor(MyExecutor{})
|
||||
|
||||
hooks := cliproxy.Hooks{
|
||||
OnAfterStart: func(s *cliproxy.Service) {
|
||||
// Register demo models for the custom provider so they appear in /v1/models.
|
||||
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
|
||||
for _, a := range core.List() {
|
||||
if strings.EqualFold(a.Provider, providerKey) {
|
||||
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
svc, err := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath("config.yaml").
|
||||
WithCoreAuthManager(core).
|
||||
WithServerOptions(
|
||||
// Optional: add a simple middleware + custom request logger
|
||||
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
|
||||
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
|
||||
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
|
||||
}),
|
||||
).
|
||||
WithHooks(hooks).
|
||||
Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.Stderr // keep os import used (demo only)
|
||||
_ = time.Second
|
||||
}
|
||||
42
examples/translator/main.go
Normal file
42
examples/translator/main.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rawRequest := []byte(`{"messages":[{"content":[{"text":"Hello! Gemini","type":"text"}],"role":"user"}],"model":"gemini-2.5-pro","stream":false}`)
|
||||
fmt.Println("Has gemini->openai response translator:", translator.HasResponseTransformerByFormatName(
|
||||
translator.FormatGemini,
|
||||
translator.FormatOpenAI,
|
||||
))
|
||||
|
||||
translatedRequest := translator.TranslateRequestByFormatName(
|
||||
translator.FormatOpenAI,
|
||||
translator.FormatGemini,
|
||||
"gemini-2.5-pro",
|
||||
rawRequest,
|
||||
false,
|
||||
)
|
||||
|
||||
fmt.Printf("Translated request to Gemini format:\n%s\n\n", translatedRequest)
|
||||
|
||||
claudeResponse := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"Okay, here's what's going through my mind. I need to schedule a meeting"},{"thoughtSignature":"","functionCall":{"name":"schedule_meeting","args":{"topic":"Q3 planning","attendees":["Bob","Alice"],"time":"10:00","date":"2025-03-27"}}}]},"finishReason":"STOP","avgLogprobs":-0.50018133435930523}],"usageMetadata":{"promptTokenCount":117,"candidatesTokenCount":28,"totalTokenCount":474,"trafficType":"PROVISIONED_THROUGHPUT","promptTokensDetails":[{"modality":"TEXT","tokenCount":117}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":28}],"thoughtsTokenCount":329},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T04:12:55.249090Z","responseId":"x7OeaIKaD6CU48APvNXDyA4"}`)
|
||||
|
||||
convertedResponse := translator.TranslateNonStreamByFormatName(
|
||||
context.Background(),
|
||||
translator.FormatGemini,
|
||||
translator.FormatOpenAI,
|
||||
"gemini-2.5-pro",
|
||||
rawRequest,
|
||||
translatedRequest,
|
||||
claudeResponse,
|
||||
nil,
|
||||
)
|
||||
|
||||
fmt.Printf("Converted response for OpenAI clients:\n%s\n", convertedResponse)
|
||||
}
|
||||
44
go.mod
44
go.mod
@@ -1,46 +1,76 @@
|
||||
module github.com/luispater/CLIProxyAPI
|
||||
module github.com/router-for-me/CLIProxyAPI/v6
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.17.4
|
||||
github.com/minio/minio-go/v7 v7.0.66
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317
|
||||
github.com/tiktoken-go/tokenizer v0.7.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
112
go.sum
112
go.sum
@@ -1,16 +1,38 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
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/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
@@ -19,6 +41,16 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -29,21 +61,52 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
|
||||
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -51,8 +114,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
@@ -62,13 +133,15 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -78,6 +151,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tiktoken-go/tokenizer v0.7.0 h1:VMu6MPT0bXFDHr7UPh9uii7CNItVt3X9K90omxL54vw=
|
||||
github.com/tiktoken-go/tokenizer v0.7.0/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -85,25 +160,36 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
||||
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=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 h1:wneCP+2d9NUmndnyTmY7VwUNYiP26xiN/AtdcojQ1lI=
|
||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
112
internal/access/config_access/provider.go
Normal file
112
internal/access/config_access/provider.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package configaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
)
|
||||
|
||||
var registerOnce sync.Once
|
||||
|
||||
// Register ensures the config-access provider is available to the access manager.
|
||||
func Register() {
|
||||
registerOnce.Do(func() {
|
||||
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
|
||||
})
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
name string
|
||||
keys map[string]struct{}
|
||||
}
|
||||
|
||||
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
|
||||
name := cfg.Name
|
||||
if name == "" {
|
||||
name = sdkconfig.DefaultAccessProviderName
|
||||
}
|
||||
keys := make(map[string]struct{}, len(cfg.APIKeys))
|
||||
for _, key := range cfg.APIKeys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
return &provider{name: name, keys: keys}, nil
|
||||
}
|
||||
|
||||
func (p *provider) Identifier() string {
|
||||
if p == nil || p.name == "" {
|
||||
return sdkconfig.DefaultAccessProviderName
|
||||
}
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) {
|
||||
if p == nil {
|
||||
return nil, sdkaccess.ErrNotHandled
|
||||
}
|
||||
if len(p.keys) == 0 {
|
||||
return nil, sdkaccess.ErrNotHandled
|
||||
}
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
|
||||
authHeaderAnthropic := r.Header.Get("X-Api-Key")
|
||||
queryKey := ""
|
||||
queryAuthToken := ""
|
||||
if r.URL != nil {
|
||||
queryKey = r.URL.Query().Get("key")
|
||||
queryAuthToken = r.URL.Query().Get("auth_token")
|
||||
}
|
||||
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
|
||||
return nil, sdkaccess.ErrNoCredentials
|
||||
}
|
||||
|
||||
apiKey := extractBearerToken(authHeader)
|
||||
|
||||
candidates := []struct {
|
||||
value string
|
||||
source string
|
||||
}{
|
||||
{apiKey, "authorization"},
|
||||
{authHeaderGoogle, "x-goog-api-key"},
|
||||
{authHeaderAnthropic, "x-api-key"},
|
||||
{queryKey, "query-key"},
|
||||
{queryAuthToken, "query-auth-token"},
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate.value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := p.keys[candidate.value]; ok {
|
||||
return &sdkaccess.Result{
|
||||
Provider: p.Identifier(),
|
||||
Principal: candidate.value,
|
||||
Metadata: map[string]string{
|
||||
"source": candidate.source,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, sdkaccess.ErrInvalidCredential
|
||||
}
|
||||
|
||||
func extractBearerToken(header string) string {
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return header
|
||||
}
|
||||
if strings.ToLower(parts[0]) != "bearer" {
|
||||
return header
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
270
internal/access/reconcile.go
Normal file
270
internal/access/reconcile.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ReconcileProviders builds the desired provider list by reusing existing providers when possible
|
||||
// and creating or removing providers only when their configuration changed. It returns the final
|
||||
// ordered provider slice along with the identifiers of providers that were added, updated, or
|
||||
// removed compared to the previous configuration.
|
||||
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
|
||||
if newCfg == nil {
|
||||
return nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
existingMap := make(map[string]sdkaccess.Provider, len(existing))
|
||||
for _, provider := range existing {
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
existingMap[provider.Identifier()] = provider
|
||||
}
|
||||
|
||||
oldCfgMap := accessProviderMap(oldCfg)
|
||||
newEntries := collectProviderEntries(newCfg)
|
||||
|
||||
result = make([]sdkaccess.Provider, 0, len(newEntries))
|
||||
finalIDs := make(map[string]struct{}, len(newEntries))
|
||||
|
||||
isInlineProvider := func(id string) bool {
|
||||
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
|
||||
}
|
||||
appendChange := func(list *[]string, id string) {
|
||||
if isInlineProvider(id) {
|
||||
return
|
||||
}
|
||||
*list = append(*list, id)
|
||||
}
|
||||
|
||||
for _, providerCfg := range newEntries {
|
||||
key := providerIdentifier(providerCfg)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
|
||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
||||
isAliased := oldCfgProvider == providerCfg
|
||||
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
||||
result = append(result, existingProvider)
|
||||
finalIDs[key] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, ok := oldCfgMap[key]; ok {
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
finalIDs[key] = struct{}{}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
|
||||
key := providerIdentifier(inline)
|
||||
if key != "" {
|
||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
||||
if providerConfigEqual(oldCfgProvider, inline) {
|
||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
||||
result = append(result, existingProvider)
|
||||
finalIDs[key] = struct{}{}
|
||||
goto inlineDone
|
||||
}
|
||||
}
|
||||
}
|
||||
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else if _, hadOld := oldCfgMap[key]; hadOld {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
finalIDs[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
inlineDone:
|
||||
}
|
||||
|
||||
removedSet := make(map[string]struct{})
|
||||
for id := range existingMap {
|
||||
if _, ok := finalIDs[id]; !ok {
|
||||
if isInlineProvider(id) {
|
||||
continue
|
||||
}
|
||||
removedSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
removed = make([]string, 0, len(removedSet))
|
||||
for id := range removedSet {
|
||||
removed = append(removed, id)
|
||||
}
|
||||
|
||||
sort.Strings(added)
|
||||
sort.Strings(updated)
|
||||
sort.Strings(removed)
|
||||
|
||||
return result, added, updated, removed, nil
|
||||
}
|
||||
|
||||
// ApplyAccessProviders reconciles the configured access providers against the
|
||||
// currently registered providers and updates the manager. It logs a concise
|
||||
// summary of the detected changes and returns whether any provider changed.
|
||||
func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {
|
||||
if manager == nil || newCfg == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
existing := manager.Providers()
|
||||
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
|
||||
if err != nil {
|
||||
log.Errorf("failed to reconcile request auth providers: %v", err)
|
||||
return false, fmt.Errorf("reconciling access providers: %w", err)
|
||||
}
|
||||
|
||||
manager.SetProviders(providers)
|
||||
|
||||
if len(added)+len(updated)+len(removed) > 0 {
|
||||
log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
|
||||
log.Debugf("auth providers changes details - added=%v updated=%v removed=%v", added, updated, removed)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
log.Debug("auth providers unchanged after config update")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
|
||||
result := make(map[string]*sdkConfig.AccessProvider)
|
||||
if cfg == nil {
|
||||
return result
|
||||
}
|
||||
for i := range cfg.Access.Providers {
|
||||
providerCfg := &cfg.Access.Providers[i]
|
||||
if providerCfg.Type == "" {
|
||||
continue
|
||||
}
|
||||
key := providerIdentifier(providerCfg)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = providerCfg
|
||||
}
|
||||
if len(result) == 0 && len(cfg.APIKeys) > 0 {
|
||||
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
|
||||
if key := providerIdentifier(provider); key != "" {
|
||||
result[key] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
|
||||
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
|
||||
for i := range cfg.Access.Providers {
|
||||
providerCfg := &cfg.Access.Providers[i]
|
||||
if providerCfg.Type == "" {
|
||||
continue
|
||||
}
|
||||
if key := providerIdentifier(providerCfg); key != "" {
|
||||
entries = append(entries, providerCfg)
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
|
||||
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
|
||||
entries = append(entries, inline)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
|
||||
if provider == nil {
|
||||
return ""
|
||||
}
|
||||
if name := strings.TrimSpace(provider.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
typ := strings.TrimSpace(provider.Type)
|
||||
if typ == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
|
||||
return sdkConfig.DefaultAccessProviderName
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
|
||||
return false
|
||||
}
|
||||
if !stringSetEqual(a.APIKeys, b.APIKeys) {
|
||||
return false
|
||||
}
|
||||
if len(a.Config) != len(b.Config) {
|
||||
return false
|
||||
}
|
||||
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSetEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true
|
||||
}
|
||||
seen := make(map[string]int, len(a))
|
||||
for _, val := range a {
|
||||
seen[val]++
|
||||
}
|
||||
for _, val := range b {
|
||||
count := seen[val]
|
||||
if count == 0 {
|
||||
return false
|
||||
}
|
||||
if count == 1 {
|
||||
delete(seen, val)
|
||||
} else {
|
||||
seen[val] = count - 1
|
||||
}
|
||||
}
|
||||
return len(seen) == 0
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
// Package claude provides HTTP handlers for Claude API code-related functionality.
|
||||
// This package implements Claude-compatible streaming chat completions with sophisticated
|
||||
// client rotation and quota management systems to ensure high availability and optimal
|
||||
// resource utilization across multiple backend clients. It handles request translation
|
||||
// between Claude API format and the underlying Gemini backend, providing seamless
|
||||
// API compatibility while maintaining robust error handling and connection management.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
translatorClaudeCodeToCodex "github.com/luispater/CLIProxyAPI/internal/translator/codex/claude/code"
|
||||
translatorClaudeCodeToGeminiCli "github.com/luispater/CLIProxyAPI/internal/translator/gemini-cli/claude/code"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ClaudeCodeAPIHandlers contains the handlers for Claude API endpoints.
|
||||
// It holds a pool of clients to interact with the backend service.
|
||||
type ClaudeCodeAPIHandlers struct {
|
||||
*handlers.APIHandlers
|
||||
}
|
||||
|
||||
// NewClaudeCodeAPIHandlers creates a new Claude API handlers instance.
|
||||
// It takes an APIHandlers instance as input and returns a ClaudeCodeAPIHandlers.
|
||||
func NewClaudeCodeAPIHandlers(apiHandlers *handlers.APIHandlers) *ClaudeCodeAPIHandlers {
|
||||
return &ClaudeCodeAPIHandlers{
|
||||
APIHandlers: apiHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
// ClaudeMessages handles Claude-compatible streaming chat completions.
|
||||
// This function implements a sophisticated client rotation and quota management system
|
||||
// to ensure high availability and optimal resource utilization across multiple backend clients.
|
||||
func (h *ClaudeCodeAPIHandlers) ClaudeMessages(c *gin.Context) {
|
||||
// Extract raw JSON data from the incoming request
|
||||
rawJSON, err := c.GetRawData()
|
||||
// If data retrieval fails, return a 400 Bad Request error.
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// h.handleGeminiStreamingResponse(c, rawJSON)
|
||||
// h.handleCodexStreamingResponse(c, rawJSON)
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
provider := util.GetProviderName(modelName.String())
|
||||
if provider == "gemini" {
|
||||
h.handleGeminiStreamingResponse(c, rawJSON)
|
||||
} else if provider == "gpt" {
|
||||
h.handleCodexStreamingResponse(c, rawJSON)
|
||||
} else {
|
||||
h.handleGeminiStreamingResponse(c, rawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGeminiStreamingResponse streams Claude-compatible responses backed by Gemini.
|
||||
// It sets up SSE, selects a backend client with rotation/quota logic,
|
||||
// forwards chunks, and translates them to Claude CLI format.
|
||||
func (h *ClaudeCodeAPIHandlers) handleGeminiStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||
// Set up Server-Sent Events (SSE) headers for streaming response
|
||||
// These headers are essential for maintaining a persistent connection
|
||||
// and enabling real-time streaming of chat completions
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
// This is crucial for streaming as it allows immediate sending of data chunks
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and prepare the Claude request, extracting model name, system instructions,
|
||||
// conversation contents, and available tools from the raw JSON
|
||||
modelName, systemInstruction, contents, tools := translatorClaudeCodeToGeminiCli.ConvertClaudeCodeRequestToCli(rawJSON)
|
||||
|
||||
// Map Claude model names to corresponding Gemini models
|
||||
// This allows the proxy to handle Claude API calls using Gemini backend
|
||||
if modelName == "claude-sonnet-4-20250514" {
|
||||
modelName = "gemini-2.5-pro"
|
||||
} else if modelName == "claude-3-5-haiku-20241022" {
|
||||
modelName = "gemini-2.5-flash"
|
||||
}
|
||||
|
||||
// Create a cancellable context for the backend client request
|
||||
// This allows proper cleanup and cancellation of ongoing requests
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
cliClient = client.NewGeminiClient(nil, nil, nil)
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
// This prevents deadlocks and ensures proper resource cleanup
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Main client rotation loop with quota management
|
||||
// This loop implements a sophisticated load balancing and failover mechanism
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the authentication method being used by the selected client
|
||||
// This affects how responses are formatted and logged
|
||||
isGlAPIKey := false
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use gemini generative language API Key: %s", glAPIKey)
|
||||
isGlAPIKey = true
|
||||
} else {
|
||||
log.Debugf("Request use gemini account: %s, project id: %s", cliClient.GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
// Initiate streaming communication with the backend client
|
||||
// This returns two channels: one for response chunks and one for errors
|
||||
|
||||
includeThoughts := false
|
||||
if userAgent, hasKey := c.Request.Header["User-Agent"]; hasKey {
|
||||
includeThoughts = !strings.Contains(userAgent[0], "claude-cli")
|
||||
}
|
||||
|
||||
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJSON, modelName, systemInstruction, contents, tools, includeThoughts)
|
||||
|
||||
// Track response state for proper Claude format conversion
|
||||
hasFirstResponse := false
|
||||
responseType := 0
|
||||
responseIndex := 0
|
||||
|
||||
// Main streaming loop - handles multiple concurrent events using Go channels
|
||||
// This select statement manages four different types of events simultaneously
|
||||
for {
|
||||
select {
|
||||
// Case 1: Handle client disconnection
|
||||
// Detects when the HTTP client has disconnected and cleans up resources
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("GeminiClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request to prevent resource leaks
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: Process incoming response chunks from the backend
|
||||
// This handles the actual streaming data from the AI model
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
// Stream has ended - send the final message_stop event
|
||||
// This follows the Claude API specification for stream termination
|
||||
_, _ = c.Writer.Write([]byte(`event: message_stop`))
|
||||
_, _ = c.Writer.Write([]byte("\n"))
|
||||
_, _ = c.Writer.Write([]byte(`data: {"type":"message_stop"}`))
|
||||
_, _ = c.Writer.Write([]byte("\n\n\n"))
|
||||
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
// Convert the backend response to Claude-compatible format
|
||||
// This translation layer ensures API compatibility
|
||||
claudeFormat := translatorClaudeCodeToGeminiCli.ConvertCliResponseToClaudeCode(chunk, isGlAPIKey, hasFirstResponse, &responseType, &responseIndex)
|
||||
if claudeFormat != "" {
|
||||
_, _ = c.Writer.Write([]byte(claudeFormat))
|
||||
flusher.Flush() // Immediately send the chunk to the client
|
||||
}
|
||||
hasFirstResponse = true
|
||||
|
||||
// Case 3: Handle errors from the backend
|
||||
// This manages various error conditions and implements retry logic
|
||||
case errInfo, okError := <-errChan:
|
||||
if okError {
|
||||
// Special handling for quota exceeded errors
|
||||
// If configured, attempt to switch to a different project/client
|
||||
if errInfo.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop // Restart the client selection process
|
||||
} else {
|
||||
// Forward other errors directly to the client
|
||||
c.Status(errInfo.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errInfo.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Case 4: Send periodic keep-alive signals
|
||||
// Prevents connection timeouts during long-running requests
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
if hasFirstResponse {
|
||||
// Send a ping event to maintain the connection
|
||||
// This is especially important for slow AI model responses
|
||||
output := "event: ping\n"
|
||||
output = output + `data: {"type": "ping"}`
|
||||
output = output + "\n\n\n"
|
||||
_, _ = c.Writer.Write([]byte(output))
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleCodexStreamingResponse streams Claude-compatible responses backed by OpenAI.
|
||||
// It converts the Claude request into Codex/OpenAI responses format, establishes SSE,
|
||||
// and translates streaming chunks back into Claude CLI events.
|
||||
func (h *ClaudeCodeAPIHandlers) handleCodexStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||
// Set up Server-Sent Events (SSE) headers for streaming response
|
||||
// These headers are essential for maintaining a persistent connection
|
||||
// and enabling real-time streaming of chat completions
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
// This is crucial for streaming as it allows immediate sending of data chunks
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and prepare the Claude request, extracting model name, system instructions,
|
||||
// conversation contents, and available tools from the raw JSON
|
||||
newRequestJSON := translatorClaudeCodeToCodex.ConvertClaudeCodeRequestToCodex(rawJSON)
|
||||
modelName := gjson.GetBytes(rawJSON, "model").String()
|
||||
// Map Claude model names to corresponding Gemini models
|
||||
// This allows the proxy to handle Claude API calls using Gemini backend
|
||||
if modelName == "claude-sonnet-4-20250514" {
|
||||
modelName = "gpt-5"
|
||||
} else if modelName == "claude-3-5-haiku-20241022" {
|
||||
modelName = "gpt-5"
|
||||
}
|
||||
newRequestJSON, _ = sjson.Set(newRequestJSON, "model", modelName)
|
||||
// log.Debugf(string(rawJSON))
|
||||
// log.Debugf(newRequestJSON)
|
||||
// return
|
||||
// Create a cancellable context for the backend client request
|
||||
// This allows proper cleanup and cancellation of ongoing requests
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
// This prevents deadlocks and ensures proper resource cleanup
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Main client rotation loop with quota management
|
||||
// This loop implements a sophisticated load balancing and failover mechanism
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request use codex account: %s", cliClient.GetEmail())
|
||||
|
||||
// Initiate streaming communication with the backend client
|
||||
// This returns two channels: one for response chunks and one for errors
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
|
||||
// Track response state for proper Claude format conversion
|
||||
hasFirstResponse := false
|
||||
hasToolCall := false
|
||||
|
||||
// Main streaming loop - handles multiple concurrent events using Go channels
|
||||
// This select statement manages four different types of events simultaneously
|
||||
for {
|
||||
select {
|
||||
// Case 1: Handle client disconnection
|
||||
// Detects when the HTTP client has disconnected and cleans up resources
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request to prevent resource leaks
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: Process incoming response chunks from the backend
|
||||
// This handles the actual streaming data from the AI model
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
// Convert the backend response to Claude-compatible format
|
||||
// This translation layer ensures API compatibility
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
var claudeFormat string
|
||||
claudeFormat, hasToolCall = translatorClaudeCodeToCodex.ConvertCodexResponseToClaude(jsonData, hasToolCall)
|
||||
// log.Debugf("claudeFormat: %s", claudeFormat)
|
||||
if claudeFormat != "" {
|
||||
_, _ = c.Writer.Write([]byte(claudeFormat))
|
||||
_, _ = c.Writer.Write([]byte("\n"))
|
||||
}
|
||||
flusher.Flush() // Immediately send the chunk to the client
|
||||
hasFirstResponse = true
|
||||
} else {
|
||||
// log.Debugf("chunk: %s", string(chunk))
|
||||
}
|
||||
// Case 3: Handle errors from the backend
|
||||
// This manages various error conditions and implements retry logic
|
||||
case errInfo, okError := <-errChan:
|
||||
if okError {
|
||||
// log.Debugf("Code: %d, Error: %v", errInfo.StatusCode, errInfo.Error)
|
||||
// Special handling for quota exceeded errors
|
||||
// If configured, attempt to switch to a different project/client
|
||||
if errInfo.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
log.Debugf("quota exceeded, switch client")
|
||||
continue outLoop // Restart the client selection process
|
||||
} else {
|
||||
// Forward other errors directly to the client
|
||||
c.Status(errInfo.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errInfo.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Case 4: Send periodic keep-alive signals
|
||||
// Prevents connection timeouts during long-running requests
|
||||
case <-time.After(3000 * time.Millisecond):
|
||||
if hasFirstResponse {
|
||||
// Send a ping event to maintain the connection
|
||||
// This is especially important for slow AI model responses
|
||||
output := "event: ping\n"
|
||||
output = output + `data: {"type": "ping"}`
|
||||
output = output + "\n\n"
|
||||
_, _ = c.Writer.Write([]byte(output))
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
// Package cli provides HTTP handlers for Gemini CLI API functionality.
|
||||
// This package implements handlers that process CLI-specific requests for Gemini API operations,
|
||||
// including content generation and streaming content generation endpoints.
|
||||
// The handlers restrict access to localhost only and manage communication with the backend service.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
translatorGeminiToCodex "github.com/luispater/CLIProxyAPI/internal/translator/codex/gemini"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// GeminiCLIAPIHandlers contains the handlers for Gemini CLI API endpoints.
|
||||
// It holds a pool of clients to interact with the backend service.
|
||||
type GeminiCLIAPIHandlers struct {
|
||||
*handlers.APIHandlers
|
||||
}
|
||||
|
||||
// NewGeminiCLIAPIHandlers creates a new Gemini CLI API handlers instance.
|
||||
// It takes an APIHandlers instance as input and returns a GeminiCLIAPIHandlers.
|
||||
func NewGeminiCLIAPIHandlers(apiHandlers *handlers.APIHandlers) *GeminiCLIAPIHandlers {
|
||||
return &GeminiCLIAPIHandlers{
|
||||
APIHandlers: apiHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
// CLIHandler handles CLI-specific requests for Gemini API operations.
|
||||
// It restricts access to localhost only and routes requests to appropriate internal handlers.
|
||||
func (h *GeminiCLIAPIHandlers) CLIHandler(c *gin.Context) {
|
||||
if !strings.HasPrefix(c.Request.RemoteAddr, "127.0.0.1:") {
|
||||
c.JSON(http.StatusForbidden, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "CLI reply only allow local access",
|
||||
Type: "forbidden",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rawJSON, _ := c.GetRawData()
|
||||
requestRawURI := c.Request.URL.Path
|
||||
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
provider := util.GetProviderName(modelName.String())
|
||||
|
||||
if requestRawURI == "/v1internal:generateContent" {
|
||||
if provider == "gemini" || provider == "unknow" {
|
||||
h.handleInternalGenerateContent(c, rawJSON)
|
||||
} else if provider == "gpt" {
|
||||
h.handleCodexInternalGenerateContent(c, rawJSON)
|
||||
}
|
||||
} else if requestRawURI == "/v1internal:streamGenerateContent" {
|
||||
if provider == "gemini" || provider == "unknow" {
|
||||
h.handleInternalStreamGenerateContent(c, rawJSON)
|
||||
} else if provider == "gpt" {
|
||||
h.handleCodexInternalStreamGenerateContent(c, rawJSON)
|
||||
}
|
||||
} else {
|
||||
reqBody := bytes.NewBuffer(rawJSON)
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("https://cloudcode-pa.googleapis.com%s", c.Request.URL.RequestURI()), reqBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
for key, value := range c.Request.Header {
|
||||
req.Header[key] = value
|
||||
}
|
||||
|
||||
httpClient := util.SetProxy(h.Cfg, &http.Client{})
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
log.Printf("warn: failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: string(bodyBytes),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
for key, value := range resp.Header {
|
||||
c.Header(key, value[0])
|
||||
}
|
||||
output, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to read response body: %v", err)
|
||||
return
|
||||
}
|
||||
_, _ = c.Writer.Write(output)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiCLIAPIHandlers) handleInternalStreamGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
alt := h.GetAlt(c)
|
||||
|
||||
if alt == "" {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
modelName := modelResult.String()
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.(*client.GeminiClient).GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, rawJSON, "")
|
||||
hasFirstResponse := false
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("GeminiClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
hasFirstResponse = true
|
||||
if cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey() != "" {
|
||||
chunk, _ = sjson.SetRawBytes(chunk, "response", chunk)
|
||||
}
|
||||
_, _ = c.Writer.Write([]byte("data: "))
|
||||
_, _ = c.Writer.Write(chunk)
|
||||
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||
|
||||
flusher.Flush()
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
if hasFirstResponse {
|
||||
_, _ = c.Writer.Write([]byte("\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiCLIAPIHandlers) handleInternalGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
// log.Debugf("GenerateContent: %s", string(rawJSON))
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
modelName := modelResult.String()
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.(*client.GeminiClient).GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
|
||||
resp, err := cliClient.SendRawMessage(cliCtx, rawJSON, "")
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = c.Writer.Write([]byte(err.Error.Error()))
|
||||
log.Debugf("code: %d, error: %s", err.StatusCode, err.Error.Error())
|
||||
cliCancel()
|
||||
}
|
||||
break
|
||||
} else {
|
||||
_, _ = c.Writer.Write(resp)
|
||||
cliCancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiCLIAPIHandlers) handleCodexInternalStreamGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
rawJSON = []byte(gjson.GetBytes(rawJSON, "request").Raw)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelResult.String())
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "system_instruction", []byte(gjson.GetBytes(rawJSON, "systemInstruction").Raw))
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "systemInstruction")
|
||||
|
||||
// log.Debugf("Request: %s", string(rawJSON))
|
||||
// return
|
||||
|
||||
// Prepare the request for the backend client.
|
||||
newRequestJSON := translatorGeminiToCodex.ConvertGeminiRequestToCodex(rawJSON)
|
||||
// log.Debugf("Request: %s", newRequestJSON)
|
||||
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName.String())
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request codex use account: %s", cliClient.GetEmail())
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
|
||||
params := &translatorGeminiToCodex.ConvertCodexResponseToGeminiParams{
|
||||
Model: modelName.String(),
|
||||
CreatedAt: 0,
|
||||
ResponseID: "",
|
||||
LastStorageOutput: "",
|
||||
}
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
// _, _ = logFile.Write(chunk)
|
||||
// _, _ = logFile.Write([]byte("\n"))
|
||||
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
data := gjson.ParseBytes(jsonData)
|
||||
typeResult := data.Get("type")
|
||||
if typeResult.String() != "" {
|
||||
outputs := translatorGeminiToCodex.ConvertCodexResponseToGemini(jsonData, params)
|
||||
if len(outputs) > 0 {
|
||||
for i := 0; i < len(outputs); i++ {
|
||||
outputs[i], _ = sjson.SetRaw("{}", "response", outputs[i])
|
||||
_, _ = c.Writer.Write([]byte("data: "))
|
||||
_, _ = c.Writer.Write([]byte(outputs[i]))
|
||||
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flusher.Flush()
|
||||
// Handle errors from the backend.
|
||||
case errMessage, okError := <-errChan:
|
||||
if okError {
|
||||
if errMessage.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
log.Debugf("code: %d, error: %s", errMessage.StatusCode, errMessage.Error.Error())
|
||||
c.Status(errMessage.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errMessage.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiCLIAPIHandlers) handleCodexInternalGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
orgRawJSON := rawJSON
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
rawJSON = []byte(gjson.GetBytes(rawJSON, "request").Raw)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelResult.String())
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "system_instruction", []byte(gjson.GetBytes(rawJSON, "systemInstruction").Raw))
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "systemInstruction")
|
||||
|
||||
// Prepare the request for the backend client.
|
||||
newRequestJSON := translatorGeminiToCodex.ConvertGeminiRequestToCodex(rawJSON)
|
||||
// log.Debugf("Request: %s", newRequestJSON)
|
||||
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName.String())
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request codex use account: %s", cliClient.GetEmail())
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
data := gjson.ParseBytes(jsonData)
|
||||
typeResult := data.Get("type")
|
||||
if typeResult.String() != "" {
|
||||
var geminiStr string
|
||||
geminiStr = translatorGeminiToCodex.ConvertCodexResponseToGeminiNonStream(jsonData, modelName.String())
|
||||
if geminiStr != "" {
|
||||
_, _ = c.Writer.Write([]byte(geminiStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
log.Debugf("org: %s", string(orgRawJSON))
|
||||
log.Debugf("raw: %s", string(rawJSON))
|
||||
log.Debugf("newRequestJSON: %s", newRequestJSON)
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,735 +0,0 @@
|
||||
// Package gemini provides HTTP handlers for Gemini API endpoints.
|
||||
// This package implements handlers for managing Gemini model operations including
|
||||
// model listing, content generation, streaming content generation, and token counting.
|
||||
// It serves as a proxy layer between clients and the Gemini backend service,
|
||||
// handling request translation, client management, and response processing.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
translatorGeminiToCodex "github.com/luispater/CLIProxyAPI/internal/translator/codex/gemini"
|
||||
translatorGeminiToGeminiCli "github.com/luispater/CLIProxyAPI/internal/translator/gemini-cli/gemini/cli"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// GeminiAPIHandlers contains the handlers for Gemini API endpoints.
|
||||
// It holds a pool of clients to interact with the backend service.
|
||||
type GeminiAPIHandlers struct {
|
||||
*handlers.APIHandlers
|
||||
}
|
||||
|
||||
// NewGeminiAPIHandlers creates a new Gemini API handlers instance.
|
||||
// It takes an APIHandlers instance as input and returns a GeminiAPIHandlers.
|
||||
func NewGeminiAPIHandlers(apiHandlers *handlers.APIHandlers) *GeminiAPIHandlers {
|
||||
return &GeminiAPIHandlers{
|
||||
APIHandlers: apiHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
// GeminiModels handles the Gemini models listing endpoint.
|
||||
// It returns a JSON response containing available Gemini models and their specifications.
|
||||
func (h *GeminiAPIHandlers) GeminiModels(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []map[string]any{
|
||||
{
|
||||
"id": "gemini-2.5-flash",
|
||||
"object": "model",
|
||||
"version": "001",
|
||||
"name": "Gemini 2.5 Flash",
|
||||
"description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
|
||||
"context_length": 1_048_576,
|
||||
"max_completion_tokens": 65_536,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
},
|
||||
{
|
||||
"id": "gemini-2.5-pro",
|
||||
"object": "model",
|
||||
"version": "2.5",
|
||||
"name": "Gemini 2.5 Pro",
|
||||
"description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
|
||||
"context_length": 1_048_576,
|
||||
"max_completion_tokens": 65_536,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
},
|
||||
{
|
||||
"id": "gpt-5",
|
||||
"object": "model",
|
||||
"version": "gpt-5-2025-08-07",
|
||||
"name": "GPT 5",
|
||||
"description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.",
|
||||
"context_length": 400_000,
|
||||
"max_completion_tokens": 128_000,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GeminiGetHandler handles GET requests for specific Gemini model information.
|
||||
// It returns detailed information about a specific Gemini model based on the action parameter.
|
||||
func (h *GeminiAPIHandlers) GeminiGetHandler(c *gin.Context) {
|
||||
var request struct {
|
||||
Action string `uri:"action" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindUri(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
switch request.Action {
|
||||
case "gemini-2.5-pro":
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": "gemini-2.5-pro",
|
||||
"object": "model",
|
||||
"version": "2.5",
|
||||
"name": "Gemini 2.5 Pro",
|
||||
"description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
|
||||
"context_length": 1_048_576,
|
||||
"max_completion_tokens": 65_536,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
})
|
||||
case "gemini-2.5-flash":
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": "gemini-2.5-flash",
|
||||
"object": "model",
|
||||
"version": "001",
|
||||
"name": "Gemini 2.5 Flash",
|
||||
"description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
|
||||
"context_length": 1_048_576,
|
||||
"max_completion_tokens": 65_536,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
})
|
||||
case "gpt-5":
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": "gpt-5",
|
||||
"object": "model",
|
||||
"version": "gpt-5-2025-08-07",
|
||||
"name": "GPT 5",
|
||||
"description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.",
|
||||
"context_length": 400_000,
|
||||
"max_completion_tokens": 128_000,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusNotFound, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Not Found",
|
||||
Type: "not_found",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GeminiHandler handles POST requests for Gemini API operations.
|
||||
// It routes requests to appropriate handlers based on the action parameter (model:method format).
|
||||
func (h *GeminiAPIHandlers) GeminiHandler(c *gin.Context) {
|
||||
var request struct {
|
||||
Action string `uri:"action" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindUri(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
action := strings.Split(request.Action, ":")
|
||||
if len(action) != 2 {
|
||||
c.JSON(http.StatusNotFound, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("%s not found.", c.Request.URL.Path),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modelName := action[0]
|
||||
method := action[1]
|
||||
rawJSON, _ := c.GetRawData()
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", []byte(modelName))
|
||||
|
||||
provider := util.GetProviderName(modelName)
|
||||
if provider == "gemini" || provider == "unknow" {
|
||||
switch method {
|
||||
case "generateContent":
|
||||
h.handleGeminiGenerateContent(c, rawJSON)
|
||||
case "streamGenerateContent":
|
||||
h.handleGeminiStreamGenerateContent(c, rawJSON)
|
||||
case "countTokens":
|
||||
h.handleGeminiCountTokens(c, rawJSON)
|
||||
}
|
||||
} else if provider == "gpt" {
|
||||
switch method {
|
||||
case "generateContent":
|
||||
h.handleCodexGenerateContent(c, rawJSON)
|
||||
case "streamGenerateContent":
|
||||
h.handleCodexStreamGenerateContent(c, rawJSON)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiAPIHandlers) handleGeminiStreamGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
alt := h.GetAlt(c)
|
||||
|
||||
if alt == "" {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
modelName := modelResult.String()
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
template := ""
|
||||
parsed := gjson.Parse(string(rawJSON))
|
||||
contents := parsed.Get("request.contents")
|
||||
if contents.Exists() {
|
||||
template = string(rawJSON)
|
||||
} else {
|
||||
template = `{"project":"","request":{},"model":""}`
|
||||
template, _ = sjson.SetRaw(template, "request", string(rawJSON))
|
||||
template, _ = sjson.Set(template, "model", gjson.Get(template, "request.model").String())
|
||||
template, _ = sjson.Delete(template, "request.model")
|
||||
}
|
||||
|
||||
template, errFixCLIToolResponse := translatorGeminiToGeminiCli.FixCLIToolResponse(template)
|
||||
if errFixCLIToolResponse != nil {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: errFixCLIToolResponse.Error(),
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
systemInstructionResult := gjson.Get(template, "request.system_instruction")
|
||||
if systemInstructionResult.Exists() {
|
||||
template, _ = sjson.SetRaw(template, "request.systemInstruction", systemInstructionResult.Raw)
|
||||
template, _ = sjson.Delete(template, "request.system_instruction")
|
||||
}
|
||||
rawJSON = []byte(template)
|
||||
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.(*client.GeminiClient).GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, rawJSON, alt)
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("GeminiClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
if cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey() == "" {
|
||||
if alt == "" {
|
||||
responseResult := gjson.GetBytes(chunk, "response")
|
||||
if responseResult.Exists() {
|
||||
chunk = []byte(responseResult.Raw)
|
||||
}
|
||||
} else {
|
||||
chunkTemplate := "[]"
|
||||
responseResult := gjson.ParseBytes(chunk)
|
||||
if responseResult.IsArray() {
|
||||
responseResultItems := responseResult.Array()
|
||||
for i := 0; i < len(responseResultItems); i++ {
|
||||
responseResultItem := responseResultItems[i]
|
||||
if responseResultItem.Get("response").Exists() {
|
||||
chunkTemplate, _ = sjson.SetRaw(chunkTemplate, "-1", responseResultItem.Get("response").Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk = []byte(chunkTemplate)
|
||||
}
|
||||
}
|
||||
if alt == "" {
|
||||
_, _ = c.Writer.Write([]byte("data: "))
|
||||
_, _ = c.Writer.Write(chunk)
|
||||
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||
} else {
|
||||
_, _ = c.Writer.Write(chunk)
|
||||
}
|
||||
flusher.Flush()
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
log.Debugf("quota exceeded, switch client")
|
||||
continue outLoop
|
||||
} else {
|
||||
log.Debugf("error code :%d, error: %v", err.StatusCode, err.Error.Error())
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiAPIHandlers) handleGeminiCountTokens(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
|
||||
alt := h.GetAlt(c)
|
||||
// orgrawJSON := rawJSON
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
modelName := modelResult.String()
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName, false)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.(*client.GeminiClient).GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
|
||||
template := `{"request":{}}`
|
||||
if gjson.GetBytes(rawJSON, "generateContentRequest").Exists() {
|
||||
template, _ = sjson.SetRaw(template, "request", gjson.GetBytes(rawJSON, "generateContentRequest").Raw)
|
||||
template, _ = sjson.Delete(template, "generateContentRequest")
|
||||
} else if gjson.GetBytes(rawJSON, "contents").Exists() {
|
||||
template, _ = sjson.SetRaw(template, "request.contents", gjson.GetBytes(rawJSON, "contents").Raw)
|
||||
template, _ = sjson.Delete(template, "contents")
|
||||
}
|
||||
rawJSON = []byte(template)
|
||||
}
|
||||
|
||||
resp, err := cliClient.SendRawTokenCount(cliCtx, rawJSON, alt)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = c.Writer.Write([]byte(err.Error.Error()))
|
||||
cliCancel()
|
||||
// log.Debugf(err.Error.Error())
|
||||
// log.Debugf(string(rawJSON))
|
||||
// log.Debugf(string(orgrawJSON))
|
||||
}
|
||||
break
|
||||
} else {
|
||||
if cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey() == "" {
|
||||
responseResult := gjson.GetBytes(resp, "response")
|
||||
if responseResult.Exists() {
|
||||
resp = []byte(responseResult.Raw)
|
||||
}
|
||||
}
|
||||
_, _ = c.Writer.Write(resp)
|
||||
cliCancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiAPIHandlers) handleGeminiGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
|
||||
alt := h.GetAlt(c)
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
modelName := modelResult.String()
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
template := ""
|
||||
parsed := gjson.Parse(string(rawJSON))
|
||||
contents := parsed.Get("request.contents")
|
||||
if contents.Exists() {
|
||||
template = string(rawJSON)
|
||||
} else {
|
||||
template = `{"project":"","request":{},"model":""}`
|
||||
template, _ = sjson.SetRaw(template, "request", string(rawJSON))
|
||||
template, _ = sjson.Set(template, "model", gjson.Get(template, "request.model").String())
|
||||
template, _ = sjson.Delete(template, "request.model")
|
||||
}
|
||||
|
||||
template, errFixCLIToolResponse := translatorGeminiToGeminiCli.FixCLIToolResponse(template)
|
||||
if errFixCLIToolResponse != nil {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: errFixCLIToolResponse.Error(),
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
systemInstructionResult := gjson.Get(template, "request.system_instruction")
|
||||
if systemInstructionResult.Exists() {
|
||||
template, _ = sjson.SetRaw(template, "request.systemInstruction", systemInstructionResult.Raw)
|
||||
template, _ = sjson.Delete(template, "request.system_instruction")
|
||||
}
|
||||
rawJSON = []byte(template)
|
||||
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.(*client.GeminiClient).GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
resp, err := cliClient.SendRawMessage(cliCtx, rawJSON, alt)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = c.Writer.Write([]byte(err.Error.Error()))
|
||||
cliCancel()
|
||||
}
|
||||
break
|
||||
} else {
|
||||
if cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey() == "" {
|
||||
responseResult := gjson.GetBytes(resp, "response")
|
||||
if responseResult.Exists() {
|
||||
resp = []byte(responseResult.Raw)
|
||||
}
|
||||
}
|
||||
_, _ = c.Writer.Write(resp)
|
||||
cliCancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiAPIHandlers) handleCodexStreamGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the request for the backend client.
|
||||
newRequestJSON := translatorGeminiToCodex.ConvertGeminiRequestToCodex(rawJSON)
|
||||
// log.Debugf("Request: %s", newRequestJSON)
|
||||
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName.String())
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request codex use account: %s", cliClient.GetEmail())
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
params := &translatorGeminiToCodex.ConvertCodexResponseToGeminiParams{
|
||||
Model: modelName.String(),
|
||||
CreatedAt: 0,
|
||||
ResponseID: "",
|
||||
LastStorageOutput: "",
|
||||
}
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
data := gjson.ParseBytes(jsonData)
|
||||
typeResult := data.Get("type")
|
||||
if typeResult.String() != "" {
|
||||
outputs := translatorGeminiToCodex.ConvertCodexResponseToGemini(jsonData, params)
|
||||
if len(outputs) > 0 {
|
||||
for i := 0; i < len(outputs); i++ {
|
||||
_, _ = c.Writer.Write([]byte("data: "))
|
||||
_, _ = c.Writer.Write([]byte(outputs[i]))
|
||||
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
// log.Debugf(string(jsonData))
|
||||
}
|
||||
flusher.Flush()
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GeminiAPIHandlers) handleCodexGenerateContent(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
|
||||
// Prepare the request for the backend client.
|
||||
newRequestJSON := translatorGeminiToCodex.ConvertGeminiRequestToCodex(rawJSON)
|
||||
// log.Debugf("Request: %s", newRequestJSON)
|
||||
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName.String())
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request codex use account: %s", cliClient.GetEmail())
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
data := gjson.ParseBytes(jsonData)
|
||||
typeResult := data.Get("type")
|
||||
if typeResult.String() != "" {
|
||||
var geminiStr string
|
||||
geminiStr = translatorGeminiToCodex.ConvertCodexResponseToGeminiNonStream(jsonData, modelName.String())
|
||||
if geminiStr != "" {
|
||||
_, _ = c.Writer.Write([]byte(geminiStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// Package handlers provides core API handler functionality for the CLI Proxy API server.
|
||||
// It includes common types, client management, load balancing, and error handling
|
||||
// shared across all API endpoint handlers (OpenAI, Claude, Gemini).
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrorResponse represents a standard error response format for the API.
|
||||
// It contains a single ErrorDetail field.
|
||||
type ErrorResponse struct {
|
||||
// Error contains detailed information about the error that occurred.
|
||||
Error ErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorDetail provides specific information about an error that occurred.
|
||||
// It includes a human-readable message, an error type, and an optional error code.
|
||||
type ErrorDetail struct {
|
||||
// Message is a human-readable message providing more details about the error.
|
||||
Message string `json:"message"`
|
||||
|
||||
// Type is the category of error that occurred (e.g., "invalid_request_error").
|
||||
Type string `json:"type"`
|
||||
|
||||
// Code is a short code identifying the error, if applicable.
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// APIHandlers contains the handlers for API endpoints.
|
||||
// It holds a pool of clients to interact with the backend service and manages
|
||||
// load balancing, client selection, and configuration.
|
||||
type APIHandlers struct {
|
||||
// CliClients is the pool of available AI service clients.
|
||||
CliClients []client.Client
|
||||
|
||||
// Cfg holds the current application configuration.
|
||||
Cfg *config.Config
|
||||
|
||||
// Mutex ensures thread-safe access to shared resources.
|
||||
Mutex *sync.Mutex
|
||||
|
||||
// LastUsedClientIndex tracks the last used client index for each provider
|
||||
// to implement round-robin load balancing.
|
||||
LastUsedClientIndex map[string]int
|
||||
}
|
||||
|
||||
// NewAPIHandlers creates a new API handlers instance.
|
||||
// It takes a slice of clients and configuration as input.
|
||||
//
|
||||
// Parameters:
|
||||
// - cliClients: A slice of AI service clients
|
||||
// - cfg: The application configuration
|
||||
//
|
||||
// Returns:
|
||||
// - *APIHandlers: A new API handlers instance
|
||||
func NewAPIHandlers(cliClients []client.Client, cfg *config.Config) *APIHandlers {
|
||||
return &APIHandlers{
|
||||
CliClients: cliClients,
|
||||
Cfg: cfg,
|
||||
Mutex: &sync.Mutex{},
|
||||
LastUsedClientIndex: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateClients updates the handlers' client list and configuration.
|
||||
// This method is called when the configuration or authentication tokens change.
|
||||
//
|
||||
// Parameters:
|
||||
// - clients: The new slice of AI service clients
|
||||
// - cfg: The new application configuration
|
||||
func (h *APIHandlers) UpdateClients(clients []client.Client, cfg *config.Config) {
|
||||
h.CliClients = clients
|
||||
h.Cfg = cfg
|
||||
}
|
||||
|
||||
// GetClient returns an available client from the pool using round-robin load balancing.
|
||||
// It checks for quota limits and tries to find an unlocked client for immediate use.
|
||||
// The modelName parameter is used to check quota status for specific models.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to be used
|
||||
// - isGenerateContent: Optional parameter to indicate if this is for content generation
|
||||
//
|
||||
// Returns:
|
||||
// - client.Client: An available client for the requested model
|
||||
// - *client.ErrorMessage: An error message if no client is available
|
||||
func (h *APIHandlers) GetClient(modelName string, isGenerateContent ...bool) (client.Client, *client.ErrorMessage) {
|
||||
provider := util.GetProviderName(modelName)
|
||||
clients := make([]client.Client, 0)
|
||||
if provider == "gemini" {
|
||||
for i := 0; i < len(h.CliClients); i++ {
|
||||
if cli, ok := h.CliClients[i].(*client.GeminiClient); ok {
|
||||
clients = append(clients, cli)
|
||||
}
|
||||
}
|
||||
} else if provider == "gpt" {
|
||||
for i := 0; i < len(h.CliClients); i++ {
|
||||
if cli, ok := h.CliClients[i].(*client.CodexClient); ok {
|
||||
clients = append(clients, cli)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, hasKey := h.LastUsedClientIndex[provider]; !hasKey {
|
||||
h.LastUsedClientIndex[provider] = 0
|
||||
}
|
||||
|
||||
if len(clients) == 0 {
|
||||
return nil, &client.ErrorMessage{StatusCode: 500, Error: fmt.Errorf("no clients available")}
|
||||
}
|
||||
|
||||
var cliClient client.Client
|
||||
|
||||
// Lock the mutex to update the last used client index
|
||||
h.Mutex.Lock()
|
||||
startIndex := h.LastUsedClientIndex[provider]
|
||||
if (len(isGenerateContent) > 0 && isGenerateContent[0]) || len(isGenerateContent) == 0 {
|
||||
currentIndex := (startIndex + 1) % len(clients)
|
||||
h.LastUsedClientIndex[provider] = currentIndex
|
||||
}
|
||||
h.Mutex.Unlock()
|
||||
|
||||
// Reorder the client to start from the last used index
|
||||
reorderedClients := make([]client.Client, 0)
|
||||
for i := 0; i < len(clients); i++ {
|
||||
cliClient = clients[(startIndex+1+i)%len(clients)]
|
||||
if cliClient.IsModelQuotaExceeded(modelName) {
|
||||
if provider == "gemini" {
|
||||
log.Debugf("Gemini Model %s is quota exceeded for account %s, project id: %s", modelName, cliClient.GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
} else if provider == "gpt" {
|
||||
log.Debugf("Codex Model %s is quota exceeded for account %s", modelName, cliClient.GetEmail())
|
||||
}
|
||||
cliClient = nil
|
||||
continue
|
||||
|
||||
}
|
||||
reorderedClients = append(reorderedClients, cliClient)
|
||||
}
|
||||
|
||||
if len(reorderedClients) == 0 {
|
||||
return nil, &client.ErrorMessage{StatusCode: 429, Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, modelName)}
|
||||
}
|
||||
|
||||
locked := false
|
||||
for i := 0; i < len(reorderedClients); i++ {
|
||||
cliClient = reorderedClients[i]
|
||||
if cliClient.GetRequestMutex().TryLock() {
|
||||
locked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !locked {
|
||||
cliClient = clients[0]
|
||||
cliClient.GetRequestMutex().Lock()
|
||||
}
|
||||
|
||||
return cliClient, nil
|
||||
}
|
||||
|
||||
// GetAlt extracts the 'alt' parameter from the request query string.
|
||||
// It checks both 'alt' and '$alt' parameters and returns the appropriate value.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: The Gin context containing the HTTP request
|
||||
//
|
||||
// Returns:
|
||||
// - string: The alt parameter value, or empty string if it's "sse"
|
||||
func (h *APIHandlers) GetAlt(c *gin.Context) string {
|
||||
var alt string
|
||||
var hasAlt bool
|
||||
alt, hasAlt = c.GetQuery("alt")
|
||||
if !hasAlt {
|
||||
alt, _ = c.GetQuery("$alt")
|
||||
}
|
||||
if alt == "sse" {
|
||||
return ""
|
||||
}
|
||||
return alt
|
||||
}
|
||||
1790
internal/api/handlers/management/auth_files.go
Normal file
1790
internal/api/handlers/management/auth_files.go
Normal file
File diff suppressed because it is too large
Load Diff
183
internal/api/handlers/management/config_basic.go
Normal file
183
internal/api/handlers/management/config_basic.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func (h *Handler) GetConfig(c *gin.Context) {
|
||||
if h == nil || h.cfg == nil {
|
||||
c.JSON(200, gin.H{})
|
||||
return
|
||||
}
|
||||
cfgCopy := *h.cfg
|
||||
cfgCopy.GlAPIKey = geminiKeyStringsFromConfig(h.cfg)
|
||||
c.JSON(200, &cfgCopy)
|
||||
}
|
||||
|
||||
func (h *Handler) GetConfigYAML(c *gin.Context) {
|
||||
data, err := os.ReadFile(h.configFilePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
var node yaml.Node
|
||||
if err = yaml.Unmarshal(data, &node); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse_failed", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/yaml; charset=utf-8")
|
||||
c.Header("Vary", "format, Accept")
|
||||
enc := yaml.NewEncoder(c.Writer)
|
||||
enc.SetIndent(2)
|
||||
_ = enc.Encode(&node)
|
||||
_ = enc.Close()
|
||||
}
|
||||
|
||||
func WriteConfig(path string, data []byte) error {
|
||||
data = config.NormalizeCommentIndentation(data)
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, errWrite := f.Write(data); errWrite != nil {
|
||||
_ = f.Close()
|
||||
return errWrite
|
||||
}
|
||||
if errSync := f.Sync(); errSync != nil {
|
||||
_ = f.Close()
|
||||
return errSync
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (h *Handler) PutConfigYAML(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": "cannot read request body"})
|
||||
return
|
||||
}
|
||||
var cfg config.Config
|
||||
if err = yaml.Unmarshal(body, &cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// Validate config using LoadConfigOptional with optional=false to enforce parsing
|
||||
tmpDir := filepath.Dir(h.configFilePath)
|
||||
tmpFile, err := os.CreateTemp(tmpDir, "config-validate-*.yaml")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
tempFile := tmpFile.Name()
|
||||
if _, errWrite := tmpFile.Write(body); errWrite != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tempFile)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errWrite.Error()})
|
||||
return
|
||||
}
|
||||
if errClose := tmpFile.Close(); errClose != nil {
|
||||
_ = os.Remove(tempFile)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errClose.Error()})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Remove(tempFile)
|
||||
}()
|
||||
_, err = config.LoadConfigOptional(tempFile, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if WriteConfig(h.configFilePath, body) != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": "failed to write config"})
|
||||
return
|
||||
}
|
||||
// Reload into handler to keep memory in sync
|
||||
newCfg, err := config.LoadConfig(h.configFilePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "reload_failed", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
h.cfg = newCfg
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
|
||||
}
|
||||
|
||||
// GetConfigFile returns the raw config.yaml file bytes without re-encoding.
|
||||
// It preserves comments and original formatting/styles.
|
||||
func (h *Handler) GetConfigFile(c *gin.Context) {
|
||||
data, err := os.ReadFile(h.configFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not_found", "message": "config file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/yaml; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
// Write raw bytes as-is
|
||||
_, _ = c.Writer.Write(data)
|
||||
}
|
||||
|
||||
// Debug
|
||||
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
|
||||
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
|
||||
|
||||
// UsageStatisticsEnabled
|
||||
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
|
||||
}
|
||||
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
|
||||
}
|
||||
|
||||
// UsageStatisticsEnabled
|
||||
func (h *Handler) GetLoggingToFile(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
|
||||
}
|
||||
func (h *Handler) PutLoggingToFile(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
|
||||
}
|
||||
|
||||
// Request log
|
||||
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
|
||||
func (h *Handler) PutRequestLog(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
|
||||
}
|
||||
|
||||
// Websocket auth
|
||||
func (h *Handler) GetWebsocketAuth(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
|
||||
}
|
||||
func (h *Handler) PutWebsocketAuth(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
|
||||
}
|
||||
|
||||
// Request retry
|
||||
func (h *Handler) GetRequestRetry(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
|
||||
}
|
||||
func (h *Handler) PutRequestRetry(c *gin.Context) {
|
||||
h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
|
||||
}
|
||||
|
||||
// Proxy URL
|
||||
func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
|
||||
func (h *Handler) PutProxyURL(c *gin.Context) {
|
||||
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
|
||||
}
|
||||
func (h *Handler) DeleteProxyURL(c *gin.Context) {
|
||||
h.cfg.ProxyURL = ""
|
||||
h.persist(c)
|
||||
}
|
||||
705
internal/api/handlers/management/config_lists.go
Normal file
705
internal/api/handlers/management/config_lists.go
Normal file
@@ -0,0 +1,705 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
)
|
||||
|
||||
// Generic helpers for list[string]
|
||||
func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var arr []string
|
||||
if err = json.Unmarshal(data, &arr); err != nil {
|
||||
var obj struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
set(arr)
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
|
||||
var body struct {
|
||||
Old *string `json:"old"`
|
||||
New *string `json:"new"`
|
||||
Index *int `json:"index"`
|
||||
Value *string `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
|
||||
(*target)[*body.Index] = *body.Value
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Old != nil && body.New != nil {
|
||||
for i := range *target {
|
||||
if (*target)[i] == *body.Old {
|
||||
(*target)[i] = *body.New
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
*target = append(*target, *body.New)
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing fields"})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
var idx int
|
||||
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||
if err == nil && idx >= 0 && idx < len(*target) {
|
||||
*target = append((*target)[:idx], (*target)[idx+1:]...)
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if val := strings.TrimSpace(c.Query("value")); val != "" {
|
||||
out := make([]string, 0, len(*target))
|
||||
for _, v := range *target {
|
||||
if strings.TrimSpace(v) != val {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
*target = out
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing index or value"})
|
||||
}
|
||||
|
||||
func sanitizeStringSlice(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for i := range in {
|
||||
if trimmed := strings.TrimSpace(in[i]); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func geminiKeyStringsFromConfig(cfg *config.Config) []string {
|
||||
if cfg == nil || len(cfg.GeminiKey) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(cfg.GeminiKey))
|
||||
for i := range cfg.GeminiKey {
|
||||
if key := strings.TrimSpace(cfg.GeminiKey[i].APIKey); key != "" {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) applyLegacyKeys(keys []string) {
|
||||
if h == nil || h.cfg == nil {
|
||||
return
|
||||
}
|
||||
sanitized := sanitizeStringSlice(keys)
|
||||
existing := make(map[string]config.GeminiKey, len(h.cfg.GeminiKey))
|
||||
for _, entry := range h.cfg.GeminiKey {
|
||||
if key := strings.TrimSpace(entry.APIKey); key != "" {
|
||||
existing[key] = entry
|
||||
}
|
||||
}
|
||||
newList := make([]config.GeminiKey, 0, len(sanitized))
|
||||
for _, key := range sanitized {
|
||||
if entry, ok := existing[key]; ok {
|
||||
newList = append(newList, entry)
|
||||
} else {
|
||||
newList = append(newList, config.GeminiKey{APIKey: key})
|
||||
}
|
||||
}
|
||||
h.cfg.GeminiKey = newList
|
||||
h.cfg.GlAPIKey = sanitized
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
}
|
||||
|
||||
// api-keys
|
||||
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
||||
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
||||
h.putStringList(c, func(v []string) {
|
||||
h.cfg.APIKeys = append([]string(nil), v...)
|
||||
h.cfg.Access.Providers = nil
|
||||
}, nil)
|
||||
}
|
||||
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
||||
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
||||
}
|
||||
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
||||
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
||||
}
|
||||
|
||||
// generative-language-api-key
|
||||
func (h *Handler) GetGlKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"generative-language-api-key": geminiKeyStringsFromConfig(h.cfg)})
|
||||
}
|
||||
func (h *Handler) PutGlKeys(c *gin.Context) {
|
||||
h.putStringList(c, func(v []string) {
|
||||
h.applyLegacyKeys(v)
|
||||
}, nil)
|
||||
}
|
||||
func (h *Handler) PatchGlKeys(c *gin.Context) {
|
||||
target := append([]string(nil), geminiKeyStringsFromConfig(h.cfg)...)
|
||||
h.patchStringList(c, &target, func() { h.applyLegacyKeys(target) })
|
||||
}
|
||||
func (h *Handler) DeleteGlKeys(c *gin.Context) {
|
||||
target := append([]string(nil), geminiKeyStringsFromConfig(h.cfg)...)
|
||||
h.deleteFromStringList(c, &target, func() { h.applyLegacyKeys(target) })
|
||||
}
|
||||
|
||||
// gemini-api-key: []GeminiKey
|
||||
func (h *Handler) GetGeminiKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
|
||||
}
|
||||
func (h *Handler) PutGeminiKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var arr []config.GeminiKey
|
||||
if err = json.Unmarshal(data, &arr); err != nil {
|
||||
var obj struct {
|
||||
Items []config.GeminiKey `json:"items"`
|
||||
}
|
||||
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
}
|
||||
func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Index *int `json:"index"`
|
||||
Match *string `json:"match"`
|
||||
Value *config.GeminiKey `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
value := *body.Value
|
||||
value.APIKey = strings.TrimSpace(value.APIKey)
|
||||
value.BaseURL = strings.TrimSpace(value.BaseURL)
|
||||
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
|
||||
if value.APIKey == "" {
|
||||
// Treat empty API key as delete.
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:*body.Index], h.cfg.GeminiKey[*body.Index+1:]...)
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
match := strings.TrimSpace(*body.Match)
|
||||
if match != "" {
|
||||
out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
|
||||
removed := false
|
||||
for i := range h.cfg.GeminiKey {
|
||||
if !removed && h.cfg.GeminiKey[i].APIKey == match {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, h.cfg.GeminiKey[i])
|
||||
}
|
||||
if removed {
|
||||
h.cfg.GeminiKey = out
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey[*body.Index] = value
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
match := strings.TrimSpace(*body.Match)
|
||||
for i := range h.cfg.GeminiKey {
|
||||
if h.cfg.GeminiKey[i].APIKey == match {
|
||||
h.cfg.GeminiKey[i] = value
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
|
||||
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 {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
if len(out) != len(h.cfg.GeminiKey) {
|
||||
h.cfg.GeminiKey = out
|
||||
h.cfg.SanitizeGeminiKeys()
|
||||
h.persist(c)
|
||||
} else {
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
var idx int
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
||||
}
|
||||
|
||||
// claude-api-key: []ClaudeKey
|
||||
func (h *Handler) GetClaudeKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
|
||||
}
|
||||
func (h *Handler) PutClaudeKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var arr []config.ClaudeKey
|
||||
if err = json.Unmarshal(data, &arr); err != nil {
|
||||
var obj struct {
|
||||
Items []config.ClaudeKey `json:"items"`
|
||||
}
|
||||
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
for i := range arr {
|
||||
normalizeClaudeKey(&arr[i])
|
||||
}
|
||||
h.cfg.ClaudeKey = arr
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
}
|
||||
func (h *Handler) PatchClaudeKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Index *int `json:"index"`
|
||||
Match *string `json:"match"`
|
||||
Value *config.ClaudeKey `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
value := *body.Value
|
||||
normalizeClaudeKey(&value)
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
|
||||
h.cfg.ClaudeKey[*body.Index] = value
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
for i := range h.cfg.ClaudeKey {
|
||||
if h.cfg.ClaudeKey[i].APIKey == *body.Match {
|
||||
h.cfg.ClaudeKey[i] = value
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
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 {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
h.cfg.ClaudeKey = out
|
||||
h.cfg.SanitizeClaudeKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
var idx int
|
||||
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
||||
}
|
||||
|
||||
// openai-compatibility: []OpenAICompatibility
|
||||
func (h *Handler) GetOpenAICompat(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
|
||||
}
|
||||
func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var arr []config.OpenAICompatibility
|
||||
if err = json.Unmarshal(data, &arr); err != nil {
|
||||
var obj struct {
|
||||
Items []config.OpenAICompatibility `json:"items"`
|
||||
}
|
||||
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
for i := range arr {
|
||||
normalizeOpenAICompatibilityEntry(&arr[i])
|
||||
}
|
||||
// Filter out providers with empty base-url -> remove provider entirely
|
||||
filtered := make([]config.OpenAICompatibility, 0, len(arr))
|
||||
for i := range arr {
|
||||
if strings.TrimSpace(arr[i].BaseURL) != "" {
|
||||
filtered = append(filtered, arr[i])
|
||||
}
|
||||
}
|
||||
h.cfg.OpenAICompatibility = filtered
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
}
|
||||
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Index *int `json:"index"`
|
||||
Value *config.OpenAICompatibility `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
normalizeOpenAICompatibilityEntry(body.Value)
|
||||
// If base-url becomes empty, delete the provider instead of updating
|
||||
if strings.TrimSpace(body.Value.BaseURL) == "" {
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:*body.Index], h.cfg.OpenAICompatibility[*body.Index+1:]...)
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
||||
removed := false
|
||||
for i := range h.cfg.OpenAICompatibility {
|
||||
if !removed && h.cfg.OpenAICompatibility[i].Name == *body.Name {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, h.cfg.OpenAICompatibility[i])
|
||||
}
|
||||
if removed {
|
||||
h.cfg.OpenAICompatibility = out
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||
h.cfg.OpenAICompatibility[*body.Index] = *body.Value
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
for i := range h.cfg.OpenAICompatibility {
|
||||
if h.cfg.OpenAICompatibility[i].Name == *body.Name {
|
||||
h.cfg.OpenAICompatibility[i] = *body.Value
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
||||
if name := c.Query("name"); name != "" {
|
||||
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
||||
for _, v := range h.cfg.OpenAICompatibility {
|
||||
if v.Name != name {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
h.cfg.OpenAICompatibility = out
|
||||
h.cfg.SanitizeOpenAICompatibility()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
var idx int
|
||||
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing name or index"})
|
||||
}
|
||||
|
||||
// codex-api-key: []CodexKey
|
||||
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
||||
}
|
||||
func (h *Handler) PutCodexKeys(c *gin.Context) {
|
||||
data, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var arr []config.CodexKey
|
||||
if err = json.Unmarshal(data, &arr); err != nil {
|
||||
var obj struct {
|
||||
Items []config.CodexKey `json:"items"`
|
||||
}
|
||||
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
// Filter out codex entries with empty base-url (treat as removed)
|
||||
filtered := make([]config.CodexKey, 0, len(arr))
|
||||
for i := range arr {
|
||||
entry := arr[i]
|
||||
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||
if entry.BaseURL == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
h.cfg.CodexKey = filtered
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
}
|
||||
func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
var body struct {
|
||||
Index *int `json:"index"`
|
||||
Match *string `json:"match"`
|
||||
Value *config.CodexKey `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
value := *body.Value
|
||||
value.APIKey = strings.TrimSpace(value.APIKey)
|
||||
value.BaseURL = strings.TrimSpace(value.BaseURL)
|
||||
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
|
||||
value.Headers = config.NormalizeHeaders(value.Headers)
|
||||
// If base-url becomes empty, delete instead of update
|
||||
if value.BaseURL == "" {
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||
h.cfg.CodexKey = append(h.cfg.CodexKey[:*body.Index], h.cfg.CodexKey[*body.Index+1:]...)
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||
removed := false
|
||||
for i := range h.cfg.CodexKey {
|
||||
if !removed && h.cfg.CodexKey[i].APIKey == *body.Match {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, h.cfg.CodexKey[i])
|
||||
}
|
||||
if removed {
|
||||
h.cfg.CodexKey = out
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||
h.cfg.CodexKey[*body.Index] = value
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
for i := range h.cfg.CodexKey {
|
||||
if h.cfg.CodexKey[i].APIKey == *body.Match {
|
||||
h.cfg.CodexKey[i] = value
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
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 {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
h.cfg.CodexKey = out
|
||||
h.cfg.SanitizeCodexKeys()
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if idxStr := c.Query("index"); idxStr != "" {
|
||||
var idx int
|
||||
_, err := fmt.Sscanf(idxStr, "%d", &idx)
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(400, gin.H{"error": "missing api-key or index"})
|
||||
}
|
||||
|
||||
func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
// Trim base-url; empty base-url indicates provider should be removed by sanitization
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
|
||||
for i := range entry.APIKeyEntries {
|
||||
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
|
||||
entry.APIKeyEntries[i].APIKey = trimmed
|
||||
if trimmed != "" {
|
||||
existing[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(entry.APIKeys) == 0 {
|
||||
return
|
||||
}
|
||||
for _, legacyKey := range entry.APIKeys {
|
||||
trimmed := strings.TrimSpace(legacyKey)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := existing[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
entry.APIKeyEntries = append(entry.APIKeyEntries, config.OpenAICompatibilityAPIKey{APIKey: trimmed})
|
||||
existing[trimmed] = struct{}{}
|
||||
}
|
||||
entry.APIKeys = nil
|
||||
}
|
||||
|
||||
func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]config.OpenAICompatibility, len(entries))
|
||||
for i := range entries {
|
||||
copyEntry := entries[i]
|
||||
if len(copyEntry.APIKeyEntries) > 0 {
|
||||
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
|
||||
}
|
||||
if len(copyEntry.APIKeys) > 0 {
|
||||
copyEntry.APIKeys = append([]string(nil), copyEntry.APIKeys...)
|
||||
}
|
||||
normalizeOpenAICompatibilityEntry(©Entry)
|
||||
out[i] = copyEntry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeClaudeKey(entry *config.ClaudeKey) {
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||
if len(entry.Models) == 0 {
|
||||
return
|
||||
}
|
||||
normalized := make([]config.ClaudeModel, 0, len(entry.Models))
|
||||
for i := range entry.Models {
|
||||
model := entry.Models[i]
|
||||
model.Name = strings.TrimSpace(model.Name)
|
||||
model.Alias = strings.TrimSpace(model.Alias)
|
||||
if model.Name == "" && model.Alias == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, model)
|
||||
}
|
||||
entry.Models = normalized
|
||||
}
|
||||
282
internal/api/handlers/management/handler.go
Normal file
282
internal/api/handlers/management/handler.go
Normal file
@@ -0,0 +1,282 @@
|
||||
// Package management provides the management API handlers and middleware
|
||||
// for configuring the server and managing auth files.
|
||||
package management
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type attemptInfo struct {
|
||||
count int
|
||||
blockedUntil time.Time
|
||||
}
|
||||
|
||||
// Handler aggregates config reference, persistence path and helpers.
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
configFilePath string
|
||||
mu sync.Mutex
|
||||
attemptsMu sync.Mutex
|
||||
failedAttempts map[string]*attemptInfo // keyed by client IP
|
||||
authManager *coreauth.Manager
|
||||
usageStats *usage.RequestStatistics
|
||||
tokenStore coreauth.Store
|
||||
localPassword string
|
||||
allowRemoteOverride bool
|
||||
envSecret string
|
||||
logDir string
|
||||
}
|
||||
|
||||
// NewHandler creates a new management handler instance.
|
||||
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
|
||||
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
|
||||
envSecret = strings.TrimSpace(envSecret)
|
||||
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
configFilePath: configFilePath,
|
||||
failedAttempts: make(map[string]*attemptInfo),
|
||||
authManager: manager,
|
||||
usageStats: usage.GetRequestStatistics(),
|
||||
tokenStore: sdkAuth.GetTokenStore(),
|
||||
allowRemoteOverride: envSecret != "",
|
||||
envSecret: envSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfig updates the in-memory config reference when the server hot-reloads.
|
||||
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
|
||||
|
||||
// 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 }
|
||||
|
||||
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
|
||||
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
|
||||
|
||||
// SetLogDirectory updates the directory where main.log should be looked up.
|
||||
func (h *Handler) SetLogDirectory(dir string) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
if !filepath.IsAbs(dir) {
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
dir = abs
|
||||
}
|
||||
}
|
||||
h.logDir = dir
|
||||
}
|
||||
|
||||
// Middleware enforces access control for management endpoints.
|
||||
// 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)
|
||||
c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate)
|
||||
|
||||
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++
|
||||
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
|
||||
if ah := c.GetHeader("Authorization"); ah != "" {
|
||||
parts := strings.SplitN(ah, " ", 2)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
provided = parts[1]
|
||||
} else {
|
||||
provided = ah
|
||||
}
|
||||
}
|
||||
if provided == "" {
|
||||
provided = c.GetHeader("X-Management-Key")
|
||||
}
|
||||
|
||||
if provided == "" {
|
||||
if !localClient {
|
||||
fail()
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// persist saves the current in-memory config to disk.
|
||||
func (h *Handler) persist(c *gin.Context) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// 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)})
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper methods for simple types
|
||||
func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
|
||||
var body struct {
|
||||
Value *bool `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
var m map[string]any
|
||||
if err2 := c.ShouldBindJSON(&m); err2 == nil {
|
||||
for _, v := range m {
|
||||
if b, ok := v.(bool); ok {
|
||||
set(b)
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
set(*body.Value)
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
|
||||
var body struct {
|
||||
Value *int `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
set(*body.Value)
|
||||
h.persist(c)
|
||||
}
|
||||
|
||||
func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
|
||||
var body struct {
|
||||
Value *string `json:"value"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
set(*body.Value)
|
||||
h.persist(c)
|
||||
}
|
||||
348
internal/api/handlers/management/logs.go
Normal file
348
internal/api/handlers/management/logs.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogFileName = "main.log"
|
||||
logScannerInitialBuffer = 64 * 1024
|
||||
logScannerMaxBuffer = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
// GetLogs returns log lines with optional incremental loading.
|
||||
func (h *Handler) GetLogs(c *gin.Context) {
|
||||
if h == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
||||
return
|
||||
}
|
||||
if h.cfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
||||
return
|
||||
}
|
||||
if !h.cfg.LoggingToFile {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
logDir := h.logDirectory()
|
||||
if strings.TrimSpace(logDir) == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.collectLogFiles(logDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cutoff := parseCutoff(c.Query("after"))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lines": []string{},
|
||||
"line-count": 0,
|
||||
"latest-timestamp": cutoff,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log files: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := parseCutoff(c.Query("after"))
|
||||
acc := newLogAccumulator(cutoff)
|
||||
for i := range files {
|
||||
if errProcess := acc.consumeFile(files[i]); errProcess != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file %s: %v", files[i], errProcess)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lines, total, latest := acc.result()
|
||||
if latest == 0 || latest < cutoff {
|
||||
latest = cutoff
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lines": lines,
|
||||
"line-count": total,
|
||||
"latest-timestamp": latest,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteLogs removes all rotated log files and truncates the active log.
|
||||
func (h *Handler) DeleteLogs(c *gin.Context) {
|
||||
if h == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
|
||||
return
|
||||
}
|
||||
if h.cfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"})
|
||||
return
|
||||
}
|
||||
if !h.cfg.LoggingToFile {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logging to file disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
dir := h.logDirectory()
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
removed := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
fullPath := filepath.Join(dir, name)
|
||||
if name == defaultLogFileName {
|
||||
if errTrunc := os.Truncate(fullPath, 0); errTrunc != nil && !os.IsNotExist(errTrunc) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to truncate log file: %v", errTrunc)})
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isRotatedLogFile(name) {
|
||||
if errRemove := os.Remove(fullPath); errRemove != nil && !os.IsNotExist(errRemove) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to remove %s: %v", name, errRemove)})
|
||||
return
|
||||
}
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Logs cleared successfully",
|
||||
"removed": removed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) logDirectory() string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
if h.logDir != "" {
|
||||
return h.logDir
|
||||
}
|
||||
if base := util.WritablePath(); base != "" {
|
||||
return filepath.Join(base, "logs")
|
||||
}
|
||||
if h.configFilePath != "" {
|
||||
dir := filepath.Dir(h.configFilePath)
|
||||
if dir != "" && dir != "." {
|
||||
return filepath.Join(dir, "logs")
|
||||
}
|
||||
}
|
||||
return "logs"
|
||||
}
|
||||
|
||||
func (h *Handler) collectLogFiles(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
order int64
|
||||
}
|
||||
cands := make([]candidate, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if name == defaultLogFileName {
|
||||
cands = append(cands, candidate{path: filepath.Join(dir, name), order: 0})
|
||||
continue
|
||||
}
|
||||
if order, ok := rotationOrder(name); ok {
|
||||
cands = append(cands, candidate{path: filepath.Join(dir, name), order: order})
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].order < cands[j].order })
|
||||
paths := make([]string, 0, len(cands))
|
||||
for i := len(cands) - 1; i >= 0; i-- {
|
||||
paths = append(paths, cands[i].path)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
type logAccumulator struct {
|
||||
cutoff int64
|
||||
lines []string
|
||||
total int
|
||||
latest int64
|
||||
include bool
|
||||
}
|
||||
|
||||
func newLogAccumulator(cutoff int64) *logAccumulator {
|
||||
return &logAccumulator{
|
||||
cutoff: cutoff,
|
||||
lines: make([]string, 0, 256),
|
||||
}
|
||||
}
|
||||
|
||||
func (acc *logAccumulator) consumeFile(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
buf := make([]byte, 0, logScannerInitialBuffer)
|
||||
scanner.Buffer(buf, logScannerMaxBuffer)
|
||||
for scanner.Scan() {
|
||||
acc.addLine(scanner.Text())
|
||||
}
|
||||
if errScan := scanner.Err(); errScan != nil {
|
||||
return errScan
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (acc *logAccumulator) addLine(raw string) {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
acc.total++
|
||||
ts := parseTimestamp(line)
|
||||
if ts > acc.latest {
|
||||
acc.latest = ts
|
||||
}
|
||||
if ts > 0 {
|
||||
acc.include = acc.cutoff == 0 || ts > acc.cutoff
|
||||
if acc.cutoff == 0 || acc.include {
|
||||
acc.lines = append(acc.lines, line)
|
||||
}
|
||||
return
|
||||
}
|
||||
if acc.cutoff == 0 || acc.include {
|
||||
acc.lines = append(acc.lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
func (acc *logAccumulator) result() ([]string, int, int64) {
|
||||
if acc.lines == nil {
|
||||
acc.lines = []string{}
|
||||
}
|
||||
return acc.lines, acc.total, acc.latest
|
||||
}
|
||||
|
||||
func parseCutoff(raw string) int64 {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
ts, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || ts <= 0 {
|
||||
return 0
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func parseTimestamp(line string) int64 {
|
||||
if strings.HasPrefix(line, "[") {
|
||||
line = line[1:]
|
||||
}
|
||||
if len(line) < 19 {
|
||||
return 0
|
||||
}
|
||||
candidate := line[:19]
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", candidate, time.Local)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func isRotatedLogFile(name string) bool {
|
||||
if _, ok := rotationOrder(name); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rotationOrder(name string) (int64, bool) {
|
||||
if order, ok := numericRotationOrder(name); ok {
|
||||
return order, true
|
||||
}
|
||||
if order, ok := timestampRotationOrder(name); ok {
|
||||
return order, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func numericRotationOrder(name string) (int64, bool) {
|
||||
if !strings.HasPrefix(name, defaultLogFileName+".") {
|
||||
return 0, false
|
||||
}
|
||||
suffix := strings.TrimPrefix(name, defaultLogFileName+".")
|
||||
if suffix == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(suffix)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return int64(n), true
|
||||
}
|
||||
|
||||
func timestampRotationOrder(name string) (int64, bool) {
|
||||
ext := filepath.Ext(defaultLogFileName)
|
||||
base := strings.TrimSuffix(defaultLogFileName, ext)
|
||||
if base == "" {
|
||||
return 0, false
|
||||
}
|
||||
prefix := base + "-"
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
clean := strings.TrimPrefix(name, prefix)
|
||||
if strings.HasSuffix(clean, ".gz") {
|
||||
clean = strings.TrimSuffix(clean, ".gz")
|
||||
}
|
||||
if ext != "" {
|
||||
if !strings.HasSuffix(clean, ext) {
|
||||
return 0, false
|
||||
}
|
||||
clean = strings.TrimSuffix(clean, ext)
|
||||
}
|
||||
if clean == "" {
|
||||
return 0, false
|
||||
}
|
||||
if idx := strings.IndexByte(clean, '.'); idx != -1 {
|
||||
clean = clean[:idx]
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02T15-04-05", clean, time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return math.MaxInt64 - parsed.Unix(), true
|
||||
}
|
||||
18
internal/api/handlers/management/quota.go
Normal file
18
internal/api/handlers/management/quota.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package management
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Quota exceeded toggles
|
||||
func (h *Handler) GetSwitchProject(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
|
||||
}
|
||||
func (h *Handler) PutSwitchProject(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
|
||||
}
|
||||
|
||||
func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
|
||||
}
|
||||
func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
|
||||
}
|
||||
20
internal/api/handlers/management/usage.go
Normal file
20
internal/api/handlers/management/usage.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/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()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"usage": snapshot,
|
||||
"failed_requests": snapshot.FailureCount,
|
||||
})
|
||||
}
|
||||
156
internal/api/handlers/management/vertex_import.go
Normal file
156
internal/api/handlers/management/vertex_import.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.
|
||||
func (h *Handler) ImportVertexCredential(c *gin.Context) {
|
||||
if h == nil || h.cfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "config unavailable"})
|
||||
return
|
||||
}
|
||||
if h.cfg.AuthDir == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth directory not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
var serviceAccount map[string]any
|
||||
if err := json.Unmarshal(data, &serviceAccount); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedSA, err := vertex.NormalizeServiceAccountMap(serviceAccount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service account", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
serviceAccount = normalizedSA
|
||||
|
||||
projectID := strings.TrimSpace(valueAsString(serviceAccount["project_id"]))
|
||||
if projectID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project_id missing"})
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(valueAsString(serviceAccount["client_email"]))
|
||||
|
||||
location := strings.TrimSpace(c.PostForm("location"))
|
||||
if location == "" {
|
||||
location = strings.TrimSpace(c.Query("location"))
|
||||
}
|
||||
if location == "" {
|
||||
location = "us-central1"
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("vertex-%s.json", sanitizeVertexFilePart(projectID))
|
||||
label := labelForVertex(projectID, email)
|
||||
storage := &vertex.VertexCredentialStorage{
|
||||
ServiceAccount: serviceAccount,
|
||||
ProjectID: projectID,
|
||||
Email: email,
|
||||
Location: location,
|
||||
Type: "vertex",
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"service_account": serviceAccount,
|
||||
"project_id": projectID,
|
||||
"email": email,
|
||||
"location": location,
|
||||
"type": "vertex",
|
||||
"label": label,
|
||||
}
|
||||
record := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "vertex",
|
||||
FileName: fileName,
|
||||
Storage: storage,
|
||||
Label: label,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if reqCtx := c.Request.Context(); reqCtx != nil {
|
||||
ctx = reqCtx
|
||||
}
|
||||
savedPath, err := h.saveTokenRecord(ctx, record)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "save_failed", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"auth-file": savedPath,
|
||||
"project_id": projectID,
|
||||
"email": email,
|
||||
"location": location,
|
||||
})
|
||||
}
|
||||
|
||||
func valueAsString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeVertexFilePart(s string) string {
|
||||
out := strings.TrimSpace(s)
|
||||
replacers := []string{"/", "_", "\\", "_", ":", "_", " ", "-"}
|
||||
for i := 0; i < len(replacers); i += 2 {
|
||||
out = strings.ReplaceAll(out, replacers[i], replacers[i+1])
|
||||
}
|
||||
if out == "" {
|
||||
return "vertex"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func labelForVertex(projectID, email string) string {
|
||||
p := strings.TrimSpace(projectID)
|
||||
e := strings.TrimSpace(email)
|
||||
if p != "" && e != "" {
|
||||
return fmt.Sprintf("%s (%s)", p, e)
|
||||
}
|
||||
if p != "" {
|
||||
return p
|
||||
}
|
||||
if e != "" {
|
||||
return e
|
||||
}
|
||||
return "vertex"
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Package openai provides HTTP handlers for OpenAI API endpoints.
|
||||
// This package implements the OpenAI-compatible API interface, including model listing
|
||||
// and chat completion functionality. It supports both streaming and non-streaming responses,
|
||||
// and manages a pool of clients to interact with backend services.
|
||||
// The handlers translate OpenAI API requests to the appropriate backend format and
|
||||
// convert responses back to OpenAI-compatible format.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
translatorOpenAIToCodex "github.com/luispater/CLIProxyAPI/internal/translator/codex/openai"
|
||||
translatorOpenAIToGeminiCli "github.com/luispater/CLIProxyAPI/internal/translator/gemini-cli/openai"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenAIAPIHandlers contains the handlers for OpenAI API endpoints.
|
||||
// It holds a pool of clients to interact with the backend service.
|
||||
type OpenAIAPIHandlers struct {
|
||||
*handlers.APIHandlers
|
||||
}
|
||||
|
||||
// NewOpenAIAPIHandlers creates a new OpenAI API handlers instance.
|
||||
// It takes an APIHandlers instance as input and returns an OpenAIAPIHandlers.
|
||||
//
|
||||
// Parameters:
|
||||
// - apiHandlers: The base API handlers instance
|
||||
//
|
||||
// Returns:
|
||||
// - *OpenAIAPIHandlers: A new OpenAI API handlers instance
|
||||
func NewOpenAIAPIHandlers(apiHandlers *handlers.APIHandlers) *OpenAIAPIHandlers {
|
||||
return &OpenAIAPIHandlers{
|
||||
APIHandlers: apiHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
// Models handles the /v1/models endpoint.
|
||||
// It returns a hardcoded list of available AI models with their capabilities
|
||||
// and specifications in OpenAI-compatible format.
|
||||
func (h *OpenAIAPIHandlers) Models(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []map[string]any{
|
||||
{
|
||||
"id": "gemini-2.5-pro",
|
||||
"object": "model",
|
||||
"version": "2.5",
|
||||
"name": "Gemini 2.5 Pro",
|
||||
"description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
|
||||
"context_length": 1_048_576,
|
||||
"max_completion_tokens": 65_536,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
},
|
||||
{
|
||||
"id": "gemini-2.5-flash",
|
||||
"object": "model",
|
||||
"version": "001",
|
||||
"name": "Gemini 2.5 Flash",
|
||||
"description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
|
||||
"context_length": 1_048_576,
|
||||
"max_completion_tokens": 65_536,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
},
|
||||
{
|
||||
"id": "gpt-5",
|
||||
"object": "model",
|
||||
"version": "gpt-5-2025-08-07",
|
||||
"name": "GPT 5",
|
||||
"description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.",
|
||||
"context_length": 400_000,
|
||||
"max_completion_tokens": 128_000,
|
||||
"supported_parameters": []string{
|
||||
"tools",
|
||||
},
|
||||
"temperature": 1,
|
||||
"topP": 0.95,
|
||||
"topK": 64,
|
||||
"maxTemperature": 2,
|
||||
"thinking": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ChatCompletions handles the /v1/chat/completions endpoint.
|
||||
// It determines whether the request is for a streaming or non-streaming response
|
||||
// and calls the appropriate handler based on the model provider.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: The Gin context containing the HTTP request and response
|
||||
func (h *OpenAIAPIHandlers) ChatCompletions(c *gin.Context) {
|
||||
rawJSON, err := c.GetRawData()
|
||||
// If data retrieval fails, return a 400 Bad Request error.
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: fmt.Sprintf("Invalid request: %v", err),
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the client requested a streaming response.
|
||||
streamResult := gjson.GetBytes(rawJSON, "stream")
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
provider := util.GetProviderName(modelName.String())
|
||||
if provider == "gemini" {
|
||||
if streamResult.Type == gjson.True {
|
||||
h.handleGeminiStreamingResponse(c, rawJSON)
|
||||
} else {
|
||||
h.handleGeminiNonStreamingResponse(c, rawJSON)
|
||||
}
|
||||
} else if provider == "gpt" {
|
||||
if streamResult.Type == gjson.True {
|
||||
h.handleCodexStreamingResponse(c, rawJSON)
|
||||
} else {
|
||||
h.handleCodexNonStreamingResponse(c, rawJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleGeminiNonStreamingResponse handles non-streaming chat completion responses
|
||||
// for Gemini models. It selects a client from the pool, sends the request, and
|
||||
// aggregates the response before sending it back to the client in OpenAI format.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: The Gin context containing the HTTP request and response
|
||||
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
|
||||
func (h *OpenAIAPIHandlers) handleGeminiNonStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
|
||||
modelName, systemInstruction, contents, tools := translatorOpenAIToGeminiCli.ConvertOpenAIChatRequestToCli(rawJSON)
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
isGlAPIKey := false
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
isGlAPIKey = true
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.(*client.GeminiClient).GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
|
||||
resp, err := cliClient.SendMessage(cliCtx, rawJSON, modelName, systemInstruction, contents, tools)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = c.Writer.Write([]byte(err.Error.Error()))
|
||||
cliCancel()
|
||||
}
|
||||
break
|
||||
} else {
|
||||
openAIFormat := translatorOpenAIToGeminiCli.ConvertCliResponseToOpenAIChatNonStream(resp, time.Now().Unix(), isGlAPIKey)
|
||||
if openAIFormat != "" {
|
||||
_, _ = c.Writer.Write([]byte(openAIFormat))
|
||||
}
|
||||
cliCancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleGeminiStreamingResponse handles streaming responses for Gemini models.
|
||||
// It establishes a streaming connection with the backend service and forwards
|
||||
// the response chunks to the client in real-time using Server-Sent Events.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: The Gin context containing the HTTP request and response
|
||||
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
|
||||
func (h *OpenAIAPIHandlers) handleGeminiStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the request for the backend client.
|
||||
modelName, systemInstruction, contents, tools := translatorOpenAIToGeminiCli.ConvertOpenAIChatRequestToCli(rawJSON)
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName)
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
isGlAPIKey := false
|
||||
if glAPIKey := cliClient.(*client.GeminiClient).GetGenerativeLanguageAPIKey(); glAPIKey != "" {
|
||||
log.Debugf("Request use generative language API Key: %s", glAPIKey)
|
||||
isGlAPIKey = true
|
||||
} else {
|
||||
log.Debugf("Request cli use account: %s, project id: %s", cliClient.GetEmail(), cliClient.(*client.GeminiClient).GetProjectID())
|
||||
}
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJSON, modelName, systemInstruction, contents, tools)
|
||||
hasFirstResponse := false
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("GeminiClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
// Stream is closed, send the final [DONE] message.
|
||||
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
// Convert the chunk to OpenAI format and send it to the client.
|
||||
hasFirstResponse = true
|
||||
openAIFormat := translatorOpenAIToGeminiCli.ConvertCliResponseToOpenAIChat(chunk, time.Now().Unix(), isGlAPIKey)
|
||||
if openAIFormat != "" {
|
||||
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", openAIFormat)
|
||||
flusher.Flush()
|
||||
}
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
if hasFirstResponse {
|
||||
_, _ = c.Writer.Write([]byte(": CLI-PROXY-API PROCESSING\n\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleCodexNonStreamingResponse handles non-streaming chat completion responses
|
||||
// for OpenAI models. It selects a client from the pool, sends the request, and
|
||||
// aggregates the response before sending it back to the client in OpenAI format.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: The Gin context containing the HTTP request and response
|
||||
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
|
||||
func (h *OpenAIAPIHandlers) handleCodexNonStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
|
||||
newRequestJSON := translatorOpenAIToCodex.ConvertOpenAIChatRequestToCodex(rawJSON)
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName.String())
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = c.Writer.Write([]byte(errorResponse.Error.Error()))
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request codex use account: %s", cliClient.GetEmail())
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
data := gjson.ParseBytes(jsonData)
|
||||
typeResult := data.Get("type")
|
||||
if typeResult.String() == "response.completed" {
|
||||
responseResult := data.Get("response")
|
||||
openaiStr := translatorOpenAIToCodex.ConvertCodexResponseToOpenAIChatNonStream(responseResult.Raw, time.Now().Unix())
|
||||
_, _ = c.Writer.Write([]byte(openaiStr))
|
||||
}
|
||||
}
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = c.Writer.Write([]byte(err.Error.Error()))
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleCodexStreamingResponse handles streaming responses for OpenAI models.
|
||||
// It establishes a streaming connection with the backend service and forwards
|
||||
// the response chunks to the client in real-time using Server-Sent Events.
|
||||
//
|
||||
// Parameters:
|
||||
// - c: The Gin context containing the HTTP request and response
|
||||
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
|
||||
func (h *OpenAIAPIHandlers) handleCodexStreamingResponse(c *gin.Context, rawJSON []byte) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Get the http.Flusher interface to manually flush the response.
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
|
||||
Error: handlers.ErrorDetail{
|
||||
Message: "Streaming not supported",
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the request for the backend client.
|
||||
newRequestJSON := translatorOpenAIToCodex.ConvertOpenAIChatRequestToCodex(rawJSON)
|
||||
// log.Debugf("Request: %s", newRequestJSON)
|
||||
|
||||
modelName := gjson.GetBytes(rawJSON, "model")
|
||||
|
||||
cliCtx, cliCancel := context.WithCancel(context.Background())
|
||||
var cliClient client.Client
|
||||
defer func() {
|
||||
// Ensure the client's mutex is unlocked on function exit.
|
||||
if cliClient != nil {
|
||||
cliClient.GetRequestMutex().Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
outLoop:
|
||||
for {
|
||||
var errorResponse *client.ErrorMessage
|
||||
cliClient, errorResponse = h.GetClient(modelName.String())
|
||||
if errorResponse != nil {
|
||||
c.Status(errorResponse.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Request codex use account: %s", cliClient.GetEmail())
|
||||
|
||||
// Send the message and receive response chunks and errors via channels.
|
||||
var params *translatorOpenAIToCodex.ConvertCliToOpenAIParams
|
||||
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, []byte(newRequestJSON), "")
|
||||
for {
|
||||
select {
|
||||
// Handle client disconnection.
|
||||
case <-c.Request.Context().Done():
|
||||
if c.Request.Context().Err().Error() == "context canceled" {
|
||||
log.Debugf("CodexClient disconnected: %v", c.Request.Context().Err())
|
||||
cliCancel() // Cancel the backend request.
|
||||
return
|
||||
}
|
||||
// Process incoming response chunks.
|
||||
case chunk, okStream := <-respChan:
|
||||
if !okStream {
|
||||
_, _ = c.Writer.Write([]byte("[done]\n\n"))
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
return
|
||||
}
|
||||
// log.Debugf("Response: %s\n", string(chunk))
|
||||
// Convert the chunk to OpenAI format and send it to the client.
|
||||
if bytes.HasPrefix(chunk, []byte("data: ")) {
|
||||
jsonData := chunk[6:]
|
||||
data := gjson.ParseBytes(jsonData)
|
||||
typeResult := data.Get("type")
|
||||
if typeResult.String() != "" {
|
||||
var openaiStr string
|
||||
params, openaiStr = translatorOpenAIToCodex.ConvertCodexResponseToOpenAIChat(jsonData, params)
|
||||
if openaiStr != "" {
|
||||
_, _ = c.Writer.Write([]byte("data: "))
|
||||
_, _ = c.Writer.Write([]byte(openaiStr))
|
||||
_, _ = c.Writer.Write([]byte("\n\n"))
|
||||
}
|
||||
}
|
||||
// log.Debugf(string(jsonData))
|
||||
}
|
||||
flusher.Flush()
|
||||
// Handle errors from the backend.
|
||||
case err, okError := <-errChan:
|
||||
if okError {
|
||||
if err.StatusCode == 429 && h.Cfg.QuotaExceeded.SwitchProject {
|
||||
continue outLoop
|
||||
} else {
|
||||
c.Status(err.StatusCode)
|
||||
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
|
||||
flusher.Flush()
|
||||
cliCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Send a keep-alive signal to the client.
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,30 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
)
|
||||
|
||||
// RequestLoggingMiddleware creates a Gin middleware function that logs HTTP requests and responses
|
||||
// when enabled through the provided logger. The middleware has zero overhead when logging is disabled.
|
||||
// RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.
|
||||
// It captures detailed information about the request and response, including headers and body,
|
||||
// and uses the provided RequestLogger to record this data. If logging is disabled in the
|
||||
// logger, the middleware has minimal overhead.
|
||||
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
shouldLog := false
|
||||
if strings.HasPrefix(path, "/v1") {
|
||||
shouldLog = true
|
||||
}
|
||||
|
||||
if !shouldLog {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Early return if logging is disabled (zero overhead)
|
||||
if !logger.IsEnabled() {
|
||||
c.Next()
|
||||
@@ -38,22 +53,22 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
|
||||
c.Next()
|
||||
|
||||
// Finalize logging after request processing
|
||||
if err := wrapper.Finalize(); err != nil {
|
||||
if err = wrapper.Finalize(c); err != nil {
|
||||
// Log error but don't interrupt the response
|
||||
// In a real implementation, you might want to use a proper logger here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureRequestInfo extracts and captures request information for logging.
|
||||
// captureRequestInfo extracts relevant information from the incoming HTTP request.
|
||||
// It captures the URL, method, headers, and body. The request body is read and then
|
||||
// restored so that it can be processed by subsequent handlers.
|
||||
func captureRequestInfo(c *gin.Context) (*RequestInfo, error) {
|
||||
// Capture URL
|
||||
url := c.Request.URL.String()
|
||||
if c.Request.URL.Path != "" {
|
||||
url = c.Request.URL.Path
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
url += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
// Capture URL with sensitive query parameters masked
|
||||
maskedQuery := util.MaskSensitiveQuery(c.Request.URL.RawQuery)
|
||||
url := c.Request.URL.Path
|
||||
if maskedQuery != "" {
|
||||
url += "?" + maskedQuery
|
||||
}
|
||||
|
||||
// Capture method
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
|
||||
// This includes request logging middleware and response writer wrappers that capture
|
||||
// request and response data for logging purposes while maintaining zero-latency performance.
|
||||
// Package middleware provides Gin HTTP middleware for the CLI Proxy API server.
|
||||
// It includes a sophisticated response writer wrapper designed to capture and log request and response data,
|
||||
// including support for streaming responses, without impacting latency.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
@@ -8,32 +8,43 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
)
|
||||
|
||||
// RequestInfo holds information about the current request for logging purposes.
|
||||
// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
|
||||
type RequestInfo struct {
|
||||
URL string
|
||||
Method string
|
||||
Headers map[string][]string
|
||||
Body []byte
|
||||
URL string // URL is the request URL.
|
||||
Method string // Method is the HTTP method (e.g., GET, POST).
|
||||
Headers map[string][]string // Headers contains the request headers.
|
||||
Body []byte // Body is the raw request body.
|
||||
}
|
||||
|
||||
// ResponseWriterWrapper wraps gin.ResponseWriter to capture response data for logging.
|
||||
// It maintains zero-latency performance by prioritizing client response over logging operations.
|
||||
// ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data.
|
||||
// It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.
|
||||
type ResponseWriterWrapper struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
isStreaming bool
|
||||
streamWriter logging.StreamingLogWriter
|
||||
chunkChannel chan []byte
|
||||
logger logging.RequestLogger
|
||||
requestInfo *RequestInfo
|
||||
statusCode int
|
||||
headers map[string][]string
|
||||
body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses.
|
||||
isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
|
||||
streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
|
||||
chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
|
||||
streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
|
||||
logger logging.RequestLogger // logger is the instance of the request logger service.
|
||||
requestInfo *RequestInfo // requestInfo holds the details of the original request.
|
||||
statusCode int // statusCode stores the HTTP status code of the response.
|
||||
headers map[string][]string // headers stores the response headers.
|
||||
}
|
||||
|
||||
// NewResponseWriterWrapper creates a new response writer wrapper.
|
||||
// NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.
|
||||
// It takes the original gin.ResponseWriter, a logger instance, and request information.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The original gin.ResponseWriter to wrap.
|
||||
// - logger: The logging service to use for recording requests.
|
||||
// - requestInfo: The pre-captured information about the incoming request.
|
||||
//
|
||||
// Returns:
|
||||
// - A pointer to a new ResponseWriterWrapper.
|
||||
func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
|
||||
return &ResponseWriterWrapper{
|
||||
ResponseWriter: w,
|
||||
@@ -44,9 +55,16 @@ func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger
|
||||
}
|
||||
}
|
||||
|
||||
// Write intercepts response data while maintaining normal Gin functionality.
|
||||
// CRITICAL: This method prioritizes client response (zero-latency) over logging operations.
|
||||
// Write wraps the underlying ResponseWriter's Write method to capture response data.
|
||||
// For non-streaming responses, it writes to an internal buffer. For streaming responses,
|
||||
// it sends data chunks to a non-blocking channel for asynchronous logging.
|
||||
// CRITICAL: This method prioritizes writing to the client to ensure zero latency,
|
||||
// handling logging operations subsequently.
|
||||
func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
|
||||
// Ensure headers are captured before first write
|
||||
// This is critical because Write() may trigger WriteHeader() internally
|
||||
w.ensureHeadersCaptured()
|
||||
|
||||
// CRITICAL: Write to client first (zero latency)
|
||||
n, err := w.ResponseWriter.Write(data)
|
||||
|
||||
@@ -67,14 +85,14 @@ func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// WriteHeader captures the status code and detects streaming responses.
|
||||
// WriteHeader wraps the underlying ResponseWriter's WriteHeader method.
|
||||
// It captures the status code, detects if the response is streaming based on the Content-Type header,
|
||||
// and initializes the appropriate logging mechanism (standard or streaming).
|
||||
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
||||
w.statusCode = statusCode
|
||||
|
||||
// Capture response headers
|
||||
for key, values := range w.ResponseWriter.Header() {
|
||||
w.headers[key] = values
|
||||
}
|
||||
// Capture response headers using the new method
|
||||
w.captureCurrentHeaders()
|
||||
|
||||
// Detect streaming based on Content-Type
|
||||
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
||||
@@ -91,9 +109,11 @@ func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
||||
if err == nil {
|
||||
w.streamWriter = streamWriter
|
||||
w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
|
||||
doneChan := make(chan struct{})
|
||||
w.streamDone = doneChan
|
||||
|
||||
// Start async chunk processor
|
||||
go w.processStreamingChunks()
|
||||
go w.processStreamingChunks(doneChan)
|
||||
|
||||
// Write status immediately
|
||||
_ = streamWriter.WriteStatus(statusCode, w.headers)
|
||||
@@ -104,7 +124,34 @@ func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
// detectStreaming determines if the response is streaming based on Content-Type and request analysis.
|
||||
// ensureHeadersCaptured is a helper function to make sure response headers are captured.
|
||||
// It is safe to call this method multiple times; it will always refresh the headers
|
||||
// with the latest state from the underlying ResponseWriter.
|
||||
func (w *ResponseWriterWrapper) ensureHeadersCaptured() {
|
||||
// Always capture the current headers to ensure we have the latest state
|
||||
w.captureCurrentHeaders()
|
||||
}
|
||||
|
||||
// captureCurrentHeaders reads all headers from the underlying ResponseWriter and stores them
|
||||
// in the wrapper's headers map. It creates copies of the header values to prevent race conditions.
|
||||
func (w *ResponseWriterWrapper) captureCurrentHeaders() {
|
||||
// Initialize headers map if needed
|
||||
if w.headers == nil {
|
||||
w.headers = make(map[string][]string)
|
||||
}
|
||||
|
||||
// Capture all current headers from the underlying ResponseWriter
|
||||
for key, values := range w.ResponseWriter.Header() {
|
||||
// Make a copy of the values slice to avoid reference issues
|
||||
headerValues := make([]string, len(values))
|
||||
copy(headerValues, values)
|
||||
w.headers[key] = headerValues
|
||||
}
|
||||
}
|
||||
|
||||
// detectStreaming determines if a response should be treated as a streaming response.
|
||||
// It checks for a "text/event-stream" Content-Type or a '"stream": true'
|
||||
// field in the original request body.
|
||||
func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
|
||||
// Check Content-Type for Server-Sent Events
|
||||
if strings.Contains(contentType, "text/event-stream") {
|
||||
@@ -122,8 +169,15 @@ func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// processStreamingChunks handles async processing of streaming chunks.
|
||||
func (w *ResponseWriterWrapper) processStreamingChunks() {
|
||||
// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
|
||||
// It asynchronously writes each chunk to the streaming log writer.
|
||||
func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
|
||||
if done == nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer close(done)
|
||||
|
||||
if w.streamWriter == nil || w.chunkChannel == nil {
|
||||
return
|
||||
}
|
||||
@@ -133,8 +187,11 @@ func (w *ResponseWriterWrapper) processStreamingChunks() {
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize completes the logging process for the response.
|
||||
func (w *ResponseWriterWrapper) Finalize() error {
|
||||
// Finalize completes the logging process for the request and response.
|
||||
// For streaming responses, it closes the chunk channel and the stream writer.
|
||||
// For non-streaming responses, it logs the complete request and response details,
|
||||
// including any API-specific request/response data stored in the Gin context.
|
||||
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
|
||||
if !w.logger.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
@@ -146,8 +203,15 @@ func (w *ResponseWriterWrapper) Finalize() error {
|
||||
w.chunkChannel = nil
|
||||
}
|
||||
|
||||
if w.streamDone != nil {
|
||||
<-w.streamDone
|
||||
w.streamDone = nil
|
||||
}
|
||||
|
||||
if w.streamWriter != nil {
|
||||
return w.streamWriter.Close()
|
||||
err := w.streamWriter.Close()
|
||||
w.streamWriter = nil
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Capture final status code and headers if not already captured
|
||||
@@ -161,14 +225,46 @@ func (w *ResponseWriterWrapper) Finalize() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Capture final headers
|
||||
// Ensure we have the latest headers before finalizing
|
||||
w.ensureHeadersCaptured()
|
||||
|
||||
// Use the captured headers as the final headers
|
||||
finalHeaders := make(map[string][]string)
|
||||
for key, values := range w.ResponseWriter.Header() {
|
||||
finalHeaders[key] = values
|
||||
}
|
||||
// Merge with any headers we captured earlier
|
||||
for key, values := range w.headers {
|
||||
finalHeaders[key] = values
|
||||
// Make a copy of the values slice to avoid reference issues
|
||||
headerValues := make([]string, len(values))
|
||||
copy(headerValues, values)
|
||||
finalHeaders[key] = headerValues
|
||||
}
|
||||
|
||||
var apiRequestBody []byte
|
||||
apiRequest, isExist := c.Get("API_REQUEST")
|
||||
if isExist {
|
||||
var ok bool
|
||||
apiRequestBody, ok = apiRequest.([]byte)
|
||||
if !ok {
|
||||
apiRequestBody = nil
|
||||
}
|
||||
}
|
||||
|
||||
var apiResponseBody []byte
|
||||
apiResponse, isExist := c.Get("API_RESPONSE")
|
||||
if isExist {
|
||||
var ok bool
|
||||
apiResponseBody, ok = apiResponse.([]byte)
|
||||
if !ok {
|
||||
apiResponseBody = nil
|
||||
}
|
||||
}
|
||||
|
||||
var slicesAPIResponseError []*interfaces.ErrorMessage
|
||||
apiResponseError, isExist := c.Get("API_RESPONSE_ERROR")
|
||||
if isExist {
|
||||
var ok bool
|
||||
slicesAPIResponseError, ok = apiResponseError.([]*interfaces.ErrorMessage)
|
||||
if !ok {
|
||||
slicesAPIResponseError = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Log complete non-streaming response
|
||||
@@ -180,13 +276,17 @@ func (w *ResponseWriterWrapper) Finalize() error {
|
||||
finalStatusCode,
|
||||
finalHeaders,
|
||||
w.body.Bytes(),
|
||||
apiRequestBody,
|
||||
apiResponseBody,
|
||||
slicesAPIResponseError,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the HTTP status code of the response.
|
||||
// Status returns the HTTP response status code captured by the wrapper.
|
||||
// It defaults to 200 if WriteHeader has not been called.
|
||||
func (w *ResponseWriterWrapper) Status() int {
|
||||
if w.statusCode == 0 {
|
||||
return 200 // Default status code
|
||||
@@ -194,7 +294,8 @@ func (w *ResponseWriterWrapper) Status() int {
|
||||
return w.statusCode
|
||||
}
|
||||
|
||||
// Size returns the size of the response body.
|
||||
// Size returns the size of the response body in bytes for non-streaming responses.
|
||||
// For streaming responses, it returns -1, as the total size is unknown.
|
||||
func (w *ResponseWriterWrapper) Size() int {
|
||||
if w.isStreaming {
|
||||
return -1 // Unknown size for streaming responses
|
||||
@@ -202,7 +303,7 @@ func (w *ResponseWriterWrapper) Size() int {
|
||||
return w.body.Len()
|
||||
}
|
||||
|
||||
// Written returns whether the response has been written.
|
||||
// Written returns true if the response header has been written (i.e., a status code has been set).
|
||||
func (w *ResponseWriterWrapper) Written() bool {
|
||||
return w.statusCode != 0
|
||||
}
|
||||
|
||||
@@ -6,24 +6,107 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers/claude"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers/gemini"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers/gemini/cli"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/handlers/openai"
|
||||
"github.com/luispater/CLIProxyAPI/internal/api/middleware"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/luispater/CLIProxyAPI/internal/logging"
|
||||
"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/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"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const oauthCallbackSuccessHTML = `<html><head><meta charset="utf-8"><title>Authentication successful</title><script>setTimeout(function(){window.close();},5000);</script></head><body><h1>Authentication successful!</h1><p>You can close this window.</p><p>This window will close automatically in 5 seconds.</p></body></html>`
|
||||
|
||||
type serverOptionConfig struct {
|
||||
extraMiddleware []gin.HandlerFunc
|
||||
engineConfigurator func(*gin.Engine)
|
||||
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
|
||||
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
|
||||
localPassword string
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
}
|
||||
|
||||
// ServerOption customises HTTP server construction.
|
||||
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)
|
||||
}
|
||||
return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir)
|
||||
}
|
||||
|
||||
// WithMiddleware appends additional Gin middleware during server construction.
|
||||
func WithMiddleware(mw ...gin.HandlerFunc) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.extraMiddleware = append(cfg.extraMiddleware, mw...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.
|
||||
func WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.engineConfigurator = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithRouterConfigurator appends a callback after default routes are registered.
|
||||
func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.routerConfigurator = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.
|
||||
func WithLocalManagementPassword(password string) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.localPassword = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.
|
||||
func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
if timeout <= 0 || onTimeout == nil {
|
||||
return
|
||||
}
|
||||
cfg.keepAliveEnabled = true
|
||||
cfg.keepAliveTimeout = timeout
|
||||
cfg.keepAliveOnTimeout = onTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// WithRequestLoggerFactory customises request logger creation.
|
||||
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.requestLoggerFactory = factory
|
||||
}
|
||||
}
|
||||
|
||||
// Server represents the main API server.
|
||||
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
|
||||
type Server struct {
|
||||
@@ -34,10 +117,52 @@ type Server struct {
|
||||
server *http.Server
|
||||
|
||||
// handlers contains the API handlers for processing requests.
|
||||
handlers *handlers.APIHandlers
|
||||
handlers *handlers.BaseAPIHandler
|
||||
|
||||
// cfg holds the current server configuration.
|
||||
cfg *config.Config
|
||||
|
||||
// oldConfigYaml stores a YAML snapshot of the previous configuration for change detection.
|
||||
// This prevents issues when the config object is modified in place by Management API.
|
||||
oldConfigYaml []byte
|
||||
|
||||
// accessManager handles request authentication providers.
|
||||
accessManager *sdkaccess.Manager
|
||||
|
||||
// requestLogger is the request logger instance for dynamic configuration updates.
|
||||
requestLogger logging.RequestLogger
|
||||
loggerToggle func(bool)
|
||||
|
||||
// configFilePath is the absolute path to the YAML config file for persistence.
|
||||
configFilePath string
|
||||
|
||||
// currentPath is the absolute path to the current working directory.
|
||||
currentPath string
|
||||
|
||||
// wsRoutes tracks registered websocket upgrade paths.
|
||||
wsRouteMu sync.Mutex
|
||||
wsRoutes map[string]struct{}
|
||||
wsAuthChanged func(bool, bool)
|
||||
wsAuthEnabled atomic.Bool
|
||||
|
||||
// management handler
|
||||
mgmt *managementHandlers.Handler
|
||||
|
||||
// managementRoutesRegistered tracks whether the management routes have been attached to the engine.
|
||||
managementRoutesRegistered atomic.Bool
|
||||
// managementRoutesEnabled controls whether management endpoints serve real handlers.
|
||||
managementRoutesEnabled atomic.Bool
|
||||
|
||||
// envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured.
|
||||
envManagementSecret bool
|
||||
|
||||
localPassword string
|
||||
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
keepAliveHeartbeat chan struct{}
|
||||
keepAliveStop chan struct{}
|
||||
}
|
||||
|
||||
// NewServer creates and initializes a new API server instance.
|
||||
@@ -45,11 +170,18 @@ type Server struct {
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The server configuration
|
||||
// - cliClients: A slice of AI service clients
|
||||
// - authManager: core runtime auth manager
|
||||
// - accessManager: request authentication manager
|
||||
//
|
||||
// Returns:
|
||||
// - *Server: A new server instance
|
||||
func NewServer(cfg *config.Config, cliClients []client.Client) *Server {
|
||||
func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {
|
||||
optionState := &serverOptionConfig{
|
||||
requestLoggerFactory: defaultRequestLoggerFactory,
|
||||
}
|
||||
for i := range opts {
|
||||
opts[i](optionState)
|
||||
}
|
||||
// Set gin mode
|
||||
if !cfg.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -57,26 +189,92 @@ func NewServer(cfg *config.Config, cliClients []client.Client) *Server {
|
||||
|
||||
// Create gin engine
|
||||
engine := gin.New()
|
||||
if optionState.engineConfigurator != nil {
|
||||
optionState.engineConfigurator(engine)
|
||||
}
|
||||
|
||||
// Add middleware
|
||||
engine.Use(gin.Logger())
|
||||
engine.Use(gin.Recovery())
|
||||
engine.Use(logging.GinLogrusLogger())
|
||||
engine.Use(logging.GinLogrusRecovery())
|
||||
for _, mw := range optionState.extraMiddleware {
|
||||
engine.Use(mw)
|
||||
}
|
||||
|
||||
// Add request logging middleware (positioned after recovery, before auth)
|
||||
requestLogger := logging.NewFileRequestLogger(cfg.RequestLog, "logs")
|
||||
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
|
||||
// Resolve logs directory relative to the configuration file directory.
|
||||
var requestLogger logging.RequestLogger
|
||||
var toggle func(bool)
|
||||
if optionState.requestLoggerFactory != nil {
|
||||
requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)
|
||||
}
|
||||
if requestLogger != nil {
|
||||
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
|
||||
if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||
toggle = setter.SetEnabled
|
||||
}
|
||||
}
|
||||
|
||||
engine.Use(corsMiddleware())
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
wd = configFilePath
|
||||
}
|
||||
|
||||
envAdminPassword, envAdminPasswordSet := os.LookupEnv("MANAGEMENT_PASSWORD")
|
||||
envAdminPassword = strings.TrimSpace(envAdminPassword)
|
||||
envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
|
||||
|
||||
// Create server instance
|
||||
s := &Server{
|
||||
engine: engine,
|
||||
handlers: handlers.NewAPIHandlers(cliClients, cfg),
|
||||
cfg: cfg,
|
||||
providerNames := make([]string, 0, len(cfg.OpenAICompatibility))
|
||||
for _, p := range cfg.OpenAICompatibility {
|
||||
providerNames = append(providerNames, p.Name)
|
||||
}
|
||||
s := &Server{
|
||||
engine: engine,
|
||||
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager, providerNames),
|
||||
cfg: cfg,
|
||||
accessManager: accessManager,
|
||||
requestLogger: requestLogger,
|
||||
loggerToggle: toggle,
|
||||
configFilePath: configFilePath,
|
||||
currentPath: wd,
|
||||
envManagementSecret: envManagementSecret,
|
||||
wsRoutes: make(map[string]struct{}),
|
||||
}
|
||||
s.wsAuthEnabled.Store(cfg.WebsocketAuth)
|
||||
// Save initial YAML snapshot
|
||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
s.applyAccessConfig(nil, cfg)
|
||||
managementasset.SetCurrentConfig(cfg)
|
||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
// Initialize management handler
|
||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||
if optionState.localPassword != "" {
|
||||
s.mgmt.SetLocalPassword(optionState.localPassword)
|
||||
}
|
||||
logDir := filepath.Join(s.currentPath, "logs")
|
||||
if base := util.WritablePath(); base != "" {
|
||||
logDir = filepath.Join(base, "logs")
|
||||
}
|
||||
s.mgmt.SetLogDirectory(logDir)
|
||||
s.localPassword = optionState.localPassword
|
||||
|
||||
// Setup routes
|
||||
s.setupRoutes()
|
||||
if optionState.routerConfigurator != nil {
|
||||
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||
}
|
||||
|
||||
// Register management routes when configuration or environment secrets are available.
|
||||
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret
|
||||
s.managementRoutesEnabled.Store(hasManagementSecret)
|
||||
if hasManagementSecret {
|
||||
s.registerManagementRoutes()
|
||||
}
|
||||
|
||||
if optionState.keepAliveEnabled {
|
||||
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
s.server = &http.Server{
|
||||
@@ -90,23 +288,28 @@ func NewServer(cfg *config.Config, cliClients []client.Client) *Server {
|
||||
// setupRoutes configures the API routes for the server.
|
||||
// It defines the endpoints and associates them with their respective handlers.
|
||||
func (s *Server) setupRoutes() {
|
||||
openaiHandlers := openai.NewOpenAIAPIHandlers(s.handlers)
|
||||
geminiHandlers := gemini.NewGeminiAPIHandlers(s.handlers)
|
||||
geminiCLIHandlers := cli.NewGeminiCLIAPIHandlers(s.handlers)
|
||||
claudeCodeHandlers := claude.NewClaudeCodeAPIHandlers(s.handlers)
|
||||
s.engine.GET("/management.html", s.serveManagementControlPanel)
|
||||
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
|
||||
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
|
||||
geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)
|
||||
claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)
|
||||
openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
|
||||
|
||||
// OpenAI compatible API routes
|
||||
v1 := s.engine.Group("/v1")
|
||||
v1.Use(AuthMiddleware(s.cfg))
|
||||
v1.Use(AuthMiddleware(s.accessManager))
|
||||
{
|
||||
v1.GET("/models", openaiHandlers.Models)
|
||||
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
|
||||
v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
|
||||
v1.POST("/completions", openaiHandlers.Completions)
|
||||
v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
|
||||
v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
|
||||
v1.POST("/responses", openaiResponsesHandlers.Responses)
|
||||
}
|
||||
|
||||
// Gemini compatible API routes
|
||||
v1beta := s.engine.Group("/v1beta")
|
||||
v1beta.Use(AuthMiddleware(s.cfg))
|
||||
v1beta.Use(AuthMiddleware(s.accessManager))
|
||||
{
|
||||
v1beta.GET("/models", geminiHandlers.GeminiModels)
|
||||
v1beta.POST("/models/:action", geminiHandlers.GeminiHandler)
|
||||
@@ -117,14 +320,340 @@ func (s *Server) setupRoutes() {
|
||||
s.engine.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "CLI Proxy API Server",
|
||||
"version": "1.0.0",
|
||||
"endpoints": []string{
|
||||
"POST /v1/chat/completions",
|
||||
"POST /v1/completions",
|
||||
"GET /v1/models",
|
||||
},
|
||||
})
|
||||
})
|
||||
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
|
||||
|
||||
// OAuth callback endpoints (reuse main server port)
|
||||
// These endpoints receive provider redirects and persist
|
||||
// the short-lived code/state for the waiting goroutine.
|
||||
s.engine.GET("/anthropic/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
errStr := c.Query("error")
|
||||
// Persist to a temporary file keyed by state
|
||||
if state != "" {
|
||||
file := fmt.Sprintf("%s/.oauth-anthropic-%s.oauth", s.cfg.AuthDir, state)
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/codex/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
errStr := c.Query("error")
|
||||
if state != "" {
|
||||
file := fmt.Sprintf("%s/.oauth-codex-%s.oauth", s.cfg.AuthDir, state)
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/google/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
errStr := c.Query("error")
|
||||
if state != "" {
|
||||
file := fmt.Sprintf("%s/.oauth-gemini-%s.oauth", s.cfg.AuthDir, state)
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
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 state != "" {
|
||||
file := fmt.Sprintf("%s/.oauth-iflow-%s.oauth", s.cfg.AuthDir, state)
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
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.
|
||||
}
|
||||
|
||||
// AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine.
|
||||
// The handler is served as-is without additional middleware beyond the standard stack already configured.
|
||||
func (s *Server) AttachWebsocketRoute(path string, handler http.Handler) {
|
||||
if s == nil || s.engine == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
trimmed = "/v1/ws"
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") {
|
||||
trimmed = "/" + trimmed
|
||||
}
|
||||
s.wsRouteMu.Lock()
|
||||
if _, exists := s.wsRoutes[trimmed]; exists {
|
||||
s.wsRouteMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.wsRoutes[trimmed] = struct{}{}
|
||||
s.wsRouteMu.Unlock()
|
||||
|
||||
authMiddleware := AuthMiddleware(s.accessManager)
|
||||
conditionalAuth := func(c *gin.Context) {
|
||||
if !s.wsAuthEnabled.Load() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
authMiddleware(c)
|
||||
}
|
||||
finalHandler := func(c *gin.Context) {
|
||||
handler.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
s.engine.GET(trimmed, conditionalAuth, finalHandler)
|
||||
}
|
||||
|
||||
func (s *Server) registerManagementRoutes() {
|
||||
if s == nil || s.engine == nil || s.mgmt == nil {
|
||||
return
|
||||
}
|
||||
if !s.managementRoutesRegistered.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("management routes registered after secret key configuration")
|
||||
|
||||
mgmt := s.engine.Group("/v0/management")
|
||||
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
|
||||
{
|
||||
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
||||
mgmt.GET("/config.yaml", s.mgmt.GetConfigFile)
|
||||
|
||||
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
||||
|
||||
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
|
||||
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
|
||||
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
|
||||
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
|
||||
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
|
||||
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
|
||||
|
||||
mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
|
||||
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
|
||||
|
||||
mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
|
||||
mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||
mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
|
||||
|
||||
mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
|
||||
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
|
||||
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
||||
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
||||
|
||||
mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)
|
||||
mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)
|
||||
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
|
||||
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
|
||||
|
||||
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
|
||||
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
|
||||
mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey)
|
||||
mgmt.DELETE("/gemini-api-key", s.mgmt.DeleteGeminiKey)
|
||||
|
||||
mgmt.GET("/logs", s.mgmt.GetLogs)
|
||||
mgmt.DELETE("/logs", s.mgmt.DeleteLogs)
|
||||
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
|
||||
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
|
||||
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
|
||||
mgmt.GET("/ws-auth", s.mgmt.GetWebsocketAuth)
|
||||
mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth)
|
||||
mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth)
|
||||
|
||||
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
|
||||
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
|
||||
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
|
||||
|
||||
mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
|
||||
mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
|
||||
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
|
||||
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
|
||||
|
||||
mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
|
||||
mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
|
||||
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
|
||||
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
|
||||
|
||||
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
|
||||
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
|
||||
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
|
||||
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
|
||||
|
||||
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
|
||||
|
||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !s.managementRoutesEnabled.Load() {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
||||
cfg := s.cfg
|
||||
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
filePath := managementasset.FilePath(s.configFilePath)
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL)
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.WithError(err).Error("failed to stat management control panel asset")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
|
||||
if timeout <= 0 || onTimeout == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.keepAliveEnabled = true
|
||||
s.keepAliveTimeout = timeout
|
||||
s.keepAliveOnTimeout = onTimeout
|
||||
s.keepAliveHeartbeat = make(chan struct{}, 1)
|
||||
s.keepAliveStop = make(chan struct{}, 1)
|
||||
|
||||
s.engine.GET("/keep-alive", s.handleKeepAlive)
|
||||
|
||||
go s.watchKeepAlive()
|
||||
}
|
||||
|
||||
func (s *Server) handleKeepAlive(c *gin.Context) {
|
||||
if s.localPassword != "" {
|
||||
provided := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if provided != "" {
|
||||
parts := strings.SplitN(provided, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
|
||||
provided = parts[1]
|
||||
}
|
||||
}
|
||||
if provided == "" {
|
||||
provided = strings.TrimSpace(c.GetHeader("X-Local-Password"))
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(s.localPassword)) != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.signalKeepAlive()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) signalKeepAlive() {
|
||||
if !s.keepAliveEnabled {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.keepAliveHeartbeat <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) watchKeepAlive() {
|
||||
if !s.keepAliveEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
timer := time.NewTimer(s.keepAliveTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
log.Warnf("keep-alive endpoint idle for %s, shutting down", s.keepAliveTimeout)
|
||||
if s.keepAliveOnTimeout != nil {
|
||||
s.keepAliveOnTimeout()
|
||||
}
|
||||
return
|
||||
case <-s.keepAliveHeartbeat:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(s.keepAliveTimeout)
|
||||
case <-s.keepAliveStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
|
||||
// that routes to different handlers based on the User-Agent header.
|
||||
// If User-Agent starts with "claude-cli", it routes to Claude handler,
|
||||
// otherwise it routes to OpenAI handler.
|
||||
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// Route to Claude handler if User-Agent starts with "claude-cli"
|
||||
if strings.HasPrefix(userAgent, "claude-cli") {
|
||||
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
|
||||
claudeHandler.ClaudeModels(c)
|
||||
} else {
|
||||
// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)
|
||||
openaiHandler.OpenAIModels(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening for and serving HTTP requests.
|
||||
@@ -154,6 +683,13 @@ func (s *Server) Start() error {
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
log.Debug("Stopping API server...")
|
||||
|
||||
if s.keepAliveEnabled {
|
||||
select {
|
||||
case s.keepAliveStop <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown the HTTP server.
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
|
||||
@@ -171,8 +707,8 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "*")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
@@ -183,75 +719,207 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
|
||||
if s == nil || s.accessManager == nil || newCfg == nil {
|
||||
return
|
||||
}
|
||||
if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateClients updates the server's client list and configuration.
|
||||
// This method is called when the configuration or authentication tokens change.
|
||||
//
|
||||
// Parameters:
|
||||
// - clients: The new slice of AI service clients
|
||||
// - cfg: The new application configuration
|
||||
func (s *Server) UpdateClients(clients []client.Client, cfg *config.Config) {
|
||||
func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
// Reconstruct old config from YAML snapshot to avoid reference sharing issues
|
||||
var oldCfg *config.Config
|
||||
if len(s.oldConfigYaml) > 0 {
|
||||
_ = yaml.Unmarshal(s.oldConfigYaml, &oldCfg)
|
||||
}
|
||||
|
||||
// Update request logger enabled state if it has changed
|
||||
previousRequestLog := false
|
||||
if oldCfg != nil {
|
||||
previousRequestLog = oldCfg.RequestLog
|
||||
}
|
||||
if s.requestLogger != nil && (oldCfg == nil || previousRequestLog != cfg.RequestLog) {
|
||||
if s.loggerToggle != nil {
|
||||
s.loggerToggle(cfg.RequestLog)
|
||||
} else if toggler, ok := s.requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||
toggler.SetEnabled(cfg.RequestLog)
|
||||
}
|
||||
if oldCfg != nil {
|
||||
log.Debugf("request logging updated from %t to %t", previousRequestLog, cfg.RequestLog)
|
||||
} else {
|
||||
log.Debugf("request logging toggled to %t", cfg.RequestLog)
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg != nil && oldCfg.LoggingToFile != cfg.LoggingToFile {
|
||||
if err := logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||
log.Errorf("failed to reconfigure log output: %v", err)
|
||||
} else {
|
||||
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
if oldCfg != nil {
|
||||
log.Debugf("usage_statistics_enabled updated from %t to %t", oldCfg.UsageStatisticsEnabled, cfg.UsageStatisticsEnabled)
|
||||
} else {
|
||||
log.Debugf("usage_statistics_enabled toggled to %t", cfg.UsageStatisticsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.DisableCooling != cfg.DisableCooling {
|
||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
if oldCfg != nil {
|
||||
log.Debugf("disable_cooling updated from %t to %t", oldCfg.DisableCooling, cfg.DisableCooling)
|
||||
} else {
|
||||
log.Debugf("disable_cooling toggled to %t", cfg.DisableCooling)
|
||||
}
|
||||
}
|
||||
|
||||
// Update log level dynamically when debug flag changes
|
||||
if oldCfg == nil || oldCfg.Debug != cfg.Debug {
|
||||
util.SetLogLevel(cfg)
|
||||
if oldCfg != nil {
|
||||
log.Debugf("debug mode updated from %t to %t", oldCfg.Debug, cfg.Debug)
|
||||
} else {
|
||||
log.Debugf("debug mode toggled to %t", cfg.Debug)
|
||||
}
|
||||
}
|
||||
|
||||
prevSecretEmpty := true
|
||||
if oldCfg != nil {
|
||||
prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == ""
|
||||
}
|
||||
newSecretEmpty := cfg.RemoteManagement.SecretKey == ""
|
||||
if s.envManagementSecret {
|
||||
s.registerManagementRoutes()
|
||||
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
||||
log.Info("management routes enabled via MANAGEMENT_PASSWORD")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(true)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case prevSecretEmpty && !newSecretEmpty:
|
||||
s.registerManagementRoutes()
|
||||
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
||||
log.Info("management routes enabled after secret key update")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(true)
|
||||
}
|
||||
case !prevSecretEmpty && newSecretEmpty:
|
||||
if s.managementRoutesEnabled.CompareAndSwap(true, false) {
|
||||
log.Info("management routes disabled after secret key removal")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(false)
|
||||
}
|
||||
default:
|
||||
s.managementRoutesEnabled.Store(!newSecretEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
s.cfg = cfg
|
||||
s.handlers.UpdateClients(clients, cfg)
|
||||
log.Infof("server clients and configuration updated: %d clients", len(clients))
|
||||
s.wsAuthEnabled.Store(cfg.WebsocketAuth)
|
||||
if oldCfg != nil && s.wsAuthChanged != nil && oldCfg.WebsocketAuth != cfg.WebsocketAuth {
|
||||
s.wsAuthChanged(oldCfg.WebsocketAuth, cfg.WebsocketAuth)
|
||||
}
|
||||
managementasset.SetCurrentConfig(cfg)
|
||||
// Save YAML snapshot for next comparison
|
||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
|
||||
providerNames := make([]string, 0, len(cfg.OpenAICompatibility))
|
||||
for _, p := range cfg.OpenAICompatibility {
|
||||
providerNames = append(providerNames, p.Name)
|
||||
}
|
||||
s.handlers.OpenAICompatProviders = providerNames
|
||||
|
||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
||||
|
||||
if !cfg.RemoteManagement.DisableControlPanel {
|
||||
staticDir := managementasset.StaticDir(s.configFilePath)
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
|
||||
}
|
||||
if s.mgmt != nil {
|
||||
s.mgmt.SetConfig(cfg)
|
||||
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
||||
}
|
||||
|
||||
// Count client sources from configuration and auth directory
|
||||
authFiles := util.CountAuthFiles(cfg.AuthDir)
|
||||
geminiAPIKeyCount := len(cfg.GeminiKey)
|
||||
claudeAPIKeyCount := len(cfg.ClaudeKey)
|
||||
codexAPIKeyCount := len(cfg.CodexKey)
|
||||
openAICompatCount := 0
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
entry := cfg.OpenAICompatibility[i]
|
||||
if len(entry.APIKeyEntries) > 0 {
|
||||
openAICompatCount += len(entry.APIKeyEntries)
|
||||
continue
|
||||
}
|
||||
openAICompatCount += len(entry.APIKeys)
|
||||
}
|
||||
|
||||
total := authFiles + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
||||
fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d Gemini API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n",
|
||||
total,
|
||||
authFiles,
|
||||
geminiAPIKeyCount,
|
||||
claudeAPIKeyCount,
|
||||
codexAPIKeyCount,
|
||||
openAICompatCount,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) SetWebsocketAuthChangeHandler(fn func(bool, bool)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.wsAuthChanged = fn
|
||||
}
|
||||
|
||||
// (management handlers moved to internal/api/handlers/management)
|
||||
|
||||
// AuthMiddleware returns a Gin middleware handler that authenticates requests
|
||||
// using API keys. If no API keys are configured, it allows all requests.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The server configuration containing API keys
|
||||
//
|
||||
// Returns:
|
||||
// - gin.HandlerFunc: The authentication middleware handler
|
||||
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
// using the configured authentication providers. When no providers are available,
|
||||
// it allows all requests (legacy behaviour).
|
||||
func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
if manager == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Get the Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
authHeaderGoogle := c.GetHeader("X-Goog-Api-Key")
|
||||
authHeaderAnthropic := c.GetHeader("X-Api-Key")
|
||||
|
||||
// Get the API key from the query parameter
|
||||
apiKeyQuery, _ := c.GetQuery("key")
|
||||
|
||||
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && apiKeyQuery == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Missing API key",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the API key
|
||||
parts := strings.Split(authHeader, " ")
|
||||
var apiKey string
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
apiKey = parts[1]
|
||||
} else {
|
||||
apiKey = authHeader
|
||||
}
|
||||
|
||||
// Find the API key in the in-memory list
|
||||
var foundKey string
|
||||
for i := range cfg.APIKeys {
|
||||
if cfg.APIKeys[i] == apiKey || cfg.APIKeys[i] == authHeaderGoogle || cfg.APIKeys[i] == authHeaderAnthropic || cfg.APIKeys[i] == apiKeyQuery {
|
||||
foundKey = cfg.APIKeys[i]
|
||||
break
|
||||
result, err := manager.Authenticate(c.Request.Context(), c.Request)
|
||||
if err == nil {
|
||||
if result != nil {
|
||||
c.Set("apiKey", result.Principal)
|
||||
c.Set("accessProvider", result.Provider)
|
||||
if len(result.Metadata) > 0 {
|
||||
c.Set("accessMetadata", result.Metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundKey == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Invalid API key",
|
||||
})
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Store the API key and user in the context
|
||||
c.Set("apiKey", foundKey)
|
||||
|
||||
c.Next()
|
||||
switch {
|
||||
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"})
|
||||
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
default:
|
||||
log.Errorf("authentication middleware error: %v", err)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
internal/auth/claude/anthropic.go
Normal file
32
internal/auth/claude/anthropic.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package claude
|
||||
|
||||
// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
|
||||
type PKCECodes struct {
|
||||
// CodeVerifier is the cryptographically random string used to correlate
|
||||
// the authorization request to the token request
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
// ClaudeTokenData holds OAuth token information from Anthropic
|
||||
type ClaudeTokenData struct {
|
||||
// AccessToken is the OAuth2 access token for API access
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// Email is the Anthropic account email
|
||||
Email string `json:"email"`
|
||||
// Expire is the timestamp of the token expire
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// ClaudeAuthBundle aggregates authentication data after OAuth flow completion
|
||||
type ClaudeAuthBundle struct {
|
||||
// APIKey is the Anthropic API key obtained from token exchange
|
||||
APIKey string `json:"api_key"`
|
||||
// TokenData contains the OAuth tokens from the authentication flow
|
||||
TokenData ClaudeTokenData `json:"token_data"`
|
||||
// LastRefresh is the timestamp of the last token refresh
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
}
|
||||
346
internal/auth/claude/anthropic_auth.go
Normal file
346
internal/auth/claude/anthropic_auth.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.
|
||||
// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)
|
||||
// for secure authentication with Claude API, including token exchange, refresh, and storage.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
anthropicAuthURL = "https://claude.ai/oauth/authorize"
|
||||
anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token"
|
||||
anthropicClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
redirectURI = "http://localhost:54545/callback"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Organization struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
} `json:"organization"`
|
||||
Account struct {
|
||||
UUID string `json:"uuid"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
} `json:"account"`
|
||||
}
|
||||
|
||||
// ClaudeAuth handles Anthropic OAuth2 authentication flow.
|
||||
// It provides methods for generating authorization URLs, exchanging codes for tokens,
|
||||
// and refreshing expired tokens using PKCE for enhanced security.
|
||||
type ClaudeAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClaudeAuth creates a new Anthropic authentication service.
|
||||
// It initializes the HTTP client with proxy settings from the configuration.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration containing proxy settings
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeAuth: A new Claude authentication service instance
|
||||
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
|
||||
return &ClaudeAuth{
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAuthURL creates the OAuth authorization URL with PKCE.
|
||||
// This method generates a secure authorization URL including PKCE challenge codes
|
||||
// for the OAuth2 flow with Anthropic's API.
|
||||
//
|
||||
// Parameters:
|
||||
// - state: A random state parameter for CSRF protection
|
||||
// - pkceCodes: The PKCE codes for secure code exchange
|
||||
//
|
||||
// Returns:
|
||||
// - string: The complete authorization URL
|
||||
// - string: The state parameter for verification
|
||||
// - error: An error if PKCE codes are missing or URL generation fails
|
||||
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {
|
||||
if pkceCodes == nil {
|
||||
return "", "", fmt.Errorf("PKCE codes are required")
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"code": {"true"},
|
||||
"client_id": {anthropicClientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {"org:create_api_key user:profile user:inference"},
|
||||
"code_challenge": {pkceCodes.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s", anthropicAuthURL, params.Encode())
|
||||
return authURL, state, nil
|
||||
}
|
||||
|
||||
// parseCodeAndState extracts the authorization code and state from the callback response.
|
||||
// It handles the parsing of the code parameter which may contain additional fragments.
|
||||
//
|
||||
// Parameters:
|
||||
// - code: The raw code parameter from the OAuth callback
|
||||
//
|
||||
// Returns:
|
||||
// - parsedCode: The extracted authorization code
|
||||
// - parsedState: The extracted state parameter if present
|
||||
func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {
|
||||
splits := strings.Split(code, "#")
|
||||
parsedCode = splits[0]
|
||||
if len(splits) > 1 {
|
||||
parsedState = splits[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges authorization code for access tokens.
|
||||
// This method implements the OAuth2 token exchange flow using PKCE for security.
|
||||
// It sends the authorization code along with PKCE verifier to get access and refresh tokens.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request
|
||||
// - code: The authorization code received from OAuth callback
|
||||
// - state: The state parameter for verification
|
||||
// - pkceCodes: The PKCE codes for secure verification
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeAuthBundle: The complete authentication bundle with tokens
|
||||
// - error: An error if token exchange fails
|
||||
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {
|
||||
if pkceCodes == nil {
|
||||
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||
}
|
||||
newCode, newState := o.parseCodeAndState(code)
|
||||
|
||||
// Prepare token exchange request
|
||||
reqBody := map[string]interface{}{
|
||||
"code": newCode,
|
||||
"state": state,
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": anthropicClientID,
|
||||
"redirect_uri": redirectURI,
|
||||
"code_verifier": pkceCodes.CodeVerifier,
|
||||
}
|
||||
|
||||
// Include state if present
|
||||
if newState != "" {
|
||||
reqBody["state"] = newState
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
// log.Debugf("Token exchange request: %s", string(jsonBody))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("failed to close response body: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
var tokenResp tokenResponse
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Create token data
|
||||
tokenData := ClaudeTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
Email: tokenResp.Account.EmailAddress,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Create auth bundle
|
||||
bundle := &ClaudeAuthBundle{
|
||||
TokenData: tokenData,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes the access token using the refresh token.
|
||||
// This method exchanges a valid refresh token for a new access token,
|
||||
// extending the user's authenticated session.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request
|
||||
// - refreshToken: The refresh token to use for getting new access token
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeTokenData: The new token data with updated access token
|
||||
// - error: An error if token refresh fails
|
||||
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"client_id": anthropicClientID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
var tokenResp tokenResponse
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Create token data
|
||||
return &ClaudeTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
Email: tokenResp.Account.EmailAddress,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info.
|
||||
// This method converts the authentication bundle into a token storage structure
|
||||
// suitable for persistence and later use.
|
||||
//
|
||||
// Parameters:
|
||||
// - bundle: The authentication bundle containing token data
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeTokenStorage: A new token storage instance
|
||||
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {
|
||||
storage := &ClaudeTokenStorage{
|
||||
AccessToken: bundle.TokenData.AccessToken,
|
||||
RefreshToken: bundle.TokenData.RefreshToken,
|
||||
LastRefresh: bundle.LastRefresh,
|
||||
Email: bundle.TokenData.Email,
|
||||
Expire: bundle.TokenData.Expire,
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry refreshes tokens with automatic retry logic.
|
||||
// This method implements exponential backoff retry logic for token refresh operations,
|
||||
// providing resilience against temporary network or service issues.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request
|
||||
// - refreshToken: The refresh token to use
|
||||
// - maxRetries: The maximum number of retry attempts
|
||||
//
|
||||
// Returns:
|
||||
// - *ClaudeTokenData: The refreshed token data
|
||||
// - error: An error if all retry attempts fail
|
||||
func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||
if err == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing token storage with new token data.
|
||||
// This method refreshes the token storage with newly obtained access and refresh tokens,
|
||||
// updating timestamps and expiration information.
|
||||
//
|
||||
// Parameters:
|
||||
// - storage: The existing token storage to update
|
||||
// - tokenData: The new token data to apply
|
||||
func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
storage.RefreshToken = tokenData.RefreshToken
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
storage.Email = tokenData.Email
|
||||
storage.Expire = tokenData.Expire
|
||||
}
|
||||
167
internal/auth/claude/errors.go
Normal file
167
internal/auth/claude/errors.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// OAuthError represents an OAuth-specific error.
|
||||
type OAuthError struct {
|
||||
// Code is the OAuth error code.
|
||||
Code string `json:"error"`
|
||||
// Description is a human-readable description of the error.
|
||||
Description string `json:"error_description,omitempty"`
|
||||
// URI is a URI identifying a human-readable web page with information about the error.
|
||||
URI string `json:"error_uri,omitempty"`
|
||||
// StatusCode is the HTTP status code associated with the error.
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the OAuth error.
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.Description != "" {
|
||||
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||
}
|
||||
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||
}
|
||||
|
||||
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||
return &OAuthError{
|
||||
Code: code,
|
||||
Description: description,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationError represents authentication-related errors.
|
||||
type AuthenticationError struct {
|
||||
// Type is the type of authentication error.
|
||||
Type string `json:"type"`
|
||||
// Message is a human-readable message describing the error.
|
||||
Message string `json:"message"`
|
||||
// Code is the HTTP status code associated with the error.
|
||||
Code int `json:"code"`
|
||||
// Cause is the underlying error that caused this authentication error.
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the authentication error.
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Common authentication error types.
|
||||
var (
|
||||
// ErrTokenExpired = &AuthenticationError{
|
||||
// Type: "token_expired",
|
||||
// Message: "Access token has expired",
|
||||
// Code: http.StatusUnauthorized,
|
||||
// }
|
||||
|
||||
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||
ErrInvalidState = &AuthenticationError{
|
||||
Type: "invalid_state",
|
||||
Message: "OAuth state parameter is invalid",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||
ErrCodeExchangeFailed = &AuthenticationError{
|
||||
Type: "code_exchange_failed",
|
||||
Message: "Failed to exchange authorization code for tokens",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||
ErrServerStartFailed = &AuthenticationError{
|
||||
Type: "server_start_failed",
|
||||
Message: "Failed to start OAuth callback server",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||
ErrPortInUse = &AuthenticationError{
|
||||
Type: "port_in_use",
|
||||
Message: "OAuth callback port is already in use",
|
||||
Code: 13, // Special exit code for port-in-use
|
||||
}
|
||||
|
||||
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||
ErrCallbackTimeout = &AuthenticationError{
|
||||
Type: "callback_timeout",
|
||||
Message: "Timeout waiting for OAuth callback",
|
||||
Code: http.StatusRequestTimeout,
|
||||
}
|
||||
)
|
||||
|
||||
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||
return &AuthenticationError{
|
||||
Type: baseErr.Type,
|
||||
Message: baseErr.Message,
|
||||
Code: baseErr.Code,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticationError checks if an error is an authentication error.
|
||||
func IsAuthenticationError(err error) bool {
|
||||
var authenticationError *AuthenticationError
|
||||
ok := errors.As(err, &authenticationError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsOAuthError checks if an error is an OAuth error.
|
||||
func IsOAuthError(err error) bool {
|
||||
var oAuthError *OAuthError
|
||||
ok := errors.As(err, &oAuthError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||
func GetUserFriendlyMessage(err error) string {
|
||||
switch {
|
||||
case IsAuthenticationError(err):
|
||||
var authErr *AuthenticationError
|
||||
errors.As(err, &authErr)
|
||||
switch authErr.Type {
|
||||
case "token_expired":
|
||||
return "Your authentication has expired. Please log in again."
|
||||
case "token_invalid":
|
||||
return "Your authentication is invalid. Please log in again."
|
||||
case "authentication_required":
|
||||
return "Please log in to continue."
|
||||
case "port_in_use":
|
||||
return "The required port is already in use. Please close any applications using port 3000 and try again."
|
||||
case "callback_timeout":
|
||||
return "Authentication timed out. Please try again."
|
||||
case "browser_open_failed":
|
||||
return "Could not open your browser automatically. Please copy and paste the URL manually."
|
||||
default:
|
||||
return "Authentication failed. Please try again."
|
||||
}
|
||||
case IsOAuthError(err):
|
||||
var oauthErr *OAuthError
|
||||
errors.As(err, &oauthErr)
|
||||
switch oauthErr.Code {
|
||||
case "access_denied":
|
||||
return "Authentication was cancelled or denied."
|
||||
case "invalid_request":
|
||||
return "Invalid authentication request. Please try again."
|
||||
case "server_error":
|
||||
return "Authentication server error. Please try again later."
|
||||
default:
|
||||
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
|
||||
}
|
||||
default:
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
218
internal/auth/claude/html_templates.go
Normal file
218
internal/auth/claude/html_templates.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
// LoginSuccessHtml is the HTML template displayed to users after successful OAuth authentication.
|
||||
// This template provides a user-friendly success page with options to close the window
|
||||
// or navigate to the Claude platform. It includes automatic window closing functionality
|
||||
// and keyboard accessibility features.
|
||||
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication Successful - Claude</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-notice {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.setup-notice h3 {
|
||||
color: #92400e;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.setup-notice p {
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.setup-notice a {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.setup-notice a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.button-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.button-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.countdown {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p class="subtitle">You have successfully authenticated with Claude. You can now close this window and return to your terminal to continue.</p>
|
||||
|
||||
{{SETUP_NOTICE}}
|
||||
|
||||
<div class="actions">
|
||||
<button class="button button-primary" onclick="window.close()">
|
||||
<span>Close Window</span>
|
||||
</button>
|
||||
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
|
||||
<span>Open Platform</span>
|
||||
<span>↗</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="countdown">
|
||||
This window will close automatically in <span id="countdown">10</span> seconds
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let countdown = 10;
|
||||
const countdownElement = document.getElementById('countdown');
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
countdownElement.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Close window when user presses Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the close button for keyboard accessibility
|
||||
document.querySelector('.button-primary').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// SetupNoticeHtml is the HTML template for the setup notice section.
|
||||
// This template is embedded within the success page to inform users about
|
||||
// additional setup steps required to complete their Claude account configuration.
|
||||
const SetupNoticeHtml = `
|
||||
<div class="setup-notice">
|
||||
<h3>Additional Setup Required</h3>
|
||||
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Claude</a> to configure your account.</p>
|
||||
</div>`
|
||||
320
internal/auth/claude/oauth_server.go
Normal file
320
internal/auth/claude/oauth_server.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||
// It listens for the authorization code response from the OAuth provider
|
||||
// and captures the necessary parameters to complete the authentication flow.
|
||||
type OAuthServer struct {
|
||||
// server is the underlying HTTP server instance
|
||||
server *http.Server
|
||||
// port is the port number on which the server listens
|
||||
port int
|
||||
// resultChan is a channel for sending OAuth results
|
||||
resultChan chan *OAuthResult
|
||||
// errorChan is a channel for sending OAuth errors
|
||||
errorChan chan error
|
||||
// mu is a mutex for protecting server state
|
||||
mu sync.Mutex
|
||||
// running indicates whether the server is currently running
|
||||
running bool
|
||||
}
|
||||
|
||||
// OAuthResult contains the result of the OAuth callback.
|
||||
// It holds either the authorization code and state for successful authentication
|
||||
// or an error message if the authentication failed.
|
||||
type OAuthResult struct {
|
||||
// Code is the authorization code received from the OAuth provider
|
||||
Code string
|
||||
// State is the state parameter used to prevent CSRF attacks
|
||||
State string
|
||||
// Error contains any error message if the OAuth flow failed
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewOAuthServer creates a new OAuth callback server.
|
||||
// It initializes the server with the specified port and creates channels
|
||||
// for handling OAuth results and errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - port: The port number on which the server should listen
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthServer: A new OAuthServer instance
|
||||
func NewOAuthServer(port int) *OAuthServer {
|
||||
return &OAuthServer{
|
||||
port: port,
|
||||
resultChan: make(chan *OAuthResult, 1),
|
||||
errorChan: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the OAuth callback server.
|
||||
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||
// and begins listening on the specified port.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to start
|
||||
func (s *OAuthServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("server is already running")
|
||||
}
|
||||
|
||||
// Check if port is available
|
||||
if !s.isPortAvailable() {
|
||||
return fmt.Errorf("port %d is already in use", s.port)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", s.handleCallback)
|
||||
mux.HandleFunc("/success", s.handleSuccess)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
s.running = true
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the OAuth callback server.
|
||||
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for controlling the shutdown process
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to stop gracefully
|
||||
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("Stopping OAuth callback server")
|
||||
|
||||
// Create a context with timeout for shutdown
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.server.Shutdown(shutdownCtx)
|
||||
s.running = false
|
||||
s.server = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||
// It blocks until either an OAuth result is received, an error occurs,
|
||||
// or the specified timeout is reached.
|
||||
//
|
||||
// Parameters:
|
||||
// - timeout: The maximum time to wait for the callback
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthResult: The OAuth result if successful
|
||||
// - error: An error if the callback times out or an error occurs
|
||||
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||
select {
|
||||
case result := <-s.resultChan:
|
||||
return result, nil
|
||||
case err := <-s.errorChan:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallback handles the OAuth callback endpoint.
|
||||
// It extracts the authorization code and state from the callback URL,
|
||||
// validates the parameters, and sends the result to the waiting channel.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Received OAuth callback")
|
||||
|
||||
// Validate request method
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract parameters
|
||||
query := r.URL.Query()
|
||||
code := query.Get("code")
|
||||
state := query.Get("state")
|
||||
errorParam := query.Get("error")
|
||||
|
||||
// Validate required parameters
|
||||
if errorParam != "" {
|
||||
log.Errorf("OAuth error received: %s", errorParam)
|
||||
result := &OAuthResult{
|
||||
Error: errorParam,
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Error("No authorization code received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_code",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if state == "" {
|
||||
log.Error("No state parameter received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_state",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No state parameter received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send successful result
|
||||
result := &OAuthResult{
|
||||
Code: code,
|
||||
State: state,
|
||||
}
|
||||
s.sendResult(result)
|
||||
|
||||
// Redirect to success page
|
||||
http.Redirect(w, r, "/success", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSuccess handles the success page endpoint.
|
||||
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Serving success page")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Parse query parameters for customization
|
||||
query := r.URL.Query()
|
||||
setupRequired := query.Get("setup_required") == "true"
|
||||
platformURL := query.Get("platform_url")
|
||||
if platformURL == "" {
|
||||
platformURL = "https://console.anthropic.com/"
|
||||
}
|
||||
|
||||
// Generate success page HTML with dynamic content
|
||||
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
|
||||
|
||||
_, err := w.Write([]byte(successHTML))
|
||||
if err != nil {
|
||||
log.Errorf("Failed to write success page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSuccessHTML creates the HTML content for the success page.
|
||||
// It customizes the page based on whether additional setup is required
|
||||
// and includes a link to the platform.
|
||||
//
|
||||
// Parameters:
|
||||
// - setupRequired: Whether additional setup is required after authentication
|
||||
// - platformURL: The URL to the platform for additional setup
|
||||
//
|
||||
// Returns:
|
||||
// - string: The HTML content for the success page
|
||||
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||
html := LoginSuccessHtml
|
||||
|
||||
// Replace platform URL placeholder
|
||||
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
|
||||
// Add setup notice if required
|
||||
if setupRequired {
|
||||
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
|
||||
} else {
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// sendResult sends the OAuth result to the waiting channel.
|
||||
// It ensures that the result is sent without blocking the handler.
|
||||
//
|
||||
// Parameters:
|
||||
// - result: The OAuth result to send
|
||||
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||
select {
|
||||
case s.resultChan <- result:
|
||||
log.Debug("OAuth result sent to channel")
|
||||
default:
|
||||
log.Warn("OAuth result channel is full, result dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// isPortAvailable checks if the specified port is available.
|
||||
// It attempts to listen on the port to determine availability.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the port is available, false otherwise
|
||||
func (s *OAuthServer) isPortAvailable() bool {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRunning returns whether the server is currently running.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the server is running, false otherwise
|
||||
func (s *OAuthServer) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
56
internal/auth/claude/pkce.go
Normal file
56
internal/auth/claude/pkce.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
|
||||
// following RFC 7636 specifications for OAuth 2.0 PKCE extension.
|
||||
// This provides additional security for the OAuth flow by ensuring that
|
||||
// only the client that initiated the request can exchange the authorization code.
|
||||
//
|
||||
// Returns:
|
||||
// - *PKCECodes: A struct containing the code verifier and challenge
|
||||
// - error: An error if the generation fails, nil otherwise
|
||||
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||
// Generate code verifier: 43-128 characters, URL-safe
|
||||
codeVerifier, err := generateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||
}
|
||||
|
||||
// Generate code challenge using S256 method
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
return &PKCECodes{
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically random string
|
||||
// of 128 characters using URL-safe base64 encoding
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||
bytes := make([]byte, 96)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Encode to URL-safe base64 without padding
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a SHA256 hash of the code verifier
|
||||
// and encodes it using URL-safe base64 encoding without padding
|
||||
func generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||
}
|
||||
73
internal/auth/claude/token.go
Normal file
73
internal/auth/claude/token.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package claude provides authentication and token management functionality
|
||||
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Claude API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
)
|
||||
|
||||
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Claude-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type ClaudeTokenStorage struct {
|
||||
// IDToken is the JWT ID token containing user claims and identity information.
|
||||
IDToken string `json:"id_token"`
|
||||
|
||||
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||
AccessToken string `json:"access_token"`
|
||||
|
||||
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
|
||||
// LastRefresh is the timestamp of the last token refresh operation.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
|
||||
// Email is the Anthropic account email address associated with this token.
|
||||
Email string `json:"email"`
|
||||
|
||||
// Type indicates the authentication provider type, always "claude" for this storage.
|
||||
Type string `json:"type"`
|
||||
|
||||
// Expire is the timestamp when the current access token expires.
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Claude token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "claude"
|
||||
|
||||
// Create directory structure if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
// Create the token file
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
// Encode and write the token data as JSON
|
||||
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,14 +6,19 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// OAuthError represents an OAuth-specific error
|
||||
// OAuthError represents an OAuth-specific error.
|
||||
type OAuthError struct {
|
||||
Code string `json:"error"`
|
||||
// Code is the OAuth error code.
|
||||
Code string `json:"error"`
|
||||
// Description is a human-readable description of the error.
|
||||
Description string `json:"error_description,omitempty"`
|
||||
URI string `json:"error_uri,omitempty"`
|
||||
StatusCode int `json:"-"`
|
||||
// URI is a URI identifying a human-readable web page with information about the error.
|
||||
URI string `json:"error_uri,omitempty"`
|
||||
// StatusCode is the HTTP status code associated with the error.
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the OAuth error.
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.Description != "" {
|
||||
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||
@@ -21,7 +26,7 @@ func (e *OAuthError) Error() string {
|
||||
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||
}
|
||||
|
||||
// NewOAuthError creates a new OAuth error
|
||||
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||
return &OAuthError{
|
||||
Code: code,
|
||||
@@ -30,14 +35,19 @@ func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationError represents authentication-related errors
|
||||
// AuthenticationError represents authentication-related errors.
|
||||
type AuthenticationError struct {
|
||||
Type string `json:"type"`
|
||||
// Type is the type of authentication error.
|
||||
Type string `json:"type"`
|
||||
// Message is a human-readable message describing the error.
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Cause error `json:"-"`
|
||||
// Code is the HTTP status code associated with the error.
|
||||
Code int `json:"code"`
|
||||
// Cause is the underlying error that caused this authentication error.
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the authentication error.
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||
@@ -45,44 +55,50 @@ func (e *AuthenticationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Common authentication error types
|
||||
// Common authentication error types.
|
||||
var (
|
||||
ErrTokenExpired = &AuthenticationError{
|
||||
Type: "token_expired",
|
||||
Message: "Access token has expired",
|
||||
Code: http.StatusUnauthorized,
|
||||
}
|
||||
// ErrTokenExpired = &AuthenticationError{
|
||||
// Type: "token_expired",
|
||||
// Message: "Access token has expired",
|
||||
// Code: http.StatusUnauthorized,
|
||||
// }
|
||||
|
||||
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||
ErrInvalidState = &AuthenticationError{
|
||||
Type: "invalid_state",
|
||||
Message: "OAuth state parameter is invalid",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||
ErrCodeExchangeFailed = &AuthenticationError{
|
||||
Type: "code_exchange_failed",
|
||||
Message: "Failed to exchange authorization code for tokens",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||
ErrServerStartFailed = &AuthenticationError{
|
||||
Type: "server_start_failed",
|
||||
Message: "Failed to start OAuth callback server",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||
ErrPortInUse = &AuthenticationError{
|
||||
Type: "port_in_use",
|
||||
Message: "OAuth callback port is already in use",
|
||||
Code: 13, // Special exit code for port-in-use
|
||||
}
|
||||
|
||||
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||
ErrCallbackTimeout = &AuthenticationError{
|
||||
Type: "callback_timeout",
|
||||
Message: "Timeout waiting for OAuth callback",
|
||||
Code: http.StatusRequestTimeout,
|
||||
}
|
||||
|
||||
// ErrBrowserOpenFailed represents an error when opening the browser for authentication fails.
|
||||
ErrBrowserOpenFailed = &AuthenticationError{
|
||||
Type: "browser_open_failed",
|
||||
Message: "Failed to open browser for authentication",
|
||||
@@ -90,7 +106,7 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// NewAuthenticationError creates a new authentication error with a cause
|
||||
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||
return &AuthenticationError{
|
||||
Type: baseErr.Type,
|
||||
@@ -100,21 +116,21 @@ func NewAuthenticationError(baseErr *AuthenticationError, cause error) *Authenti
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticationError checks if an error is an authentication error
|
||||
// IsAuthenticationError checks if an error is an authentication error.
|
||||
func IsAuthenticationError(err error) bool {
|
||||
var authenticationError *AuthenticationError
|
||||
ok := errors.As(err, &authenticationError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsOAuthError checks if an error is an OAuth error
|
||||
// IsOAuthError checks if an error is an OAuth error.
|
||||
func IsOAuthError(err error) bool {
|
||||
var oAuthError *OAuthError
|
||||
ok := errors.As(err, &oAuthError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetUserFriendlyMessage returns a user-friendly error message
|
||||
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||
func GetUserFriendlyMessage(err error) string {
|
||||
switch {
|
||||
case IsAuthenticationError(err):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package codex
|
||||
|
||||
// LoginSuccessHtml is the template for the OAuth success page
|
||||
// LoginSuccessHTML is the HTML template for the page shown after a successful
|
||||
// OAuth2 authentication with Codex. It informs the user that the authentication
|
||||
// was successful and provides a countdown timer to automatically close the window.
|
||||
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -202,7 +204,9 @@ const LoginSuccessHtml = `<!DOCTYPE html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// SetupNoticeHtml is the template for the setup notice section
|
||||
// SetupNoticeHTML is the HTML template for the section that provides instructions
|
||||
// for additional setup. This is displayed on the success page when further actions
|
||||
// are required from the user.
|
||||
const SetupNoticeHtml = `
|
||||
<div class="setup-notice">
|
||||
<h3>Additional Setup Required</h3>
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// JWTClaims represents the claims section of a JWT token
|
||||
// JWTClaims represents the claims section of a JSON Web Token (JWT).
|
||||
// It includes standard claims like issuer, subject, and expiration time, as well as
|
||||
// custom claims specific to OpenAI's authentication.
|
||||
type JWTClaims struct {
|
||||
AtHash string `json:"at_hash"`
|
||||
Aud []string `json:"aud"`
|
||||
@@ -25,12 +27,18 @@ type JWTClaims struct {
|
||||
Sid string `json:"sid"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
// Organizations defines the structure for organization details within the JWT claims.
|
||||
// It holds information about the user's organization, such as ID, role, and title.
|
||||
type Organizations struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Role string `json:"role"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// CodexAuthInfo contains authentication-related details specific to Codex.
|
||||
// This includes ChatGPT account information, subscription status, and user/organization IDs.
|
||||
type CodexAuthInfo struct {
|
||||
ChatgptAccountID string `json:"chatgpt_account_id"`
|
||||
ChatgptPlanType string `json:"chatgpt_plan_type"`
|
||||
@@ -43,8 +51,10 @@ type CodexAuthInfo struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// ParseJWTToken parses a JWT token and extracts the claims without verification
|
||||
// This is used for extracting user information from ID tokens
|
||||
// ParseJWTToken parses a JWT token string and extracts its claims without performing
|
||||
// cryptographic signature verification. This is useful for introspecting the token's
|
||||
// contents to retrieve user information from an ID token after it has been validated
|
||||
// by the authentication server.
|
||||
func ParseJWTToken(token string) (*JWTClaims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
@@ -65,7 +75,9 @@ func ParseJWTToken(token string) (*JWTClaims, error) {
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// base64URLDecode decodes a base64 URL-encoded string with proper padding
|
||||
// base64URLDecode decodes a Base64 URL-encoded string, adding padding if necessary.
|
||||
// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures
|
||||
// correct decoding by re-adding the padding before decoding.
|
||||
func base64URLDecode(data string) ([]byte, error) {
|
||||
// Add padding if necessary
|
||||
switch len(data) % 4 {
|
||||
@@ -78,12 +90,13 @@ func base64URLDecode(data string) ([]byte, error) {
|
||||
return base64.URLEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
// GetUserEmail extracts the user email from JWT claims
|
||||
// GetUserEmail extracts the user's email address from the JWT claims.
|
||||
func (c *JWTClaims) GetUserEmail() string {
|
||||
return c.Email
|
||||
}
|
||||
|
||||
// GetAccountID extracts the user ID from JWT claims (subject)
|
||||
// GetAccountID extracts the user's account ID (subject) from the JWT claims.
|
||||
// It retrieves the unique identifier for the user's ChatGPT account.
|
||||
func (c *JWTClaims) GetAccountID() string {
|
||||
return c.CodexAuthInfo.ChatgptAccountID
|
||||
}
|
||||
|
||||
@@ -13,24 +13,45 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OAuthServer handles the local HTTP server for OAuth callbacks
|
||||
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||
// It listens for the authorization code response from the OAuth provider
|
||||
// and captures the necessary parameters to complete the authentication flow.
|
||||
type OAuthServer struct {
|
||||
server *http.Server
|
||||
port int
|
||||
// server is the underlying HTTP server instance
|
||||
server *http.Server
|
||||
// port is the port number on which the server listens
|
||||
port int
|
||||
// resultChan is a channel for sending OAuth results
|
||||
resultChan chan *OAuthResult
|
||||
errorChan chan error
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
// errorChan is a channel for sending OAuth errors
|
||||
errorChan chan error
|
||||
// mu is a mutex for protecting server state
|
||||
mu sync.Mutex
|
||||
// running indicates whether the server is currently running
|
||||
running bool
|
||||
}
|
||||
|
||||
// OAuthResult contains the result of the OAuth callback
|
||||
// OAuthResult contains the result of the OAuth callback.
|
||||
// It holds either the authorization code and state for successful authentication
|
||||
// or an error message if the authentication failed.
|
||||
type OAuthResult struct {
|
||||
Code string
|
||||
// Code is the authorization code received from the OAuth provider
|
||||
Code string
|
||||
// State is the state parameter used to prevent CSRF attacks
|
||||
State string
|
||||
// Error contains any error message if the OAuth flow failed
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewOAuthServer creates a new OAuth callback server
|
||||
// NewOAuthServer creates a new OAuth callback server.
|
||||
// It initializes the server with the specified port and creates channels
|
||||
// for handling OAuth results and errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - port: The port number on which the server should listen
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthServer: A new OAuthServer instance
|
||||
func NewOAuthServer(port int) *OAuthServer {
|
||||
return &OAuthServer{
|
||||
port: port,
|
||||
@@ -39,8 +60,13 @@ func NewOAuthServer(port int) *OAuthServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the OAuth callback server
|
||||
func (s *OAuthServer) Start(ctx context.Context) error {
|
||||
// Start starts the OAuth callback server.
|
||||
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||
// and begins listening on the specified port.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to start
|
||||
func (s *OAuthServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -79,7 +105,14 @@ func (s *OAuthServer) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the OAuth callback server
|
||||
// Stop gracefully stops the OAuth callback server.
|
||||
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for controlling the shutdown process
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to stop gracefully
|
||||
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -101,7 +134,16 @@ func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitForCallback waits for the OAuth callback with a timeout
|
||||
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||
// It blocks until either an OAuth result is received, an error occurs,
|
||||
// or the specified timeout is reached.
|
||||
//
|
||||
// Parameters:
|
||||
// - timeout: The maximum time to wait for the callback
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthResult: The OAuth result if successful
|
||||
// - error: An error if the callback times out or an error occurs
|
||||
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||
select {
|
||||
case result := <-s.resultChan:
|
||||
@@ -113,7 +155,13 @@ func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, erro
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallback handles the OAuth callback endpoint
|
||||
// handleCallback handles the OAuth callback endpoint.
|
||||
// It extracts the authorization code and state from the callback URL,
|
||||
// validates the parameters, and sends the result to the waiting channel.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Received OAuth callback")
|
||||
|
||||
@@ -171,7 +219,12 @@ func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/success", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSuccess handles the success page endpoint
|
||||
// handleSuccess handles the success page endpoint.
|
||||
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Serving success page")
|
||||
|
||||
@@ -195,7 +248,16 @@ func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// generateSuccessHTML creates the HTML content for the success page
|
||||
// generateSuccessHTML creates the HTML content for the success page.
|
||||
// It customizes the page based on whether additional setup is required
|
||||
// and includes a link to the platform.
|
||||
//
|
||||
// Parameters:
|
||||
// - setupRequired: Whether additional setup is required after authentication
|
||||
// - platformURL: The URL to the platform for additional setup
|
||||
//
|
||||
// Returns:
|
||||
// - string: The HTML content for the success page
|
||||
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||
html := LoginSuccessHtml
|
||||
|
||||
@@ -213,7 +275,11 @@ func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string
|
||||
return html
|
||||
}
|
||||
|
||||
// sendResult sends the OAuth result to the waiting channel
|
||||
// sendResult sends the OAuth result to the waiting channel.
|
||||
// It ensures that the result is sent without blocking the handler.
|
||||
//
|
||||
// Parameters:
|
||||
// - result: The OAuth result to send
|
||||
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||
select {
|
||||
case s.resultChan <- result:
|
||||
@@ -223,7 +289,11 @@ func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// isPortAvailable checks if the specified port is available
|
||||
// isPortAvailable checks if the specified port is available.
|
||||
// It attempts to listen on the port to determine availability.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the port is available, false otherwise
|
||||
func (s *OAuthServer) isPortAvailable() bool {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
@@ -236,7 +306,10 @@ func (s *OAuthServer) isPortAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRunning returns whether the server is currently running
|
||||
// IsRunning returns whether the server is currently running.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the server is running, false otherwise
|
||||
func (s *OAuthServer) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package codex
|
||||
|
||||
// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
|
||||
// PKCECodes holds the verification codes for the OAuth2 PKCE (Proof Key for Code Exchange) flow.
|
||||
// PKCE is an extension to the Authorization Code flow to prevent CSRF and authorization code injection attacks.
|
||||
type PKCECodes struct {
|
||||
// CodeVerifier is the cryptographically random string used to correlate
|
||||
// the authorization request to the token request
|
||||
@@ -9,7 +10,8 @@ type PKCECodes struct {
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
// CodexTokenData holds OAuth token information from OpenAI
|
||||
// CodexTokenData holds the OAuth token information obtained from OpenAI.
|
||||
// It includes the ID token, access token, refresh token, and associated user details.
|
||||
type CodexTokenData struct {
|
||||
// IDToken is the JWT ID token containing user claims
|
||||
IDToken string `json:"id_token"`
|
||||
@@ -25,7 +27,8 @@ type CodexTokenData struct {
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// CodexAuthBundle aggregates authentication data after OAuth flow completion
|
||||
// CodexAuthBundle aggregates all authentication-related data after the OAuth flow is complete.
|
||||
// This includes the API key, token data, and the timestamp of the last refresh.
|
||||
type CodexAuthBundle struct {
|
||||
// APIKey is the OpenAI API key obtained from token exchange
|
||||
APIKey string `json:"api_key"`
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Package codex provides authentication and token management for OpenAI's Codex API.
|
||||
// It handles the OAuth2 flow, including generating authorization URLs, exchanging
|
||||
// authorization codes for tokens, and refreshing expired tokens. The package also
|
||||
// defines data structures for storing and managing Codex authentication credentials.
|
||||
package codex
|
||||
|
||||
import (
|
||||
@@ -10,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -22,19 +26,24 @@ const (
|
||||
redirectURI = "http://localhost:1455/auth/callback"
|
||||
)
|
||||
|
||||
// CodexAuth handles OpenAI OAuth2 authentication flow
|
||||
// CodexAuth handles the OpenAI OAuth2 authentication flow.
|
||||
// It manages the HTTP client and provides methods for generating authorization URLs,
|
||||
// exchanging authorization codes for tokens, and refreshing access tokens.
|
||||
type CodexAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCodexAuth creates a new OpenAI authentication service
|
||||
// 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 &CodexAuth{
|
||||
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAuthURL creates the OAuth authorization URL with PKCE
|
||||
// GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange).
|
||||
// It constructs the URL with the necessary parameters, including the client ID,
|
||||
// response type, redirect URI, scopes, and PKCE challenge.
|
||||
func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) {
|
||||
if pkceCodes == nil {
|
||||
return "", fmt.Errorf("PKCE codes are required")
|
||||
@@ -57,7 +66,9 @@ func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string,
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges authorization code for access tokens
|
||||
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
|
||||
// It performs an HTTP POST request to the OpenAI token endpoint with the provided
|
||||
// authorization code and PKCE verifier.
|
||||
func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
|
||||
if pkceCodes == nil {
|
||||
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||
@@ -143,7 +154,9 @@ func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkce
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes the access token using the refresh token
|
||||
// RefreshTokens refreshes an access token using a refresh token.
|
||||
// This method is called when an access token has expired. It makes a request to the
|
||||
// token endpoint to obtain a new set of tokens.
|
||||
func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) {
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
@@ -216,7 +229,8 @@ func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a new CodexTokenStorage from auth bundle and user info
|
||||
// CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle.
|
||||
// It populates the storage struct with token data, user information, and timestamps.
|
||||
func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage {
|
||||
storage := &CodexTokenStorage{
|
||||
IDToken: bundle.TokenData.IDToken,
|
||||
@@ -231,7 +245,9 @@ func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStora
|
||||
return storage
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry refreshes tokens with automatic retry logic
|
||||
// RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism.
|
||||
// It attempts to refresh the tokens up to a specified maximum number of retries,
|
||||
// with an exponential backoff strategy to handle transient network errors.
|
||||
func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
@@ -257,7 +273,8 @@ func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken str
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing token storage with new token data
|
||||
// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
|
||||
// This is typically called after a successful token refresh to persist the new credentials.
|
||||
func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
|
||||
storage.IDToken = tokenData.IDToken
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Package codex provides authentication and token management functionality
|
||||
// for OpenAI's Codex AI services. It handles OAuth2 PKCE (Proof Key for Code Exchange)
|
||||
// code generation for secure authentication flows.
|
||||
package codex
|
||||
|
||||
import (
|
||||
@@ -7,8 +10,10 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
|
||||
// following RFC 7636 specifications for OAuth 2.0 PKCE extension
|
||||
// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.
|
||||
// It creates a cryptographically random code verifier and its corresponding
|
||||
// SHA256 code challenge, as specified in RFC 7636. This is a critical security
|
||||
// feature for the OAuth 2.0 authorization code flow.
|
||||
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||
// Generate code verifier: 43-128 characters, URL-safe
|
||||
codeVerifier, err := generateCodeVerifier()
|
||||
@@ -25,8 +30,10 @@ func GeneratePKCECodes() (*PKCECodes, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically random string
|
||||
// of 128 characters using URL-safe base64 encoding
|
||||
// generateCodeVerifier creates a cryptographically secure random string to be used
|
||||
// as the code verifier in the PKCE flow. The verifier is a high-entropy string
|
||||
// that is later used to prove possession of the client that initiated the
|
||||
// authorization request.
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||
bytes := make([]byte, 96)
|
||||
@@ -39,8 +46,10 @@ func generateCodeVerifier() (string, error) {
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a SHA256 hash of the code verifier
|
||||
// and encodes it using URL-safe base64 encoding without padding
|
||||
// generateCodeChallenge creates a code challenge from a given code verifier.
|
||||
// The challenge is derived by taking the SHA256 hash of the verifier and then
|
||||
// Base64 URL-encoding the result. This is sent in the initial authorization
|
||||
// request and later verified against the verifier.
|
||||
func generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||
|
||||
@@ -1,37 +1,52 @@
|
||||
// Package codex provides authentication and token management functionality
|
||||
// for OpenAI's Codex AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Codex API.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
)
|
||||
|
||||
// CodexTokenStorage extends the existing GeminiTokenStorage for OpenAI-specific data
|
||||
// It maintains compatibility with the existing auth system while adding OpenAI-specific fields
|
||||
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Codex-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type CodexTokenStorage struct {
|
||||
// IDToken is the JWT ID token containing user claims
|
||||
// IDToken is the JWT ID token containing user claims and identity information.
|
||||
IDToken string `json:"id_token"`
|
||||
// AccessToken is the OAuth2 access token for API access
|
||||
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens
|
||||
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// AccountID is the OpenAI account identifier
|
||||
// AccountID is the OpenAI account identifier associated with this token.
|
||||
AccountID string `json:"account_id"`
|
||||
// LastRefresh is the timestamp of the last token refresh
|
||||
// LastRefresh is the timestamp of the last token refresh operation.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
// Email is the OpenAI account email
|
||||
// Email is the OpenAI account email address associated with this token.
|
||||
Email string `json:"email"`
|
||||
// Type indicates the type (gemini, chatgpt, claude) of token storage.
|
||||
// Type indicates the authentication provider type, always "codex" for this storage.
|
||||
Type string `json:"type"`
|
||||
// Expire is the timestamp of the token expire
|
||||
// Expire is the timestamp when the current access token expires.
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the token storage to a JSON file.
|
||||
// SaveTokenToFile serializes the Codex token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "codex"
|
||||
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
|
||||
26
internal/auth/empty/token.go
Normal file
26
internal/auth/empty/token.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Package empty provides a no-operation token storage implementation.
|
||||
// This package is used when authentication tokens are not required or when
|
||||
// using API key-based authentication instead of OAuth tokens for any provider.
|
||||
package empty
|
||||
|
||||
// EmptyStorage is a no-operation implementation of the TokenStorage interface.
|
||||
// It provides empty implementations for scenarios where token storage is not needed,
|
||||
// such as when using API keys instead of OAuth tokens for authentication.
|
||||
type EmptyStorage struct {
|
||||
// Type indicates the authentication provider type, always "empty" for this implementation.
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile is a no-operation implementation that always succeeds.
|
||||
// This method satisfies the TokenStorage interface but performs no actual file operations
|
||||
// since empty storage doesn't require persistent token data.
|
||||
//
|
||||
// Parameters:
|
||||
// - _: The file path parameter is ignored in this implementation
|
||||
//
|
||||
// Returns:
|
||||
// - error: Always returns nil (no error)
|
||||
func (ts *EmptyStorage) SaveTokenToFile(_ string) error {
|
||||
ts.Type = "empty"
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Package auth provides OAuth2 authentication functionality for Google Cloud APIs.
|
||||
// It handles the complete OAuth2 flow including token storage, web-based authentication,
|
||||
// proxy support, and automatic token refresh. The package supports both SOCKS5 and HTTP/HTTPS proxies.
|
||||
// Package gemini provides authentication and token management functionality
|
||||
// for Google's Gemini AI services. It handles OAuth2 authentication flows,
|
||||
// including obtaining tokens via web-based authorization, storing tokens,
|
||||
// and refreshing them when they expire.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
@@ -14,9 +15,10 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"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/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"golang.org/x/net/proxy"
|
||||
@@ -38,9 +40,13 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// GeminiAuth provides methods for handling the Gemini OAuth2 authentication flow.
|
||||
// It encapsulates the logic for obtaining, storing, and refreshing authentication tokens
|
||||
// for Google's Gemini AI services.
|
||||
type GeminiAuth struct {
|
||||
}
|
||||
|
||||
// NewGeminiAuth creates a new instance of GeminiAuth.
|
||||
func NewGeminiAuth() *GeminiAuth {
|
||||
return &GeminiAuth{}
|
||||
}
|
||||
@@ -48,6 +54,16 @@ func NewGeminiAuth() *GeminiAuth {
|
||||
// GetAuthenticatedClient configures and returns an HTTP client ready for making authenticated API calls.
|
||||
// It manages the entire OAuth2 flow, including handling proxies, loading existing tokens,
|
||||
// initiating a new web-based OAuth flow if necessary, and refreshing tokens.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the HTTP client
|
||||
// - ts: The Gemini token storage containing authentication tokens
|
||||
// - cfg: The configuration containing proxy settings
|
||||
// - noBrowser: Optional parameter to disable browser opening
|
||||
//
|
||||
// Returns:
|
||||
// - *http.Client: An HTTP client configured with authentication
|
||||
// - error: An error if the client configuration fails, nil otherwise
|
||||
func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiTokenStorage, cfg *config.Config, noBrowser ...bool) (*http.Client, error) {
|
||||
// Configure proxy settings for the HTTP client if a proxy URL is provided.
|
||||
proxyURL, err := url.Parse(cfg.ProxyURL)
|
||||
@@ -91,7 +107,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
|
||||
|
||||
// If no token is found in storage, initiate the web-based OAuth flow.
|
||||
if ts.Token == nil {
|
||||
log.Info("Could not load token from file, starting OAuth flow.")
|
||||
fmt.Printf("Could not load token from file, starting OAuth flow.\n")
|
||||
token, err = g.getTokenFromWeb(ctx, conf, noBrowser...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get token from web: %w", err)
|
||||
@@ -117,6 +133,16 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
|
||||
|
||||
// createTokenStorage creates a new GeminiTokenStorage object. It fetches the user's email
|
||||
// using the provided token and populates the storage structure.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the HTTP request
|
||||
// - config: The OAuth2 configuration
|
||||
// - token: The OAuth2 token to use for authentication
|
||||
// - projectID: The Google Cloud Project ID to associate with this token
|
||||
//
|
||||
// Returns:
|
||||
// - *GeminiTokenStorage: A new token storage object with user information
|
||||
// - error: An error if the token storage creation fails, nil otherwise
|
||||
func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
|
||||
httpClient := config.Client(ctx, token)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
||||
@@ -143,9 +169,9 @@ func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Conf
|
||||
|
||||
emailResult := gjson.GetBytes(bodyBytes, "email")
|
||||
if emailResult.Exists() && emailResult.Type == gjson.String {
|
||||
log.Infof("Authenticated user email: %s", emailResult.String())
|
||||
fmt.Printf("Authenticated user email: %s\n", emailResult.String())
|
||||
} else {
|
||||
log.Info("Failed to get user email from token")
|
||||
fmt.Println("Failed to get user email from token")
|
||||
}
|
||||
|
||||
var ifToken map[string]any
|
||||
@@ -174,6 +200,15 @@ func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Conf
|
||||
// It starts a local HTTP server to listen for the callback from Google's auth server,
|
||||
// opens the user's browser to the authorization URL, and exchanges the received
|
||||
// authorization code for an access token.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the HTTP client
|
||||
// - config: The OAuth2 configuration
|
||||
// - noBrowser: Optional parameter to disable browser opening
|
||||
//
|
||||
// Returns:
|
||||
// - *oauth2.Token: The OAuth2 token obtained from the authorization flow
|
||||
// - error: An error if the token acquisition fails, nil otherwise
|
||||
func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, noBrowser ...bool) (*oauth2.Token, error) {
|
||||
// Use a channel to pass the authorization code from the HTTP handler to the main function.
|
||||
codeChan := make(chan string)
|
||||
@@ -211,17 +246,19 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
|
||||
if len(noBrowser) == 1 && !noBrowser[0] {
|
||||
log.Info("Opening browser for authentication...")
|
||||
fmt.Println("Opening browser for authentication...")
|
||||
|
||||
// Check if browser is available
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available on this system")
|
||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
util.PrintSSHTunnelInstructions(8085)
|
||||
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
} else {
|
||||
if err := browser.OpenURL(authURL); err != nil {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
util.PrintSSHTunnelInstructions(8085)
|
||||
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
|
||||
// Log platform info for debugging
|
||||
platformInfo := browser.GetPlatformInfo()
|
||||
@@ -231,10 +268,11 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||
util.PrintSSHTunnelInstructions(8085)
|
||||
fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for authentication callback...")
|
||||
fmt.Println("Waiting for authentication callback...")
|
||||
|
||||
// Wait for the authorization code or an error.
|
||||
var authCode string
|
||||
@@ -258,6 +296,6 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Authentication successful.")
|
||||
fmt.Println("Authentication successful.")
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GeminiTokenStorage defines the structure for storing OAuth2 token information,
|
||||
// along with associated user and project details. This data is typically
|
||||
// serialized to a JSON file for persistence.
|
||||
// GeminiTokenStorage stores OAuth2 token information for Google Gemini API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Gemini-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type GeminiTokenStorage struct {
|
||||
// Token holds the raw OAuth2 token data, including access and refresh tokens.
|
||||
Token any `json:"token"`
|
||||
@@ -29,14 +33,13 @@ type GeminiTokenStorage struct {
|
||||
// Checked indicates if the associated Cloud AI API has been verified as enabled.
|
||||
Checked bool `json:"checked"`
|
||||
|
||||
// Type indicates the type (gemini, chatgpt, claude) of token storage.
|
||||
// Type indicates the authentication provider type, always "gemini" for this storage.
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the token storage to a JSON file.
|
||||
// SaveTokenToFile serializes the Gemini token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path. It ensures the file is
|
||||
// properly closed after writing.
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
@@ -44,8 +47,9 @@ type GeminiTokenStorage struct {
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "gemini"
|
||||
if err := os.MkdirAll(path.Dir(authFilePath), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
@@ -54,7 +58,9 @@ func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("failed to close file: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||
@@ -62,3 +68,20 @@ func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CredentialFileName returns the filename used to persist Gemini CLI credentials.
|
||||
// When projectID represents multiple projects (comma-separated or literal ALL),
|
||||
// the suffix is normalized to "all" and a "gemini-" prefix is enforced to keep
|
||||
// web and CLI generated files consistent.
|
||||
func CredentialFileName(email, projectID string, includeProviderPrefix bool) string {
|
||||
email = strings.TrimSpace(email)
|
||||
project := strings.TrimSpace(projectID)
|
||||
if strings.EqualFold(project, "all") || strings.Contains(project, ",") {
|
||||
return fmt.Sprintf("gemini-%s-all.json", email)
|
||||
}
|
||||
prefix := ""
|
||||
if includeProviderPrefix {
|
||||
prefix = "gemini-"
|
||||
}
|
||||
return fmt.Sprintf("%s%s-%s.json", prefix, email, project)
|
||||
}
|
||||
|
||||
276
internal/auth/iflow/iflow_auth.go
Normal file
276
internal/auth/iflow/iflow_auth.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// OAuth endpoints and client metadata are derived from the reference Python implementation.
|
||||
iFlowOAuthTokenEndpoint = "https://iflow.cn/oauth/token"
|
||||
iFlowOAuthAuthorizeEndpoint = "https://iflow.cn/oauth"
|
||||
iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo"
|
||||
iFlowSuccessRedirectURL = "https://iflow.cn/oauth/success"
|
||||
|
||||
// Client credentials provided by iFlow for the Code Assist integration.
|
||||
iFlowOAuthClientID = "10009311001"
|
||||
iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
|
||||
)
|
||||
|
||||
// DefaultAPIBaseURL is the canonical chat completions endpoint.
|
||||
const DefaultAPIBaseURL = "https://apis.iflow.cn/v1"
|
||||
|
||||
// SuccessRedirectURL is exposed for consumers needing the official success page.
|
||||
const SuccessRedirectURL = iFlowSuccessRedirectURL
|
||||
|
||||
// CallbackPort defines the local port used for OAuth callbacks.
|
||||
const CallbackPort = 11451
|
||||
|
||||
// IFlowAuth encapsulates the HTTP client helpers for the OAuth flow.
|
||||
type IFlowAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewIFlowAuth constructs a new IFlowAuth with proxy-aware transport.
|
||||
func NewIFlowAuth(cfg *config.Config) *IFlowAuth {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
return &IFlowAuth{httpClient: util.SetProxy(&cfg.SDKConfig, client)}
|
||||
}
|
||||
|
||||
// AuthorizationURL builds the authorization URL and matching redirect URI.
|
||||
func (ia *IFlowAuth) AuthorizationURL(state string, port int) (authURL, redirectURI string) {
|
||||
redirectURI = fmt.Sprintf("http://localhost:%d/oauth2callback", port)
|
||||
values := url.Values{}
|
||||
values.Set("loginMethod", "phone")
|
||||
values.Set("type", "phone")
|
||||
values.Set("redirect", redirectURI)
|
||||
values.Set("state", state)
|
||||
values.Set("client_id", iFlowOAuthClientID)
|
||||
authURL = fmt.Sprintf("%s?%s", iFlowOAuthAuthorizeEndpoint, values.Encode())
|
||||
return authURL, redirectURI
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
|
||||
func (ia *IFlowAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*IFlowTokenData, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", iFlowOAuthClientID)
|
||||
form.Set("client_secret", iFlowOAuthClientSecret)
|
||||
|
||||
req, err := ia.newTokenRequest(ctx, form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ia.doTokenRequest(ctx, req)
|
||||
}
|
||||
|
||||
// RefreshTokens exchanges a refresh token for a new access token.
|
||||
func (ia *IFlowAuth) RefreshTokens(ctx context.Context, refreshToken string) (*IFlowTokenData, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", iFlowOAuthClientID)
|
||||
form.Set("client_secret", iFlowOAuthClientSecret)
|
||||
|
||||
req, err := ia.newTokenRequest(ctx, form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ia.doTokenRequest(ctx, req)
|
||||
}
|
||||
|
||||
func (ia *IFlowAuth) newTokenRequest(ctx context.Context, form url.Values) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowOAuthTokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow token: create request failed: %w", err)
|
||||
}
|
||||
|
||||
basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + iFlowOAuthClientSecret))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Basic "+basic)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (ia *IFlowAuth) doTokenRequest(ctx context.Context, req *http.Request) (*IFlowTokenData, error) {
|
||||
resp, err := ia.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow token: request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow token: read response failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Debugf("iflow token request failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("iflow token: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var tokenResp IFlowTokenResponse
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("iflow token: decode response failed: %w", err)
|
||||
}
|
||||
|
||||
data := &IFlowTokenData{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
Scope: tokenResp.Scope,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
log.Debug(string(body))
|
||||
return nil, fmt.Errorf("iflow token: missing access token in response")
|
||||
}
|
||||
|
||||
info, errAPI := ia.FetchUserInfo(ctx, tokenResp.AccessToken)
|
||||
if errAPI != nil {
|
||||
return nil, fmt.Errorf("iflow token: fetch user info failed: %w", errAPI)
|
||||
}
|
||||
if strings.TrimSpace(info.APIKey) == "" {
|
||||
return nil, fmt.Errorf("iflow token: empty api key returned")
|
||||
}
|
||||
email := strings.TrimSpace(info.Email)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(info.Phone)
|
||||
}
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("iflow token: missing account email/phone in user info")
|
||||
}
|
||||
data.APIKey = info.APIKey
|
||||
data.Email = email
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// FetchUserInfo retrieves account metadata (including API key) for the provided access token.
|
||||
func (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*userInfoData, error) {
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return nil, fmt.Errorf("iflow api key: access token is empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := ia.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow api key: request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("iflow api key: read response failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Debugf("iflow api key failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("iflow api key: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var result userInfoResponse
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("iflow api key: decode body failed: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
return nil, fmt.Errorf("iflow api key: request not successful")
|
||||
}
|
||||
|
||||
if result.Data.APIKey == "" {
|
||||
return nil, fmt.Errorf("iflow api key: missing api key in response")
|
||||
}
|
||||
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage converts token data into persistence storage.
|
||||
func (ia *IFlowAuth) CreateTokenStorage(data *IFlowTokenData) *IFlowTokenStorage {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
return &IFlowTokenStorage{
|
||||
AccessToken: data.AccessToken,
|
||||
RefreshToken: data.RefreshToken,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
Expire: data.Expire,
|
||||
APIKey: data.APIKey,
|
||||
Email: data.Email,
|
||||
TokenType: data.TokenType,
|
||||
Scope: data.Scope,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates the persisted token storage with latest token data.
|
||||
func (ia *IFlowAuth) UpdateTokenStorage(storage *IFlowTokenStorage, data *IFlowTokenData) {
|
||||
if storage == nil || data == nil {
|
||||
return
|
||||
}
|
||||
storage.AccessToken = data.AccessToken
|
||||
storage.RefreshToken = data.RefreshToken
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
storage.Expire = data.Expire
|
||||
if data.APIKey != "" {
|
||||
storage.APIKey = data.APIKey
|
||||
}
|
||||
if data.Email != "" {
|
||||
storage.Email = data.Email
|
||||
}
|
||||
storage.TokenType = data.TokenType
|
||||
storage.Scope = data.Scope
|
||||
}
|
||||
|
||||
// IFlowTokenResponse models the OAuth token endpoint response.
|
||||
type IFlowTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// IFlowTokenData captures processed token details.
|
||||
type IFlowTokenData struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
TokenType string
|
||||
Scope string
|
||||
Expire string
|
||||
APIKey string
|
||||
Email string
|
||||
}
|
||||
|
||||
// userInfoResponse represents the structure returned by the user info endpoint.
|
||||
type userInfoResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data userInfoData `json:"data"`
|
||||
}
|
||||
|
||||
type userInfoData struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
43
internal/auth/iflow/iflow_token.go
Normal file
43
internal/auth/iflow/iflow_token.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
)
|
||||
|
||||
// IFlowTokenStorage persists iFlow OAuth credentials alongside the derived API key.
|
||||
type IFlowTokenStorage struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
Expire string `json:"expired"`
|
||||
APIKey string `json:"api_key"`
|
||||
Email string `json:"email"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serialises the token storage to disk.
|
||||
func (ts *IFlowTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "iflow"
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0o700); err != nil {
|
||||
return fmt.Errorf("iflow token: create directory failed: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("iflow token: create file failed: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||
return fmt.Errorf("iflow token: encode token failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
143
internal/auth/iflow/oauth_server.go
Normal file
143
internal/auth/iflow/oauth_server.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const errorRedirectURL = "https://iflow.cn/oauth/error"
|
||||
|
||||
// OAuthResult captures the outcome of the local OAuth callback.
|
||||
type OAuthResult struct {
|
||||
Code string
|
||||
State string
|
||||
Error string
|
||||
}
|
||||
|
||||
// OAuthServer provides a minimal HTTP server for handling the iFlow OAuth callback.
|
||||
type OAuthServer struct {
|
||||
server *http.Server
|
||||
port int
|
||||
result chan *OAuthResult
|
||||
errChan chan error
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewOAuthServer constructs a new OAuthServer bound to the provided port.
|
||||
func NewOAuthServer(port int) *OAuthServer {
|
||||
return &OAuthServer{
|
||||
port: port,
|
||||
result: make(chan *OAuthResult, 1),
|
||||
errChan: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the callback listener.
|
||||
func (s *OAuthServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running {
|
||||
return fmt.Errorf("iflow oauth server already running")
|
||||
}
|
||||
if !s.isPortAvailable() {
|
||||
return fmt.Errorf("port %d is already in use", s.port)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/oauth2callback", s.handleCallback)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
s.running = true
|
||||
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully terminates the callback listener.
|
||||
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
s.running = false
|
||||
s.server = nil
|
||||
}()
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// WaitForCallback blocks until a callback result, server error, or timeout occurs.
|
||||
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||
select {
|
||||
case res := <-s.result:
|
||||
return res, nil
|
||||
case err := <-s.errChan:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if errParam := strings.TrimSpace(query.Get("error")); errParam != "" {
|
||||
s.sendResult(&OAuthResult{Error: errParam})
|
||||
http.Redirect(w, r, errorRedirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(query.Get("code"))
|
||||
if code == "" {
|
||||
s.sendResult(&OAuthResult{Error: "missing_code"})
|
||||
http.Redirect(w, r, errorRedirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
state := query.Get("state")
|
||||
s.sendResult(&OAuthResult{Code: code, State: state})
|
||||
http.Redirect(w, r, SuccessRedirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *OAuthServer) sendResult(res *OAuthResult) {
|
||||
select {
|
||||
case s.result <- res:
|
||||
default:
|
||||
log.Debug("iflow oauth result channel full, dropping result")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthServer) isPortAvailable() bool {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = listener.Close()
|
||||
return true
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
// Package auth provides authentication functionality for various AI service providers.
|
||||
// It includes interfaces and implementations for token storage and authentication methods.
|
||||
package auth
|
||||
|
||||
// TokenStorage defines the interface for storing authentication tokens.
|
||||
// Implementations of this interface should provide methods to persist
|
||||
// authentication tokens to a file system location.
|
||||
type TokenStorage interface {
|
||||
// SaveTokenToFile persists authentication tokens to the specified file path.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The file path where the authentication tokens should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the save operation fails, nil otherwise
|
||||
SaveTokenToFile(authFilePath string) error
|
||||
}
|
||||
|
||||
359
internal/auth/qwen/qwen_auth.go
Normal file
359
internal/auth/qwen/qwen_auth.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package qwen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// QwenOAuthDeviceCodeEndpoint is the URL for initiating the OAuth 2.0 device authorization flow.
|
||||
QwenOAuthDeviceCodeEndpoint = "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||
// QwenOAuthTokenEndpoint is the URL for exchanging device codes or refresh tokens for access tokens.
|
||||
QwenOAuthTokenEndpoint = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||
// QwenOAuthClientID is the client identifier for the Qwen OAuth 2.0 application.
|
||||
QwenOAuthClientID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||
// QwenOAuthScope defines the permissions requested by the application.
|
||||
QwenOAuthScope = "openid profile email model.completion"
|
||||
// QwenOAuthGrantType specifies the grant type for the device code flow.
|
||||
QwenOAuthGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
)
|
||||
|
||||
// QwenTokenData represents the OAuth credentials, including access and refresh tokens.
|
||||
type QwenTokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain a new access token when the current one expires.
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
// TokenType indicates the type of token, typically "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
// ResourceURL specifies the base URL of the resource server.
|
||||
ResourceURL string `json:"resource_url,omitempty"`
|
||||
// Expire indicates the expiration date and time of the access token.
|
||||
Expire string `json:"expiry_date,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceFlow represents the response from the device authorization endpoint.
|
||||
type DeviceFlow struct {
|
||||
// DeviceCode is the code that the client uses to poll for an access token.
|
||||
DeviceCode string `json:"device_code"`
|
||||
// UserCode is the code that the user enters at the verification URI.
|
||||
UserCode string `json:"user_code"`
|
||||
// VerificationURI is the URL where the user can enter the user code to authorize the device.
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
// VerificationURIComplete is a URI that includes the user_code, which can be used to automatically
|
||||
// fill in the code on the verification page.
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
// ExpiresIn is the time in seconds until the device_code and user_code expire.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
// Interval is the minimum time in seconds that the client should wait between polling requests.
|
||||
Interval int `json:"interval"`
|
||||
// CodeVerifier is the cryptographically random string used in the PKCE flow.
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
// QwenTokenResponse represents the successful token response from the token endpoint.
|
||||
type QwenTokenResponse struct {
|
||||
// AccessToken is the token used to access protected resources.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain a new access token.
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
// TokenType indicates the type of token, typically "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
// ResourceURL specifies the base URL of the resource server.
|
||||
ResourceURL string `json:"resource_url,omitempty"`
|
||||
// ExpiresIn is the time in seconds until the access token expires.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// QwenAuth manages authentication and token handling for the Qwen API.
|
||||
type QwenAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewQwenAuth creates a new QwenAuth instance with a proxy-configured HTTP client.
|
||||
func NewQwenAuth(cfg *config.Config) *QwenAuth {
|
||||
return &QwenAuth{
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
// generateCodeVerifier generates a cryptographically random string for the PKCE code verifier.
|
||||
func (qa *QwenAuth) generateCodeVerifier() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a SHA-256 hash of the code verifier, used as the PKCE code challenge.
|
||||
func (qa *QwenAuth) generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// generatePKCEPair creates a new code verifier and its corresponding code challenge for PKCE.
|
||||
func (qa *QwenAuth) generatePKCEPair() (string, string, error) {
|
||||
codeVerifier, err := qa.generateCodeVerifier()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
codeChallenge := qa.generateCodeChallenge(codeVerifier)
|
||||
return codeVerifier, codeChallenge, nil
|
||||
}
|
||||
|
||||
// RefreshTokens exchanges a refresh token for a new access token.
|
||||
func (qa *QwenAuth) RefreshTokens(ctx context.Context, refreshToken string) (*QwenTokenData, error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
data.Set("client_id", QwenOAuthClientID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthTokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := qa.httpClient.Do(req)
|
||||
|
||||
// resp, err := qa.httpClient.PostForm(QwenOAuthTokenEndpoint, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errorData map[string]interface{}
|
||||
if err = json.Unmarshal(body, &errorData); err == nil {
|
||||
return nil, fmt.Errorf("token refresh failed: %v - %v", errorData["error"], errorData["error_description"])
|
||||
}
|
||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||
}
|
||||
|
||||
var tokenData QwenTokenResponse
|
||||
if err = json.Unmarshal(body, &tokenData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
return &QwenTokenData{
|
||||
AccessToken: tokenData.AccessToken,
|
||||
TokenType: tokenData.TokenType,
|
||||
RefreshToken: tokenData.RefreshToken,
|
||||
ResourceURL: tokenData.ResourceURL,
|
||||
Expire: time.Now().Add(time.Duration(tokenData.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitiateDeviceFlow starts the OAuth 2.0 device authorization flow and returns the device flow details.
|
||||
func (qa *QwenAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceFlow, error) {
|
||||
// Generate PKCE code verifier and challenge
|
||||
codeVerifier, codeChallenge, err := qa.generatePKCEPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PKCE pair: %w", err)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", QwenOAuthClientID)
|
||||
data.Set("scope", QwenOAuthScope)
|
||||
data.Set("code_challenge", codeChallenge)
|
||||
data.Set("code_challenge_method", "S256")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthDeviceCodeEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := qa.httpClient.Do(req)
|
||||
|
||||
// resp, err := qa.httpClient.PostForm(QwenOAuthDeviceCodeEndpoint, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device authorization request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device authorization failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
|
||||
}
|
||||
|
||||
var result DeviceFlow
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse device flow response: %w", err)
|
||||
}
|
||||
|
||||
// Check if the response indicates success
|
||||
if result.DeviceCode == "" {
|
||||
return nil, fmt.Errorf("device authorization failed: device_code not found in response")
|
||||
}
|
||||
|
||||
// Add the code_verifier to the result so it can be used later for polling
|
||||
result.CodeVerifier = codeVerifier
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// PollForToken polls the token endpoint with the device code to obtain an access token.
|
||||
func (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenData, error) {
|
||||
pollInterval := 5 * time.Second
|
||||
maxAttempts := 60 // 5 minutes max
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", QwenOAuthGrantType)
|
||||
data.Set("client_id", QwenOAuthClientID)
|
||||
data.Set("device_code", deviceCode)
|
||||
data.Set("code_verifier", codeVerifier)
|
||||
|
||||
resp, err := http.PostForm(QwenOAuthTokenEndpoint, data)
|
||||
if err != nil {
|
||||
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
|
||||
var errorData map[string]interface{}
|
||||
if err = json.Unmarshal(body, &errorData); err == nil {
|
||||
// According to OAuth RFC 8628, handle standard polling responses
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
errorType, _ := errorData["error"].(string)
|
||||
switch errorType {
|
||||
case "authorization_pending":
|
||||
// User has not yet approved the authorization request. Continue polling.
|
||||
fmt.Printf("Polling attempt %d/%d...\n\n", attempt+1, maxAttempts)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
case "slow_down":
|
||||
// Client is polling too frequently. Increase poll interval.
|
||||
pollInterval = time.Duration(float64(pollInterval) * 1.5)
|
||||
if pollInterval > 10*time.Second {
|
||||
pollInterval = 10 * time.Second
|
||||
}
|
||||
fmt.Printf("Server requested to slow down, increasing poll interval to %v\n\n", pollInterval)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("device code expired. Please restart the authentication process")
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("authorization denied by user. Please restart the authentication process")
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, return with proper error information
|
||||
errorType, _ := errorData["error"].(string)
|
||||
errorDesc, _ := errorData["error_description"].(string)
|
||||
return nil, fmt.Errorf("device token poll failed: %s - %s", errorType, errorDesc)
|
||||
}
|
||||
|
||||
// If JSON parsing fails, fall back to text response
|
||||
return nil, fmt.Errorf("device token poll failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
|
||||
}
|
||||
// log.Debugf("%s", string(body))
|
||||
// Success - parse token data
|
||||
var response QwenTokenResponse
|
||||
if err = json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to QwenTokenData format and save
|
||||
tokenData := &QwenTokenData{
|
||||
AccessToken: response.AccessToken,
|
||||
RefreshToken: response.RefreshToken,
|
||||
TokenType: response.TokenType,
|
||||
ResourceURL: response.ResourceURL,
|
||||
Expire: time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("authentication timeout. Please restart the authentication process")
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry attempts to refresh tokens with a specified number of retries upon failure.
|
||||
func (o *QwenAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*QwenTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||
if err == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a QwenTokenStorage object from a QwenTokenData object.
|
||||
func (o *QwenAuth) CreateTokenStorage(tokenData *QwenTokenData) *QwenTokenStorage {
|
||||
storage := &QwenTokenStorage{
|
||||
AccessToken: tokenData.AccessToken,
|
||||
RefreshToken: tokenData.RefreshToken,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
ResourceURL: tokenData.ResourceURL,
|
||||
Expire: tokenData.Expire,
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing token storage with new token data
|
||||
func (o *QwenAuth) UpdateTokenStorage(storage *QwenTokenStorage, tokenData *QwenTokenData) {
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
storage.RefreshToken = tokenData.RefreshToken
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
storage.ResourceURL = tokenData.ResourceURL
|
||||
storage.Expire = tokenData.Expire
|
||||
}
|
||||
63
internal/auth/qwen/qwen_token.go
Normal file
63
internal/auth/qwen/qwen_token.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Package qwen provides authentication and token management functionality
|
||||
// for Alibaba's Qwen AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Qwen API.
|
||||
package qwen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
)
|
||||
|
||||
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Qwen-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type QwenTokenStorage struct {
|
||||
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// LastRefresh is the timestamp of the last token refresh operation.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
// ResourceURL is the base URL for API requests.
|
||||
ResourceURL string `json:"resource_url"`
|
||||
// Email is the Qwen account email address associated with this token.
|
||||
Email string `json:"email"`
|
||||
// Type indicates the authentication provider type, always "qwen" for this storage.
|
||||
Type string `json:"type"`
|
||||
// Expire is the timestamp when the current access token expires.
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Qwen token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "qwen"
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
208
internal/auth/vertex/keyutil.go
Normal file
208
internal/auth/vertex/keyutil.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package vertex
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeServiceAccountJSON normalizes the given JSON-encoded service account payload.
|
||||
// It returns the normalized JSON (with sanitized private_key) or, if normalization fails,
|
||||
// the original bytes and the encountered error.
|
||||
func NormalizeServiceAccountJSON(raw []byte) ([]byte, error) {
|
||||
if len(raw) == 0 {
|
||||
return raw, nil
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
normalized, err := NormalizeServiceAccountMap(payload)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
out, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NormalizeServiceAccountMap returns a copy of the given service account map with
|
||||
// a sanitized private_key field that is guaranteed to contain a valid RSA PRIVATE KEY PEM block.
|
||||
func NormalizeServiceAccountMap(sa map[string]any) (map[string]any, error) {
|
||||
if sa == nil {
|
||||
return nil, fmt.Errorf("service account payload is empty")
|
||||
}
|
||||
pk, _ := sa["private_key"].(string)
|
||||
if strings.TrimSpace(pk) == "" {
|
||||
return nil, fmt.Errorf("service account missing private_key")
|
||||
}
|
||||
normalized, err := sanitizePrivateKey(pk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clone := make(map[string]any, len(sa))
|
||||
for k, v := range sa {
|
||||
clone[k] = v
|
||||
}
|
||||
clone["private_key"] = normalized
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func sanitizePrivateKey(raw string) (string, error) {
|
||||
pk := strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
pk = strings.ReplaceAll(pk, "\r", "\n")
|
||||
pk = stripANSIEscape(pk)
|
||||
pk = strings.ToValidUTF8(pk, "")
|
||||
pk = strings.TrimSpace(pk)
|
||||
|
||||
normalized := pk
|
||||
if block, _ := pem.Decode([]byte(pk)); block == nil {
|
||||
// Attempt to reconstruct from the textual payload.
|
||||
if reconstructed, err := rebuildPEM(pk); err == nil {
|
||||
normalized = reconstructed
|
||||
} else {
|
||||
return "", fmt.Errorf("private_key is not valid pem: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(normalized))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("private_key pem decode failed")
|
||||
}
|
||||
|
||||
rsaBlock, err := ensureRSAPrivateKey(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pem.EncodeToMemory(rsaBlock)), nil
|
||||
}
|
||||
|
||||
func ensureRSAPrivateKey(block *pem.Block) (*pem.Block, error) {
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("pem block is nil")
|
||||
}
|
||||
|
||||
if block.Type == "RSA PRIVATE KEY" {
|
||||
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("private_key invalid rsa: %w", err)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
if block.Type == "PRIVATE KEY" {
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("private_key invalid pkcs8: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("private_key is not an RSA key")
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(rsaKey)
|
||||
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}, nil
|
||||
}
|
||||
|
||||
// Attempt auto-detection: try PKCS#1 first, then PKCS#8.
|
||||
if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
der := x509.MarshalPKCS1PrivateKey(rsaKey)
|
||||
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||
der := x509.MarshalPKCS1PrivateKey(rsaKey)
|
||||
return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("private_key uses unsupported format")
|
||||
}
|
||||
|
||||
func rebuildPEM(raw string) (string, error) {
|
||||
kind := "PRIVATE KEY"
|
||||
if strings.Contains(raw, "RSA PRIVATE KEY") {
|
||||
kind = "RSA PRIVATE KEY"
|
||||
}
|
||||
header := "-----BEGIN " + kind + "-----"
|
||||
footer := "-----END " + kind + "-----"
|
||||
start := strings.Index(raw, header)
|
||||
end := strings.Index(raw, footer)
|
||||
if start < 0 || end <= start {
|
||||
return "", fmt.Errorf("missing pem markers")
|
||||
}
|
||||
body := raw[start+len(header) : end]
|
||||
payload := filterBase64(body)
|
||||
if payload == "" {
|
||||
return "", fmt.Errorf("private_key base64 payload empty")
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("private_key base64 decode failed: %w", err)
|
||||
}
|
||||
block := &pem.Block{Type: kind, Bytes: der}
|
||||
return string(pem.EncodeToMemory(block)), nil
|
||||
}
|
||||
|
||||
func filterBase64(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '+' || r == '/' || r == '=':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripANSIEscape(s string) string {
|
||||
in := []rune(s)
|
||||
var out []rune
|
||||
for i := 0; i < len(in); i++ {
|
||||
r := in[i]
|
||||
if r != 0x1b {
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
if i+1 >= len(in) {
|
||||
continue
|
||||
}
|
||||
next := in[i+1]
|
||||
switch next {
|
||||
case ']':
|
||||
i += 2
|
||||
for i < len(in) {
|
||||
if in[i] == 0x07 {
|
||||
break
|
||||
}
|
||||
if in[i] == 0x1b && i+1 < len(in) && in[i+1] == '\\' {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
case '[':
|
||||
i += 2
|
||||
for i < len(in) {
|
||||
if (in[i] >= 'A' && in[i] <= 'Z') || (in[i] >= 'a' && in[i] <= 'z') {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
default:
|
||||
// skip single ESC
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
66
internal/auth/vertex/vertex_credentials.go
Normal file
66
internal/auth/vertex/vertex_credentials.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Package vertex provides token storage for Google Vertex AI Gemini via service account credentials.
|
||||
// It serialises service account JSON into an auth file that is consumed by the runtime executor.
|
||||
package vertex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// VertexCredentialStorage stores the service account JSON for Vertex AI access.
|
||||
// The content is persisted verbatim under the "service_account" key, together with
|
||||
// helper fields for project, location and email to improve logging and discovery.
|
||||
type VertexCredentialStorage struct {
|
||||
// ServiceAccount holds the parsed service account JSON content.
|
||||
ServiceAccount map[string]any `json:"service_account"`
|
||||
|
||||
// ProjectID is derived from the service account JSON (project_id).
|
||||
ProjectID string `json:"project_id"`
|
||||
|
||||
// Email is the client_email from the service account JSON.
|
||||
Email string `json:"email"`
|
||||
|
||||
// Location optionally sets a default region (e.g., us-central1) for Vertex endpoints.
|
||||
Location string `json:"location,omitempty"`
|
||||
|
||||
// Type is the provider identifier stored alongside credentials. Always "vertex".
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile writes the credential payload to the given file path in JSON format.
|
||||
// It ensures the parent directory exists and logs the operation for transparency.
|
||||
func (s *VertexCredentialStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
if s == nil {
|
||||
return fmt.Errorf("vertex credential: storage is nil")
|
||||
}
|
||||
if s.ServiceAccount == nil {
|
||||
return fmt.Errorf("vertex credential: service account content is empty")
|
||||
}
|
||||
// Ensure we tag the file with the provider type.
|
||||
s.Type = "vertex"
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0o700); err != nil {
|
||||
return fmt.Errorf("vertex credential: create directory failed: %w", err)
|
||||
}
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vertex credential: create file failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("vertex credential: failed to close file: %v", errClose)
|
||||
}
|
||||
}()
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
if err = enc.Encode(s); err != nil {
|
||||
return fmt.Errorf("vertex credential: encode failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package browser provides cross-platform functionality for opening URLs in the default web browser.
|
||||
// It abstracts the underlying operating system commands and provides a simple interface.
|
||||
package browser
|
||||
|
||||
import (
|
||||
@@ -9,9 +11,17 @@ import (
|
||||
"github.com/skratchdot/open-golang/open"
|
||||
)
|
||||
|
||||
// OpenURL opens a URL in the default browser
|
||||
// OpenURL opens the specified URL in the default web browser.
|
||||
// It first attempts to use a platform-agnostic library and falls back to
|
||||
// platform-specific commands if that fails.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The URL to open.
|
||||
//
|
||||
// Returns:
|
||||
// - An error if the URL cannot be opened, otherwise nil.
|
||||
func OpenURL(url string) error {
|
||||
log.Debugf("Attempting to open URL in browser: %s", url)
|
||||
fmt.Printf("Attempting to open URL in browser: %s\n", url)
|
||||
|
||||
// Try using the open-golang library first
|
||||
err := open.Run(url)
|
||||
@@ -26,7 +36,14 @@ func OpenURL(url string) error {
|
||||
return openURLPlatformSpecific(url)
|
||||
}
|
||||
|
||||
// openURLPlatformSpecific opens URL using platform-specific commands
|
||||
// openURLPlatformSpecific is a helper function that opens a URL using OS-specific commands.
|
||||
// This serves as a fallback mechanism for OpenURL.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The URL to open.
|
||||
//
|
||||
// Returns:
|
||||
// - An error if the URL cannot be opened, otherwise nil.
|
||||
func openURLPlatformSpecific(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
@@ -61,7 +78,11 @@ func openURLPlatformSpecific(url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if browser opening functionality is available
|
||||
// IsAvailable checks if the system has a command available to open a web browser.
|
||||
// It verifies the presence of necessary commands for the current operating system.
|
||||
//
|
||||
// Returns:
|
||||
// - true if a browser can be opened, false otherwise.
|
||||
func IsAvailable() bool {
|
||||
// First check if open-golang can work
|
||||
testErr := open.Run("about:blank")
|
||||
@@ -90,7 +111,11 @@ func IsAvailable() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// GetPlatformInfo returns information about the current platform's browser support
|
||||
// GetPlatformInfo returns a map containing details about the current platform's
|
||||
// browser opening capabilities, including the OS, architecture, and available commands.
|
||||
//
|
||||
// Returns:
|
||||
// - A map with platform-specific browser support information.
|
||||
func GetPlatformInfo() map[string]interface{} {
|
||||
info := map[string]interface{}{
|
||||
"os": runtime.GOOS,
|
||||
@@ -105,7 +130,7 @@ func GetPlatformInfo() map[string]interface{} {
|
||||
info["default_command"] = "rundll32"
|
||||
case "linux":
|
||||
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
|
||||
availableBrowsers := []string{}
|
||||
var availableBrowsers []string
|
||||
for _, browser := range browsers {
|
||||
if _, err := exec.LookPath(browser); err == nil {
|
||||
availableBrowsers = append(availableBrowsers, browser)
|
||||
|
||||
15
internal/buildinfo/buildinfo.go
Normal file
15
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Package buildinfo exposes compile-time metadata shared across the server.
|
||||
package buildinfo
|
||||
|
||||
// The following variables are overridden via ldflags during release builds.
|
||||
// Defaults cover local development builds.
|
||||
var (
|
||||
// Version is the semantic version or git describe output of the binary.
|
||||
Version = "dev"
|
||||
|
||||
// Commit is the git commit SHA baked into the binary.
|
||||
Commit = "none"
|
||||
|
||||
// BuildDate records when the binary was built in UTC.
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
@@ -1,87 +0,0 @@
|
||||
// Package client defines the interface and base structure for AI API clients.
|
||||
// It provides a common interface that all supported AI service clients must implement,
|
||||
// including methods for sending messages, handling streams, and managing authentication.
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
)
|
||||
|
||||
// Client defines the interface that all AI API clients must implement.
|
||||
// This interface provides methods for interacting with various AI services
|
||||
// including sending messages, streaming responses, and managing authentication.
|
||||
type Client interface {
|
||||
// GetRequestMutex returns the mutex used to synchronize requests for this client.
|
||||
// This ensures that only one request is processed at a time for quota management.
|
||||
GetRequestMutex() *sync.Mutex
|
||||
|
||||
// GetUserAgent returns the User-Agent string used for HTTP requests.
|
||||
GetUserAgent() string
|
||||
|
||||
// SendMessage sends a single message to the AI service and returns the response.
|
||||
// It takes the raw JSON request, model name, system instructions, conversation contents,
|
||||
// and tool declarations, then returns the response bytes and any error that occurred.
|
||||
SendMessage(ctx context.Context, rawJSON []byte, model string, systemInstruction *Content, contents []Content, tools []ToolDeclaration) ([]byte, *ErrorMessage)
|
||||
|
||||
// SendMessageStream sends a message to the AI service and returns streaming responses.
|
||||
// It takes similar parameters to SendMessage but returns channels for streaming data
|
||||
// and errors, enabling real-time response processing.
|
||||
SendMessageStream(ctx context.Context, rawJSON []byte, model string, systemInstruction *Content, contents []Content, tools []ToolDeclaration, includeThoughts ...bool) (<-chan []byte, <-chan *ErrorMessage)
|
||||
|
||||
// SendRawMessage sends a raw JSON message to the AI service without translation.
|
||||
// This method is used when the request is already in the service's native format.
|
||||
SendRawMessage(ctx context.Context, rawJSON []byte, alt string) ([]byte, *ErrorMessage)
|
||||
|
||||
// SendRawMessageStream sends a raw JSON message and returns streaming responses.
|
||||
// Similar to SendRawMessage but for streaming responses.
|
||||
SendRawMessageStream(ctx context.Context, rawJSON []byte, alt string) (<-chan []byte, <-chan *ErrorMessage)
|
||||
|
||||
// SendRawTokenCount sends a token count request to the AI service.
|
||||
// This method is used to estimate the number of tokens in a given text.
|
||||
SendRawTokenCount(ctx context.Context, rawJSON []byte, alt string) ([]byte, *ErrorMessage)
|
||||
|
||||
// SaveTokenToFile saves the client's authentication token to a file.
|
||||
// This is used for persisting authentication state between sessions.
|
||||
SaveTokenToFile() error
|
||||
|
||||
// IsModelQuotaExceeded checks if the specified model has exceeded its quota.
|
||||
// This helps with load balancing and automatic failover to alternative models.
|
||||
IsModelQuotaExceeded(model string) bool
|
||||
|
||||
// GetEmail returns the email associated with the client's authentication.
|
||||
// This is used for logging and identification purposes.
|
||||
GetEmail() string
|
||||
}
|
||||
|
||||
// ClientBase provides a common base structure for all AI API clients.
|
||||
// It implements shared functionality such as request synchronization, HTTP client management,
|
||||
// configuration access, token storage, and quota tracking.
|
||||
type ClientBase struct {
|
||||
// RequestMutex ensures only one request is processed at a time for quota management.
|
||||
RequestMutex *sync.Mutex
|
||||
|
||||
// httpClient is the HTTP client used for making API requests.
|
||||
httpClient *http.Client
|
||||
|
||||
// cfg holds the application configuration.
|
||||
cfg *config.Config
|
||||
|
||||
// tokenStorage manages authentication tokens for the client.
|
||||
tokenStorage auth.TokenStorage
|
||||
|
||||
// modelQuotaExceeded tracks when models have exceeded their quota.
|
||||
// The map key is the model name, and the value is the time when the quota was exceeded.
|
||||
modelQuotaExceeded map[string]*time.Time
|
||||
}
|
||||
|
||||
// GetRequestMutex returns the mutex used to synchronize requests for this client.
|
||||
// This ensures that only one request is processed at a time for quota management.
|
||||
func (c *ClientBase) GetRequestMutex() *sync.Mutex {
|
||||
return c.RequestMutex
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth"
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
chatGPTEndpoint = "https://chatgpt.com/backend-api"
|
||||
)
|
||||
|
||||
// CodexClient implements the Client interface for OpenAI API
|
||||
type CodexClient struct {
|
||||
ClientBase
|
||||
codexAuth *codex.CodexAuth
|
||||
}
|
||||
|
||||
// NewCodexClient creates a new OpenAI client instance
|
||||
func NewCodexClient(cfg *config.Config, ts *codex.CodexTokenStorage) (*CodexClient, error) {
|
||||
httpClient := util.SetProxy(cfg, &http.Client{})
|
||||
client := &CodexClient{
|
||||
ClientBase: ClientBase{
|
||||
RequestMutex: &sync.Mutex{},
|
||||
httpClient: httpClient,
|
||||
cfg: cfg,
|
||||
modelQuotaExceeded: make(map[string]*time.Time),
|
||||
tokenStorage: ts,
|
||||
},
|
||||
codexAuth: codex.NewCodexAuth(cfg),
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetUserAgent returns the user agent string for OpenAI API requests
|
||||
func (c *CodexClient) GetUserAgent() string {
|
||||
return "codex-cli"
|
||||
}
|
||||
|
||||
func (c *CodexClient) TokenStorage() auth.TokenStorage {
|
||||
return c.tokenStorage
|
||||
}
|
||||
|
||||
// SendMessage sends a message to OpenAI API (non-streaming)
|
||||
func (c *CodexClient) SendMessage(_ context.Context, _ []byte, _ string, _ *Content, _ []Content, _ []ToolDeclaration) ([]byte, *ErrorMessage) {
|
||||
// For now, return an error as OpenAI integration is not fully implemented
|
||||
return nil, &ErrorMessage{
|
||||
StatusCode: http.StatusNotImplemented,
|
||||
Error: fmt.Errorf("codex message sending not yet implemented"),
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessageStream sends a streaming message to OpenAI API
|
||||
func (c *CodexClient) SendMessageStream(_ context.Context, _ []byte, _ string, _ *Content, _ []Content, _ []ToolDeclaration, _ ...bool) (<-chan []byte, <-chan *ErrorMessage) {
|
||||
errChan := make(chan *ErrorMessage, 1)
|
||||
errChan <- &ErrorMessage{
|
||||
StatusCode: http.StatusNotImplemented,
|
||||
Error: fmt.Errorf("codex streaming not yet implemented"),
|
||||
}
|
||||
close(errChan)
|
||||
|
||||
return nil, errChan
|
||||
}
|
||||
|
||||
// SendRawMessage sends a raw message to OpenAI API
|
||||
func (c *CodexClient) SendRawMessage(ctx context.Context, rawJSON []byte, alt string) ([]byte, *ErrorMessage) {
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
model := modelResult.String()
|
||||
modelName := model
|
||||
|
||||
respBody, err := c.APIRequest(ctx, "/codex/responses", rawJSON, alt, false)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
bodyBytes, errReadAll := io.ReadAll(respBody)
|
||||
if errReadAll != nil {
|
||||
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
|
||||
}
|
||||
return bodyBytes, nil
|
||||
|
||||
}
|
||||
|
||||
// SendRawMessageStream sends a raw streaming message to OpenAI API
|
||||
func (c *CodexClient) SendRawMessageStream(ctx context.Context, rawJSON []byte, alt string) (<-chan []byte, <-chan *ErrorMessage) {
|
||||
errChan := make(chan *ErrorMessage)
|
||||
dataChan := make(chan []byte)
|
||||
go func() {
|
||||
defer close(errChan)
|
||||
defer close(dataChan)
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
model := modelResult.String()
|
||||
modelName := model
|
||||
var stream io.ReadCloser
|
||||
for {
|
||||
var err *ErrorMessage
|
||||
stream, err = c.APIRequest(ctx, "/codex/responses", rawJSON, alt, true)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now
|
||||
}
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
break
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stream)
|
||||
buffer := make([]byte, 10240*1024)
|
||||
scanner.Buffer(buffer, 10240*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
dataChan <- line
|
||||
}
|
||||
|
||||
if errScanner := scanner.Err(); errScanner != nil {
|
||||
errChan <- &ErrorMessage{500, errScanner}
|
||||
_ = stream.Close()
|
||||
return
|
||||
}
|
||||
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
return dataChan, errChan
|
||||
}
|
||||
|
||||
// SendRawTokenCount sends a token count request to OpenAI API
|
||||
func (c *CodexClient) SendRawTokenCount(_ context.Context, _ []byte, _ string) ([]byte, *ErrorMessage) {
|
||||
return nil, &ErrorMessage{
|
||||
StatusCode: http.StatusNotImplemented,
|
||||
Error: fmt.Errorf("codex token counting not yet implemented"),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveTokenToFile persists the token storage to disk
|
||||
func (c *CodexClient) SaveTokenToFile() error {
|
||||
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("codex-%s.json", c.tokenStorage.(*codex.CodexTokenStorage).Email))
|
||||
return c.tokenStorage.SaveTokenToFile(fileName)
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes the access tokens if needed
|
||||
func (c *CodexClient) RefreshTokens(ctx context.Context) error {
|
||||
if c.tokenStorage == nil || c.tokenStorage.(*codex.CodexTokenStorage).RefreshToken == "" {
|
||||
return fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
// Refresh tokens using the auth service
|
||||
newTokenData, err := c.codexAuth.RefreshTokensWithRetry(ctx, c.tokenStorage.(*codex.CodexTokenStorage).RefreshToken, 3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh tokens: %w", err)
|
||||
}
|
||||
|
||||
// Update token storage
|
||||
c.codexAuth.UpdateTokenStorage(c.tokenStorage.(*codex.CodexTokenStorage), newTokenData)
|
||||
|
||||
// Save updated tokens
|
||||
if err = c.SaveTokenToFile(); err != nil {
|
||||
log.Warnf("Failed to save refreshed tokens: %v", err)
|
||||
}
|
||||
|
||||
log.Debug("codex tokens refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIRequest handles making requests to the CLI API endpoints.
|
||||
func (c *CodexClient) APIRequest(ctx context.Context, endpoint string, body interface{}, _ string, _ bool) (io.ReadCloser, *ErrorMessage) {
|
||||
var jsonBody []byte
|
||||
var err error
|
||||
if byteBody, ok := body.([]byte); ok {
|
||||
jsonBody = byteBody
|
||||
} else {
|
||||
jsonBody, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to marshal request body: %w", err)}
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s", chatGPTEndpoint, endpoint)
|
||||
|
||||
// log.Debug(string(jsonBody))
|
||||
// log.Debug(url)
|
||||
reqBody := bytes.NewBuffer(jsonBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
|
||||
if err != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to create request: %v", err)}
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
// Set headers
|
||||
req.Header.Set("Version", "0.21.0")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Openai-Beta", "responses=experimental")
|
||||
req.Header.Set("Session_id", sessionID)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Chatgpt-Account-Id", c.tokenStorage.(*codex.CodexTokenStorage).AccountID)
|
||||
req.Header.Set("Originator", "codex_cli_rs")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.tokenStorage.(*codex.CodexTokenStorage).AccessToken))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to execute request: %v", err)}
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
log.Printf("warn: failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
// log.Debug(string(jsonBody))
|
||||
return nil, &ErrorMessage{resp.StatusCode, fmt.Errorf(string(bodyBytes))}
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *CodexClient) GetEmail() string {
|
||||
return c.tokenStorage.(*codex.CodexTokenStorage).Email
|
||||
}
|
||||
|
||||
// IsModelQuotaExceeded returns true if the specified model has exceeded its quota
|
||||
// and no fallback options are available.
|
||||
func (c *CodexClient) IsModelQuotaExceeded(model string) bool {
|
||||
if lastExceededTime, hasKey := c.modelQuotaExceeded[model]; hasKey {
|
||||
duration := time.Now().Sub(*lastExceededTime)
|
||||
if duration > 30*time.Minute {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,942 +0,0 @@
|
||||
// Package client provides HTTP client functionality for interacting with Google Cloud AI APIs.
|
||||
// It handles OAuth2 authentication, token management, request/response processing,
|
||||
// streaming communication, quota management, and automatic model fallback.
|
||||
// The package supports both direct API key authentication and OAuth2 flows.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
geminiAuth "github.com/luispater/CLIProxyAPI/internal/auth/gemini"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
codeAssistEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||
apiVersion = "v1internal"
|
||||
|
||||
glEndPoint = "https://generativelanguage.googleapis.com"
|
||||
glAPIVersion = "v1beta"
|
||||
)
|
||||
|
||||
var (
|
||||
previewModels = map[string][]string{
|
||||
"gemini-2.5-pro": {"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"},
|
||||
"gemini-2.5-flash": {"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"},
|
||||
}
|
||||
)
|
||||
|
||||
// GeminiClient is the main client for interacting with the CLI API.
|
||||
type GeminiClient struct {
|
||||
ClientBase
|
||||
glAPIKey string
|
||||
}
|
||||
|
||||
// NewGeminiClient creates a new CLI API client.
|
||||
func NewGeminiClient(httpClient *http.Client, ts *geminiAuth.GeminiTokenStorage, cfg *config.Config, glAPIKey ...string) *GeminiClient {
|
||||
var glKey string
|
||||
if len(glAPIKey) > 0 {
|
||||
glKey = glAPIKey[0]
|
||||
}
|
||||
return &GeminiClient{
|
||||
ClientBase: ClientBase{
|
||||
RequestMutex: &sync.Mutex{},
|
||||
httpClient: httpClient,
|
||||
cfg: cfg,
|
||||
tokenStorage: ts,
|
||||
modelQuotaExceeded: make(map[string]*time.Time),
|
||||
},
|
||||
glAPIKey: glKey,
|
||||
}
|
||||
}
|
||||
|
||||
// SetProjectID updates the project ID for the client's token storage.
|
||||
func (c *GeminiClient) SetProjectID(projectID string) {
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID = projectID
|
||||
}
|
||||
|
||||
// SetIsAuto configures whether the client should operate in automatic mode.
|
||||
func (c *GeminiClient) SetIsAuto(auto bool) {
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Auto = auto
|
||||
}
|
||||
|
||||
// SetIsChecked sets the checked status for the client's token storage.
|
||||
func (c *GeminiClient) SetIsChecked(checked bool) {
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Checked = checked
|
||||
}
|
||||
|
||||
// IsChecked returns whether the client's token storage has been checked.
|
||||
func (c *GeminiClient) IsChecked() bool {
|
||||
return c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Checked
|
||||
}
|
||||
|
||||
// IsAuto returns whether the client is operating in automatic mode.
|
||||
func (c *GeminiClient) IsAuto() bool {
|
||||
return c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Auto
|
||||
}
|
||||
|
||||
// GetEmail returns the email address associated with the client's token storage.
|
||||
func (c *GeminiClient) GetEmail() string {
|
||||
return c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Email
|
||||
}
|
||||
|
||||
// GetProjectID returns the Google Cloud project ID from the client's token storage.
|
||||
func (c *GeminiClient) GetProjectID() string {
|
||||
if c.glAPIKey == "" && c.tokenStorage != nil {
|
||||
if ts, ok := c.tokenStorage.(*geminiAuth.GeminiTokenStorage); ok {
|
||||
return ts.ProjectID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetGenerativeLanguageAPIKey returns the generative language API key if configured.
|
||||
func (c *GeminiClient) GetGenerativeLanguageAPIKey() string {
|
||||
return c.glAPIKey
|
||||
}
|
||||
|
||||
// SetupUser performs the initial user onboarding and setup.
|
||||
func (c *GeminiClient) SetupUser(ctx context.Context, email, projectID string) error {
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Email = email
|
||||
log.Info("Performing user onboarding...")
|
||||
|
||||
// 1. LoadCodeAssist
|
||||
loadAssistReqBody := map[string]interface{}{
|
||||
"metadata": c.getClientMetadata(),
|
||||
}
|
||||
if projectID != "" {
|
||||
loadAssistReqBody["cloudaicompanionProject"] = projectID
|
||||
}
|
||||
|
||||
var loadAssistResp map[string]interface{}
|
||||
err := c.makeAPIRequest(ctx, "loadCodeAssist", "POST", loadAssistReqBody, &loadAssistResp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load code assist: %w", err)
|
||||
}
|
||||
|
||||
// a, _ := json.Marshal(&loadAssistResp)
|
||||
// log.Debug(string(a))
|
||||
//
|
||||
// a, _ = json.Marshal(loadAssistReqBody)
|
||||
// log.Debug(string(a))
|
||||
|
||||
// 2. OnboardUser
|
||||
var onboardTierID = "legacy-tier"
|
||||
if tiers, ok := loadAssistResp["allowedTiers"].([]interface{}); ok {
|
||||
for _, t := range tiers {
|
||||
if tier, tierOk := t.(map[string]interface{}); tierOk {
|
||||
if isDefault, isDefaultOk := tier["isDefault"].(bool); isDefaultOk && isDefault {
|
||||
if id, idOk := tier["id"].(string); idOk {
|
||||
onboardTierID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onboardProjectID := projectID
|
||||
if p, ok := loadAssistResp["cloudaicompanionProject"].(string); ok && p != "" {
|
||||
onboardProjectID = p
|
||||
}
|
||||
|
||||
onboardReqBody := map[string]interface{}{
|
||||
"tierId": onboardTierID,
|
||||
"metadata": c.getClientMetadata(),
|
||||
}
|
||||
if onboardProjectID != "" {
|
||||
onboardReqBody["cloudaicompanionProject"] = onboardProjectID
|
||||
} else {
|
||||
return fmt.Errorf("failed to start user onboarding, need define a project id")
|
||||
}
|
||||
|
||||
for {
|
||||
var lroResp map[string]interface{}
|
||||
err = c.makeAPIRequest(ctx, "onboardUser", "POST", onboardReqBody, &lroResp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start user onboarding: %w", err)
|
||||
}
|
||||
// a, _ := json.Marshal(&lroResp)
|
||||
// log.Debug(string(a))
|
||||
|
||||
// 3. Poll Long-Running Operation (LRO)
|
||||
done, doneOk := lroResp["done"].(bool)
|
||||
if doneOk && done {
|
||||
if project, projectOk := lroResp["response"].(map[string]interface{})["cloudaicompanionProject"].(map[string]interface{}); projectOk {
|
||||
if projectID != "" {
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID = projectID
|
||||
} else {
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID = project["id"].(string)
|
||||
}
|
||||
log.Infof("Onboarding complete. Using Project ID: %s", c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
log.Println("Onboarding in progress, waiting 5 seconds...")
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeAPIRequest handles making requests to the CLI API endpoints.
|
||||
func (c *GeminiClient) makeAPIRequest(ctx context.Context, endpoint, method string, body interface{}, result interface{}) error {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonBody)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, apiVersion, endpoint)
|
||||
if strings.HasPrefix(endpoint, "operations/") {
|
||||
url = fmt.Sprintf("%s/%s", codeAssistEndpoint, endpoint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
metadataStr := c.getClientMetadataString()
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", c.GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", "gl-node/22.17.0")
|
||||
req.Header.Set("Client-Metadata", metadataStr)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
log.Printf("warn: failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if err = json.NewDecoder(resp.Body).Decode(result); err != nil {
|
||||
return fmt.Errorf("failed to decode response body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIRequest handles making requests to the CLI API endpoints.
|
||||
func (c *GeminiClient) APIRequest(ctx context.Context, endpoint string, body interface{}, alt string, stream bool) (io.ReadCloser, *ErrorMessage) {
|
||||
var jsonBody []byte
|
||||
var err error
|
||||
if byteBody, ok := body.([]byte); ok {
|
||||
jsonBody = byteBody
|
||||
} else {
|
||||
jsonBody, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to marshal request body: %w", err)}
|
||||
}
|
||||
}
|
||||
|
||||
var url string
|
||||
if c.glAPIKey == "" {
|
||||
// Add alt=sse for streaming
|
||||
url = fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, apiVersion, endpoint)
|
||||
if alt == "" && stream {
|
||||
url = url + "?alt=sse"
|
||||
} else {
|
||||
if alt != "" {
|
||||
url = url + fmt.Sprintf("?$alt=%s", alt)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if endpoint == "countTokens" {
|
||||
modelResult := gjson.GetBytes(jsonBody, "model")
|
||||
url = fmt.Sprintf("%s/%s/models/%s:%s", glEndPoint, glAPIVersion, modelResult.String(), endpoint)
|
||||
} else {
|
||||
modelResult := gjson.GetBytes(jsonBody, "model")
|
||||
url = fmt.Sprintf("%s/%s/models/%s:%s", glEndPoint, glAPIVersion, modelResult.String(), endpoint)
|
||||
if alt == "" && stream {
|
||||
url = url + "?alt=sse"
|
||||
} else {
|
||||
if alt != "" {
|
||||
url = url + fmt.Sprintf("?$alt=%s", alt)
|
||||
}
|
||||
}
|
||||
jsonBody = []byte(gjson.GetBytes(jsonBody, "request").Raw)
|
||||
systemInstructionResult := gjson.GetBytes(jsonBody, "systemInstruction")
|
||||
if systemInstructionResult.Exists() {
|
||||
jsonBody, _ = sjson.SetRawBytes(jsonBody, "system_instruction", []byte(systemInstructionResult.Raw))
|
||||
jsonBody, _ = sjson.DeleteBytes(jsonBody, "systemInstruction")
|
||||
jsonBody, _ = sjson.DeleteBytes(jsonBody, "session_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// log.Debug(string(jsonBody))
|
||||
// log.Debug(url)
|
||||
reqBody := bytes.NewBuffer(jsonBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
|
||||
if err != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to create request: %v", err)}
|
||||
}
|
||||
|
||||
// Set headers
|
||||
metadataStr := c.getClientMetadataString()
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.glAPIKey == "" {
|
||||
token, errToken := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
|
||||
if errToken != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to get token: %v", errToken)}
|
||||
}
|
||||
req.Header.Set("User-Agent", c.GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", "gl-node/22.17.0")
|
||||
req.Header.Set("Client-Metadata", metadataStr)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||
} else {
|
||||
req.Header.Set("x-goog-api-key", c.glAPIKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ErrorMessage{500, fmt.Errorf("failed to execute request: %v", err)}
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
log.Printf("warn: failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
// log.Debug(string(jsonBody))
|
||||
return nil, &ErrorMessage{resp.StatusCode, fmt.Errorf(string(bodyBytes))}
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// SendMessage handles a single conversational turn, including tool calls.
|
||||
func (c *GeminiClient) SendMessage(ctx context.Context, rawJSON []byte, model string, systemInstruction *Content, contents []Content, tools []ToolDeclaration) ([]byte, *ErrorMessage) {
|
||||
request := GenerateContentRequest{
|
||||
Contents: contents,
|
||||
GenerationConfig: GenerationConfig{
|
||||
ThinkingConfig: GenerationConfigThinkingConfig{
|
||||
IncludeThoughts: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
request.SystemInstruction = systemInstruction
|
||||
|
||||
request.Tools = tools
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
"project": c.GetProjectID(), // Assuming ProjectID is available
|
||||
"request": request,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
byteRequestBody, _ := json.Marshal(requestBody)
|
||||
|
||||
// log.Debug(string(byteRequestBody))
|
||||
|
||||
reasoningEffortResult := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||
if reasoningEffortResult.String() == "none" {
|
||||
byteRequestBody, _ = sjson.DeleteBytes(byteRequestBody, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
|
||||
} else if reasoningEffortResult.String() == "auto" {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
||||
} else if reasoningEffortResult.String() == "low" {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
|
||||
} else if reasoningEffortResult.String() == "medium" {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
|
||||
} else if reasoningEffortResult.String() == "high" {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 24576)
|
||||
} else {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
||||
}
|
||||
|
||||
temperatureResult := gjson.GetBytes(rawJSON, "temperature")
|
||||
if temperatureResult.Exists() && temperatureResult.Type == gjson.Number {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.temperature", temperatureResult.Num)
|
||||
}
|
||||
|
||||
topPResult := gjson.GetBytes(rawJSON, "top_p")
|
||||
if topPResult.Exists() && topPResult.Type == gjson.Number {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topP", topPResult.Num)
|
||||
}
|
||||
|
||||
topKResult := gjson.GetBytes(rawJSON, "top_k")
|
||||
if topKResult.Exists() && topKResult.Type == gjson.Number {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topK", topKResult.Num)
|
||||
}
|
||||
|
||||
modelName := model
|
||||
// log.Debug(string(byteRequestBody))
|
||||
for {
|
||||
if c.isModelQuotaExceeded(modelName) {
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
modelName = c.getPreviewModel(model)
|
||||
if modelName != "" {
|
||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "model", modelName)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, &ErrorMessage{
|
||||
StatusCode: 429,
|
||||
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := c.APIRequest(ctx, "generateContent", byteRequestBody, "", false)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
bodyBytes, errReadAll := io.ReadAll(respBody)
|
||||
if errReadAll != nil {
|
||||
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
|
||||
}
|
||||
return bodyBytes, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessageStream handles streaming conversational turns with comprehensive parameter management.
|
||||
// This function implements a sophisticated streaming system that supports tool calls, reasoning modes,
|
||||
// quota management, and automatic model fallback. It returns two channels for asynchronous communication:
|
||||
// one for streaming response data and another for error handling.
|
||||
func (c *GeminiClient) SendMessageStream(ctx context.Context, rawJSON []byte, model string, systemInstruction *Content, contents []Content, tools []ToolDeclaration, includeThoughts ...bool) (<-chan []byte, <-chan *ErrorMessage) {
|
||||
// Define the data prefix used in Server-Sent Events streaming format
|
||||
dataTag := []byte("data: ")
|
||||
|
||||
// Create channels for asynchronous communication
|
||||
// errChan: delivers error messages during streaming
|
||||
// dataChan: delivers response data chunks
|
||||
errChan := make(chan *ErrorMessage)
|
||||
dataChan := make(chan []byte)
|
||||
|
||||
// Launch a goroutine to handle the streaming process asynchronously
|
||||
// This allows the function to return immediately while processing continues in the background
|
||||
go func() {
|
||||
// Ensure channels are properly closed when the goroutine exits
|
||||
defer close(errChan)
|
||||
defer close(dataChan)
|
||||
|
||||
// Configure thinking/reasoning capabilities
|
||||
// Default to including thoughts unless explicitly disabled
|
||||
includeThoughtsFlag := true
|
||||
if len(includeThoughts) > 0 {
|
||||
includeThoughtsFlag = includeThoughts[0]
|
||||
}
|
||||
|
||||
// Build the base request structure for the Gemini API
|
||||
// This includes conversation contents and generation configuration
|
||||
request := GenerateContentRequest{
|
||||
Contents: contents,
|
||||
GenerationConfig: GenerationConfig{
|
||||
ThinkingConfig: GenerationConfigThinkingConfig{
|
||||
IncludeThoughts: includeThoughtsFlag,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add system instructions if provided
|
||||
// System instructions guide the AI's behavior and response style
|
||||
request.SystemInstruction = systemInstruction
|
||||
|
||||
// Add available tools for function calling capabilities
|
||||
// Tools allow the AI to perform actions beyond text generation
|
||||
request.Tools = tools
|
||||
|
||||
// Construct the complete request body with project context
|
||||
// The project ID is essential for proper API routing and billing
|
||||
requestBody := map[string]interface{}{
|
||||
"project": c.GetProjectID(), // Project ID for API routing and quota management
|
||||
"request": request,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
// Serialize the request body to JSON for API transmission
|
||||
byteRequestBody, _ := json.Marshal(requestBody)
|
||||
|
||||
// Parse and configure reasoning effort levels from the original request
|
||||
// This maps Claude-style reasoning effort parameters to Gemini's thinking budget system
|
||||
reasoningEffortResult := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||
if reasoningEffortResult.String() == "none" {
|
||||
// Disable thinking entirely for fastest responses
|
||||
byteRequestBody, _ = sjson.DeleteBytes(byteRequestBody, "request.generationConfig.thinkingConfig.include_thoughts")
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
|
||||
} else if reasoningEffortResult.String() == "auto" {
|
||||
// Let the model decide the appropriate thinking budget automatically
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
||||
} else if reasoningEffortResult.String() == "low" {
|
||||
// Minimal thinking for simple tasks (1KB thinking budget)
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
|
||||
} else if reasoningEffortResult.String() == "medium" {
|
||||
// Moderate thinking for complex tasks (8KB thinking budget)
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
|
||||
} else if reasoningEffortResult.String() == "high" {
|
||||
// Maximum thinking for very complex tasks (24KB thinking budget)
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 24576)
|
||||
} else {
|
||||
// Default to automatic thinking budget if no specific level is provided
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
|
||||
}
|
||||
|
||||
// Configure temperature parameter for response randomness control
|
||||
// Temperature affects the creativity vs consistency trade-off in responses
|
||||
temperatureResult := gjson.GetBytes(rawJSON, "temperature")
|
||||
if temperatureResult.Exists() && temperatureResult.Type == gjson.Number {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.temperature", temperatureResult.Num)
|
||||
}
|
||||
|
||||
// Configure top-p parameter for nucleus sampling
|
||||
// Controls the cumulative probability threshold for token selection
|
||||
topPResult := gjson.GetBytes(rawJSON, "top_p")
|
||||
if topPResult.Exists() && topPResult.Type == gjson.Number {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topP", topPResult.Num)
|
||||
}
|
||||
|
||||
// Configure top-k parameter for limiting token candidates
|
||||
// Restricts the model to consider only the top K most likely tokens
|
||||
topKResult := gjson.GetBytes(rawJSON, "top_k")
|
||||
if topKResult.Exists() && topKResult.Type == gjson.Number {
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topK", topKResult.Num)
|
||||
}
|
||||
|
||||
// Initialize model name for quota management and potential fallback
|
||||
modelName := model
|
||||
var stream io.ReadCloser
|
||||
|
||||
// Quota management and model fallback loop
|
||||
// This loop handles quota exceeded scenarios and automatic model switching
|
||||
for {
|
||||
// Check if the current model has exceeded its quota
|
||||
if c.isModelQuotaExceeded(modelName) {
|
||||
// Attempt to switch to a preview model if configured and using account auth
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
modelName = c.getPreviewModel(model)
|
||||
if modelName != "" {
|
||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
|
||||
// Update the request body with the new model name
|
||||
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "model", modelName)
|
||||
continue // Retry with the preview model
|
||||
}
|
||||
}
|
||||
// If no fallback is available, return a quota exceeded error
|
||||
errChan <- &ErrorMessage{
|
||||
StatusCode: 429,
|
||||
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to establish a streaming connection with the API
|
||||
var err *ErrorMessage
|
||||
stream, err = c.APIRequest(ctx, "streamGenerateContent", byteRequestBody, "", true)
|
||||
if err != nil {
|
||||
// Handle quota exceeded errors by marking the model and potentially retrying
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now // Mark model as quota exceeded
|
||||
// If preview model switching is enabled, retry the loop
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Forward other errors to the error channel
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
// Clear any previous quota exceeded status for this model
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
break // Successfully established connection, exit the retry loop
|
||||
}
|
||||
|
||||
// Process the streaming response using a scanner
|
||||
// This handles the Server-Sent Events format from the API
|
||||
scanner := bufio.NewScanner(stream)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
// Filter and forward only data lines (those prefixed with "data: ")
|
||||
// This extracts the actual JSON content from the SSE format
|
||||
if bytes.HasPrefix(line, dataTag) {
|
||||
dataChan <- line[6:] // Remove "data: " prefix and send the JSON content
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any scanning errors that occurred during stream processing
|
||||
if errScanner := scanner.Err(); errScanner != nil {
|
||||
// Send a 500 Internal Server Error for scanning failures
|
||||
errChan <- &ErrorMessage{500, errScanner}
|
||||
_ = stream.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the stream is properly closed to prevent resource leaks
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
// Return the channels immediately for asynchronous communication
|
||||
// The caller can read from these channels while the goroutine processes the request
|
||||
return dataChan, errChan
|
||||
}
|
||||
|
||||
// SendRawTokenCount handles a token count.
|
||||
func (c *GeminiClient) SendRawTokenCount(ctx context.Context, rawJSON []byte, alt string) ([]byte, *ErrorMessage) {
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
model := modelResult.String()
|
||||
modelName := model
|
||||
for {
|
||||
if c.isModelQuotaExceeded(modelName) {
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
modelName = c.getPreviewModel(model)
|
||||
if modelName != "" {
|
||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelName)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, &ErrorMessage{
|
||||
StatusCode: 429,
|
||||
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := c.APIRequest(ctx, "countTokens", rawJSON, alt, false)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
bodyBytes, errReadAll := io.ReadAll(respBody)
|
||||
if errReadAll != nil {
|
||||
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
|
||||
}
|
||||
return bodyBytes, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendRawMessage handles a single conversational turn, including tool calls.
|
||||
func (c *GeminiClient) SendRawMessage(ctx context.Context, rawJSON []byte, alt string) ([]byte, *ErrorMessage) {
|
||||
if c.glAPIKey == "" {
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "project", c.GetProjectID())
|
||||
}
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
model := modelResult.String()
|
||||
modelName := model
|
||||
for {
|
||||
if c.isModelQuotaExceeded(modelName) {
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
modelName = c.getPreviewModel(model)
|
||||
if modelName != "" {
|
||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelName)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, &ErrorMessage{
|
||||
StatusCode: 429,
|
||||
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := c.APIRequest(ctx, "generateContent", rawJSON, alt, false)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
bodyBytes, errReadAll := io.ReadAll(respBody)
|
||||
if errReadAll != nil {
|
||||
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
|
||||
}
|
||||
return bodyBytes, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SendRawMessageStream handles a single conversational turn, including tool calls.
|
||||
func (c *GeminiClient) SendRawMessageStream(ctx context.Context, rawJSON []byte, alt string) (<-chan []byte, <-chan *ErrorMessage) {
|
||||
dataTag := []byte("data: ")
|
||||
errChan := make(chan *ErrorMessage)
|
||||
dataChan := make(chan []byte)
|
||||
go func() {
|
||||
defer close(errChan)
|
||||
defer close(dataChan)
|
||||
|
||||
if c.glAPIKey == "" {
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "project", c.GetProjectID())
|
||||
}
|
||||
|
||||
modelResult := gjson.GetBytes(rawJSON, "model")
|
||||
model := modelResult.String()
|
||||
modelName := model
|
||||
var stream io.ReadCloser
|
||||
for {
|
||||
if c.isModelQuotaExceeded(modelName) {
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
modelName = c.getPreviewModel(model)
|
||||
if modelName != "" {
|
||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", modelName)
|
||||
continue
|
||||
}
|
||||
}
|
||||
errChan <- &ErrorMessage{
|
||||
StatusCode: 429,
|
||||
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
|
||||
}
|
||||
return
|
||||
}
|
||||
var err *ErrorMessage
|
||||
stream, err = c.APIRequest(ctx, "streamGenerateContent", rawJSON, alt, true)
|
||||
if err != nil {
|
||||
if err.StatusCode == 429 {
|
||||
now := time.Now()
|
||||
c.modelQuotaExceeded[modelName] = &now
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
delete(c.modelQuotaExceeded, modelName)
|
||||
break
|
||||
}
|
||||
|
||||
if alt == "" {
|
||||
scanner := bufio.NewScanner(stream)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if bytes.HasPrefix(line, dataTag) {
|
||||
dataChan <- line[6:]
|
||||
}
|
||||
}
|
||||
|
||||
if errScanner := scanner.Err(); errScanner != nil {
|
||||
errChan <- &ErrorMessage{500, errScanner}
|
||||
_ = stream.Close()
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
data, err := io.ReadAll(stream)
|
||||
if err != nil {
|
||||
errChan <- &ErrorMessage{500, err}
|
||||
_ = stream.Close()
|
||||
return
|
||||
}
|
||||
dataChan <- data
|
||||
}
|
||||
_ = stream.Close()
|
||||
|
||||
}()
|
||||
|
||||
return dataChan, errChan
|
||||
}
|
||||
|
||||
// isModelQuotaExceeded checks if the specified model has exceeded its quota
|
||||
// within the last 30 minutes.
|
||||
func (c *GeminiClient) isModelQuotaExceeded(model string) bool {
|
||||
if lastExceededTime, hasKey := c.modelQuotaExceeded[model]; hasKey {
|
||||
duration := time.Now().Sub(*lastExceededTime)
|
||||
if duration > 30*time.Minute {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getPreviewModel returns an available preview model for the given base model,
|
||||
// or an empty string if no preview models are available or all are quota exceeded.
|
||||
func (c *GeminiClient) getPreviewModel(model string) string {
|
||||
if models, hasKey := previewModels[model]; hasKey {
|
||||
for i := 0; i < len(models); i++ {
|
||||
if !c.isModelQuotaExceeded(models[i]) {
|
||||
return models[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsModelQuotaExceeded returns true if the specified model has exceeded its quota
|
||||
// and no fallback options are available.
|
||||
func (c *GeminiClient) IsModelQuotaExceeded(model string) bool {
|
||||
if c.isModelQuotaExceeded(model) {
|
||||
if c.cfg.QuotaExceeded.SwitchPreviewModel {
|
||||
return c.getPreviewModel(model) == ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckCloudAPIIsEnabled sends a simple test request to the API to verify
|
||||
// that the Cloud AI API is enabled for the user's project. It provides
|
||||
// an activation URL if the API is disabled.
|
||||
func (c *GeminiClient) CheckCloudAPIIsEnabled() (bool, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
c.RequestMutex.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
c.RequestMutex.Lock()
|
||||
|
||||
// A simple request to test the API endpoint.
|
||||
requestBody := fmt.Sprintf(`{"project":"%s","request":{"contents":[{"role":"user","parts":[{"text":"Be concise. What is the capital of France?"}]}],"generationConfig":{"thinkingConfig":{"include_thoughts":false,"thinkingBudget":0}}},"model":"gemini-2.5-flash"}`, c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID)
|
||||
|
||||
stream, err := c.APIRequest(ctx, "streamGenerateContent", []byte(requestBody), "", true)
|
||||
if err != nil {
|
||||
// If a 403 Forbidden error occurs, it likely means the API is not enabled.
|
||||
if err.StatusCode == 403 {
|
||||
errJSON := err.Error.Error()
|
||||
// Check for a specific error code and extract the activation URL.
|
||||
if gjson.Get(errJSON, "0.error.code").Int() == 403 {
|
||||
activationURL := gjson.Get(errJSON, "0.error.details.0.metadata.activationUrl").String()
|
||||
if activationURL != "" {
|
||||
log.Warnf(
|
||||
"\n\nPlease activate your account with this url:\n\n%s\n\n And execute this command again:\n%s --login --project_id %s",
|
||||
activationURL,
|
||||
os.Args[0],
|
||||
c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
log.Warnf("\n\nPlease copy this message and create an issue.\n\n%s\n\n", errJSON)
|
||||
return false, nil
|
||||
}
|
||||
return false, err.Error
|
||||
}
|
||||
defer func() {
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
// We only need to know if the request was successful, so we can drain the stream.
|
||||
scanner := bufio.NewScanner(stream)
|
||||
for scanner.Scan() {
|
||||
// Do nothing, just consume the stream.
|
||||
}
|
||||
|
||||
return scanner.Err() == nil, scanner.Err()
|
||||
}
|
||||
|
||||
// GetProjectList fetches a list of Google Cloud projects accessible by the user.
|
||||
func (c *GeminiClient) GetProjectList(ctx context.Context) (*GCPProject, error) {
|
||||
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create project list request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute project list request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var project GCPProject
|
||||
if err = json.NewDecoder(resp.Body).Decode(&project); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal project list: %w", err)
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the client's current token storage to a JSON file.
|
||||
// The filename is constructed from the user's email and project ID.
|
||||
func (c *GeminiClient) SaveTokenToFile() error {
|
||||
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("%s-%s.json", c.tokenStorage.(*geminiAuth.GeminiTokenStorage).Email, c.tokenStorage.(*geminiAuth.GeminiTokenStorage).ProjectID))
|
||||
log.Infof("Saving credentials to %s", fileName)
|
||||
return c.tokenStorage.SaveTokenToFile(fileName)
|
||||
}
|
||||
|
||||
// getClientMetadata returns a map of metadata about the client environment,
|
||||
// such as IDE type, platform, and plugin version.
|
||||
func (c *GeminiClient) getClientMetadata() map[string]string {
|
||||
return map[string]string{
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
// "pluginVersion": pluginVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// getClientMetadataString returns the client metadata as a single,
|
||||
// comma-separated string, which is required for the 'GeminiClient-Metadata' header.
|
||||
func (c *GeminiClient) getClientMetadataString() string {
|
||||
md := c.getClientMetadata()
|
||||
parts := make([]string, 0, len(md))
|
||||
for k, v := range md {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// GetUserAgent constructs the User-Agent string for HTTP requests.
|
||||
func (c *GeminiClient) GetUserAgent() string {
|
||||
// return fmt.Sprintf("GeminiCLI/%s (%s; %s)", pluginVersion, runtime.GOOS, runtime.GOARCH)
|
||||
return "google-api-nodejs-client/9.15.1"
|
||||
}
|
||||
54
internal/cmd/anthropic_login.go
Normal file
54
internal/cmd/anthropic_login.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DoClaudeLogin triggers the Claude OAuth flow through the shared authentication manager.
|
||||
// It initiates the OAuth authentication process for Anthropic Claude services and saves
|
||||
// the authentication tokens to the configured auth directory.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration
|
||||
// - options: Login options including browser behavior and prompts
|
||||
func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
manager := newAuthManager()
|
||||
|
||||
authOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
Metadata: map[string]string{},
|
||||
Prompt: options.Prompt,
|
||||
}
|
||||
|
||||
_, savedPath, err := manager.Login(context.Background(), "claude", cfg, authOpts)
|
||||
if err != nil {
|
||||
var authErr *claude.AuthenticationError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Error(claude.GetUserFriendlyMessage(authErr))
|
||||
if authErr.Type == claude.ErrPortInUse.Type {
|
||||
os.Exit(claude.ErrPortInUse.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Printf("Claude authentication failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
fmt.Println("Claude authentication successful!")
|
||||
}
|
||||
23
internal/cmd/auth_manager.go
Normal file
23
internal/cmd/auth_manager.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
)
|
||||
|
||||
// newAuthManager creates a new authentication manager instance with all supported
|
||||
// authenticators and a file-based token store. It initializes authenticators for
|
||||
// Gemini, Codex, Claude, and Qwen providers.
|
||||
//
|
||||
// Returns:
|
||||
// - *sdkAuth.Manager: A configured authentication manager instance
|
||||
func newAuthManager() *sdkAuth.Manager {
|
||||
store := sdkAuth.GetTokenStore()
|
||||
manager := sdkAuth.NewManager(store,
|
||||
sdkAuth.NewGeminiAuthenticator(),
|
||||
sdkAuth.NewCodexAuthenticator(),
|
||||
sdkAuth.NewClaudeAuthenticator(),
|
||||
sdkAuth.NewQwenAuthenticator(),
|
||||
sdkAuth.NewIFlowAuthenticator(),
|
||||
)
|
||||
return manager
|
||||
}
|
||||
54
internal/cmd/iflow_login.go
Normal file
54
internal/cmd/iflow_login.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DoIFlowLogin performs the iFlow OAuth login via the shared authentication manager.
|
||||
func DoIFlowLogin(cfg *config.Config, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
manager := newAuthManager()
|
||||
|
||||
promptFn := options.Prompt
|
||||
if promptFn == nil {
|
||||
promptFn = func(prompt string) (string, error) {
|
||||
fmt.Println()
|
||||
fmt.Println(prompt)
|
||||
var value string
|
||||
_, err := fmt.Scanln(&value)
|
||||
return value, err
|
||||
}
|
||||
}
|
||||
|
||||
authOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
Metadata: map[string]string{},
|
||||
Prompt: promptFn,
|
||||
}
|
||||
|
||||
_, savedPath, err := manager.Login(context.Background(), "iflow", cfg, authOpts)
|
||||
if err != nil {
|
||||
var emailErr *sdkAuth.EmailRequiredError
|
||||
if errors.As(err, &emailErr) {
|
||||
log.Error(emailErr.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("iFlow authentication failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
fmt.Println("iFlow authentication successful!")
|
||||
}
|
||||
@@ -1,95 +1,581 @@
|
||||
// Package cmd provides command-line interface functionality for the CLI Proxy API.
|
||||
// It implements the main application commands including login/authentication
|
||||
// and server startup, handling the complete user onboarding and service lifecycle.
|
||||
// Package cmd provides command-line interface functionality for the CLI Proxy API server.
|
||||
// It includes authentication flows for various AI service providers, service startup,
|
||||
// and other command-line operations.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth/gemini"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// DoLogin handles the entire user login and setup process.
|
||||
// It authenticates the user, sets up the user's project, checks API enablement,
|
||||
// and saves the token for future use.
|
||||
const (
|
||||
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||
geminiCLIVersion = "v1internal"
|
||||
geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
|
||||
geminiCLIApiClient = "gl-node/22.17.0"
|
||||
geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
|
||||
)
|
||||
|
||||
type projectSelectionRequiredError struct{}
|
||||
|
||||
func (e *projectSelectionRequiredError) Error() string {
|
||||
return "gemini cli: project selection required"
|
||||
}
|
||||
|
||||
// DoLogin handles Google Gemini authentication using the shared authentication manager.
|
||||
// It initiates the OAuth flow for Google Gemini services, performs the legacy CLI user setup,
|
||||
// and saves the authentication tokens to the configured auth directory.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration
|
||||
// - projectID: Optional Google Cloud project ID for Gemini services
|
||||
// - options: Login options including browser behavior and prompts
|
||||
func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
var err error
|
||||
var ts gemini.GeminiTokenStorage
|
||||
if projectID != "" {
|
||||
ts.ProjectID = projectID
|
||||
ctx := context.Background()
|
||||
|
||||
loginOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
ProjectID: strings.TrimSpace(projectID),
|
||||
Metadata: map[string]string{},
|
||||
Prompt: options.Prompt,
|
||||
}
|
||||
|
||||
// Initialize an authenticated HTTP client. This will trigger the OAuth flow if necessary.
|
||||
clientCtx := context.Background()
|
||||
log.Info("Initializing Google authentication...")
|
||||
geminiAuth := gemini.NewGeminiAuth()
|
||||
httpClient, errGetClient := geminiAuth.GetAuthenticatedClient(clientCtx, &ts, cfg, options.NoBrowser)
|
||||
if errGetClient != nil {
|
||||
log.Fatalf("failed to get authenticated client: %v", errGetClient)
|
||||
authenticator := sdkAuth.NewGeminiAuthenticator()
|
||||
record, errLogin := authenticator.Login(ctx, cfg, loginOpts)
|
||||
if errLogin != nil {
|
||||
log.Fatalf("Gemini authentication failed: %v", errLogin)
|
||||
return
|
||||
}
|
||||
|
||||
storage, okStorage := record.Storage.(*gemini.GeminiTokenStorage)
|
||||
if !okStorage || storage == nil {
|
||||
log.Fatal("Gemini authentication failed: unsupported token storage")
|
||||
return
|
||||
}
|
||||
|
||||
geminiAuth := gemini.NewGeminiAuth()
|
||||
httpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, options.NoBrowser)
|
||||
if errClient != nil {
|
||||
log.Fatalf("Gemini authentication failed: %v", errClient)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Authentication successful.")
|
||||
|
||||
// Initialize the API client.
|
||||
cliClient := client.NewGeminiClient(httpClient, &ts, cfg)
|
||||
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
||||
if errProjects != nil {
|
||||
log.Fatalf("Failed to get project list: %v", errProjects)
|
||||
return
|
||||
}
|
||||
|
||||
// Perform the user setup process.
|
||||
err = cliClient.SetupUser(clientCtx, ts.Email, projectID)
|
||||
if err != nil {
|
||||
// Handle the specific case where a project ID is required but not provided.
|
||||
if err.Error() == "failed to start user onboarding, need define a project id" {
|
||||
log.Error("Failed to start user onboarding: A project ID is required.")
|
||||
// Fetch and display the user's available projects to help them choose one.
|
||||
project, errGetProjectList := cliClient.GetProjectList(clientCtx)
|
||||
if errGetProjectList != nil {
|
||||
log.Fatalf("Failed to get project list: %v", err)
|
||||
} else {
|
||||
log.Infof("Your account %s needs to specify a project ID.", ts.Email)
|
||||
log.Info("========================================================================")
|
||||
for _, p := range project.Projects {
|
||||
log.Infof("Project ID: %s", p.ProjectID)
|
||||
log.Infof("Project Name: %s", p.Name)
|
||||
log.Info("------------------------------------------------------------------------")
|
||||
}
|
||||
log.Infof("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0])
|
||||
promptFn := options.Prompt
|
||||
if promptFn == nil {
|
||||
promptFn = defaultProjectPrompt()
|
||||
}
|
||||
|
||||
selectedProjectID := promptForProjectSelection(projects, strings.TrimSpace(projectID), promptFn)
|
||||
projectSelections, errSelection := resolveProjectSelections(selectedProjectID, projects)
|
||||
if errSelection != nil {
|
||||
log.Fatalf("Invalid project selection: %v", errSelection)
|
||||
return
|
||||
}
|
||||
if len(projectSelections) == 0 {
|
||||
log.Fatal("No project selected; aborting login.")
|
||||
return
|
||||
}
|
||||
|
||||
activatedProjects := make([]string, 0, len(projectSelections))
|
||||
for _, candidateID := range projectSelections {
|
||||
log.Infof("Activating project %s", candidateID)
|
||||
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, candidateID); errSetup != nil {
|
||||
var projectErr *projectSelectionRequiredError
|
||||
if errors.As(errSetup, &projectErr) {
|
||||
log.Error("Failed to start user onboarding: A project ID is required.")
|
||||
showProjectSelectionHelp(storage.Email, projects)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Fatalf("Failed to complete user setup: %v", err)
|
||||
log.Fatalf("Failed to complete user setup: %v", errSetup)
|
||||
return
|
||||
}
|
||||
return // Exit after handling the error.
|
||||
finalID := strings.TrimSpace(storage.ProjectID)
|
||||
if finalID == "" {
|
||||
finalID = candidateID
|
||||
}
|
||||
activatedProjects = append(activatedProjects, finalID)
|
||||
}
|
||||
|
||||
// If setup is successful, proceed to check API status and save the token.
|
||||
auto := projectID == ""
|
||||
cliClient.SetIsAuto(auto)
|
||||
storage.Auto = false
|
||||
storage.ProjectID = strings.Join(activatedProjects, ",")
|
||||
|
||||
// If the project was not automatically selected, check if the Cloud AI API is enabled.
|
||||
if !cliClient.IsChecked() && !cliClient.IsAuto() {
|
||||
isChecked, checkErr := cliClient.CheckCloudAPIIsEnabled()
|
||||
if checkErr != nil {
|
||||
log.Fatalf("Failed to check if Cloud AI API is enabled: %v", checkErr)
|
||||
return
|
||||
if !storage.Auto && !storage.Checked {
|
||||
for _, pid := range activatedProjects {
|
||||
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, pid)
|
||||
if errCheck != nil {
|
||||
log.Fatalf("Failed to check if Cloud AI API is enabled for %s: %v", pid, errCheck)
|
||||
return
|
||||
}
|
||||
if !isChecked {
|
||||
log.Fatalf("Failed to check if Cloud AI API is enabled for project %s. If you encounter an error message, please create an issue.", pid)
|
||||
return
|
||||
}
|
||||
}
|
||||
cliClient.SetIsChecked(isChecked)
|
||||
// If the check fails (returns false), the CheckCloudAPIIsEnabled function
|
||||
// will have already printed instructions, so we can just exit.
|
||||
if !isChecked {
|
||||
log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.")
|
||||
return
|
||||
storage.Checked = true
|
||||
}
|
||||
|
||||
updateAuthRecord(record, storage)
|
||||
|
||||
store := sdkAuth.GetTokenStore()
|
||||
if setter, okSetter := store.(interface{ SetBaseDir(string) }); okSetter && cfg != nil {
|
||||
setter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
|
||||
savedPath, errSave := store.Save(ctx, record)
|
||||
if errSave != nil {
|
||||
log.Fatalf("Failed to save token to file: %v", errSave)
|
||||
return
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
fmt.Println("Gemini authentication successful!")
|
||||
}
|
||||
|
||||
func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *gemini.GeminiTokenStorage, requestedProject string) error {
|
||||
metadata := map[string]string{
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
|
||||
trimmedRequest := strings.TrimSpace(requestedProject)
|
||||
explicitProject := trimmedRequest != ""
|
||||
|
||||
loadReqBody := map[string]any{
|
||||
"metadata": metadata,
|
||||
}
|
||||
if explicitProject {
|
||||
loadReqBody["cloudaicompanionProject"] = trimmedRequest
|
||||
}
|
||||
|
||||
var loadResp map[string]any
|
||||
if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
|
||||
return fmt.Errorf("load code assist: %w", errLoad)
|
||||
}
|
||||
|
||||
tierID := "legacy-tier"
|
||||
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
||||
for _, rawTier := range tiers {
|
||||
tier, okTier := rawTier.(map[string]any)
|
||||
if !okTier {
|
||||
continue
|
||||
}
|
||||
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
|
||||
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
|
||||
tierID = strings.TrimSpace(id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the successfully obtained and verified token to a file.
|
||||
err = cliClient.SaveTokenToFile()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to save token to file: %v", err)
|
||||
projectID := trimmedRequest
|
||||
if projectID == "" {
|
||||
if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
|
||||
projectID = strings.TrimSpace(id)
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
|
||||
if id, okID := projectMap["id"].(string); okID {
|
||||
projectID = strings.TrimSpace(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
return &projectSelectionRequiredError{}
|
||||
}
|
||||
|
||||
onboardReqBody := map[string]any{
|
||||
"tierId": tierID,
|
||||
"metadata": metadata,
|
||||
"cloudaicompanionProject": projectID,
|
||||
}
|
||||
|
||||
// Store the requested project as a fallback in case the response omits it.
|
||||
storage.ProjectID = projectID
|
||||
|
||||
for {
|
||||
var onboardResp map[string]any
|
||||
if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
|
||||
return fmt.Errorf("onboard user: %w", errOnboard)
|
||||
}
|
||||
|
||||
if done, okDone := onboardResp["done"].(bool); okDone && done {
|
||||
responseProjectID := ""
|
||||
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
|
||||
switch projectValue := resp["cloudaicompanionProject"].(type) {
|
||||
case map[string]any:
|
||||
if id, okID := projectValue["id"].(string); okID {
|
||||
responseProjectID = strings.TrimSpace(id)
|
||||
}
|
||||
case string:
|
||||
responseProjectID = strings.TrimSpace(projectValue)
|
||||
}
|
||||
}
|
||||
|
||||
finalProjectID := projectID
|
||||
if responseProjectID != "" {
|
||||
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
|
||||
log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
|
||||
} else {
|
||||
finalProjectID = responseProjectID
|
||||
}
|
||||
}
|
||||
|
||||
storage.ProjectID = strings.TrimSpace(finalProjectID)
|
||||
if storage.ProjectID == "" {
|
||||
storage.ProjectID = strings.TrimSpace(projectID)
|
||||
}
|
||||
if storage.ProjectID == "" {
|
||||
return fmt.Errorf("onboard user completed without project id")
|
||||
}
|
||||
log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Onboarding in progress, waiting 5 seconds...")
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
|
||||
url := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
|
||||
if strings.HasPrefix(endpoint, "operations/") {
|
||||
url = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
rawBody, errMarshal := json.Marshal(body)
|
||||
if errMarshal != nil {
|
||||
return fmt.Errorf("marshal request body: %w", errMarshal)
|
||||
}
|
||||
reader = bytes.NewReader(rawBody)
|
||||
}
|
||||
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, reader)
|
||||
if errRequest != nil {
|
||||
return fmt.Errorf("create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
|
||||
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
|
||||
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return fmt.Errorf("execute request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("response body close error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
|
||||
return fmt.Errorf("decode response body: %w", errDecode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
||||
if errRequest != nil {
|
||||
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
||||
}
|
||||
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("response body close error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
|
||||
var projects interfaces.GCPProject
|
||||
if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
|
||||
}
|
||||
|
||||
return projects.Projects, nil
|
||||
}
|
||||
|
||||
// promptForProjectSelection prints available projects and returns the chosen project ID.
|
||||
func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetID string, promptFn func(string) (string, error)) string {
|
||||
trimmedPreset := strings.TrimSpace(presetID)
|
||||
if len(projects) == 0 {
|
||||
if trimmedPreset != "" {
|
||||
return trimmedPreset
|
||||
}
|
||||
fmt.Println("No Google Cloud projects are available for selection.")
|
||||
return ""
|
||||
}
|
||||
|
||||
fmt.Println("Available Google Cloud projects:")
|
||||
defaultIndex := 0
|
||||
for idx, project := range projects {
|
||||
fmt.Printf("[%d] %s (%s)\n", idx+1, project.ProjectID, project.Name)
|
||||
if trimmedPreset != "" && project.ProjectID == trimmedPreset {
|
||||
defaultIndex = idx
|
||||
}
|
||||
}
|
||||
fmt.Println("Type 'ALL' to onboard every listed project.")
|
||||
|
||||
defaultID := projects[defaultIndex].ProjectID
|
||||
|
||||
if trimmedPreset != "" {
|
||||
if strings.EqualFold(trimmedPreset, "ALL") {
|
||||
return "ALL"
|
||||
}
|
||||
for _, project := range projects {
|
||||
if project.ProjectID == trimmedPreset {
|
||||
return trimmedPreset
|
||||
}
|
||||
}
|
||||
log.Warnf("Provided project ID %s not found in available projects; please choose from the list.", trimmedPreset)
|
||||
}
|
||||
|
||||
for {
|
||||
promptMsg := fmt.Sprintf("Enter project ID [%s] or ALL: ", defaultID)
|
||||
answer, errPrompt := promptFn(promptMsg)
|
||||
if errPrompt != nil {
|
||||
log.Errorf("Project selection prompt failed: %v", errPrompt)
|
||||
return defaultID
|
||||
}
|
||||
answer = strings.TrimSpace(answer)
|
||||
if strings.EqualFold(answer, "ALL") {
|
||||
return "ALL"
|
||||
}
|
||||
if answer == "" {
|
||||
return defaultID
|
||||
}
|
||||
|
||||
for _, project := range projects {
|
||||
if project.ProjectID == answer {
|
||||
return project.ProjectID
|
||||
}
|
||||
}
|
||||
|
||||
if idx, errAtoi := strconv.Atoi(answer); errAtoi == nil {
|
||||
if idx >= 1 && idx <= len(projects) {
|
||||
return projects[idx-1].ProjectID
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Invalid selection, enter a project ID or a number from the list.")
|
||||
}
|
||||
}
|
||||
|
||||
func resolveProjectSelections(selection string, projects []interfaces.GCPProjectProjects) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(selection)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
available := make(map[string]struct{}, len(projects))
|
||||
ordered := make([]string, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
id := strings.TrimSpace(project.ProjectID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := available[id]; exists {
|
||||
continue
|
||||
}
|
||||
available[id] = struct{}{}
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
if strings.EqualFold(trimmed, "ALL") {
|
||||
if len(ordered) == 0 {
|
||||
return nil, fmt.Errorf("no projects available for ALL selection")
|
||||
}
|
||||
return append([]string(nil), ordered...), nil
|
||||
}
|
||||
parts := strings.Split(trimmed, ",")
|
||||
selections := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
if len(available) > 0 {
|
||||
if _, ok := available[id]; !ok {
|
||||
return nil, fmt.Errorf("project %s not found in available projects", id)
|
||||
}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
selections = append(selections, id)
|
||||
}
|
||||
return selections, nil
|
||||
}
|
||||
|
||||
func defaultProjectPrompt() func(string) (string, error) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
return func(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
line, errRead := reader.ReadString('\n')
|
||||
if errRead != nil {
|
||||
if errors.Is(errRead, io.EOF) {
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
return "", errRead
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
}
|
||||
|
||||
func showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProjects) {
|
||||
if email != "" {
|
||||
log.Infof("Your account %s needs to specify a project ID.", email)
|
||||
} else {
|
||||
log.Info("You need to specify a project ID.")
|
||||
}
|
||||
|
||||
if len(projects) > 0 {
|
||||
fmt.Println("========================================================================")
|
||||
for _, p := range projects {
|
||||
fmt.Printf("Project ID: %s\n", p.ProjectID)
|
||||
fmt.Printf("Project Name: %s\n", p.Name)
|
||||
fmt.Println("------------------------------------------------------------------------")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("No active projects were returned for this account.")
|
||||
}
|
||||
|
||||
fmt.Printf("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0])
|
||||
}
|
||||
|
||||
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
|
||||
serviceUsageURL := "https://serviceusage.googleapis.com"
|
||||
requiredServices := []string{
|
||||
// "geminicloudassist.googleapis.com", // Gemini Cloud Assist API
|
||||
"cloudaicompanion.googleapis.com", // Gemini for Google Cloud API
|
||||
}
|
||||
for _, service := range requiredServices {
|
||||
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil)
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
|
||||
_ = resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
enableUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
|
||||
req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableUrl, strings.NewReader("{}"))
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
resp, errDo = httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
errMessage := string(bodyBytes)
|
||||
errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
|
||||
if errMessageResult.Exists() {
|
||||
errMessage = errMessageResult.String()
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||
_ = resp.Body.Close()
|
||||
continue
|
||||
} else if resp.StatusCode == http.StatusBadRequest {
|
||||
_ = resp.Body.Close()
|
||||
if strings.Contains(strings.ToLower(errMessage), "already enabled") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("project activation required: %s", errMessage)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStorage) {
|
||||
if record == nil || storage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
finalName := gemini.CredentialFileName(storage.Email, storage.ProjectID, false)
|
||||
|
||||
if record.Metadata == nil {
|
||||
record.Metadata = make(map[string]any)
|
||||
}
|
||||
record.Metadata["email"] = storage.Email
|
||||
record.Metadata["project_id"] = storage.ProjectID
|
||||
record.Metadata["auto"] = storage.Auto
|
||||
record.Metadata["checked"] = storage.Checked
|
||||
|
||||
record.ID = finalName
|
||||
record.FileName = finalName
|
||||
record.Storage = storage
|
||||
}
|
||||
|
||||
@@ -2,172 +2,63 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// LoginOptions contains options for login
|
||||
// LoginOptions contains options for the login processes.
|
||||
// It provides configuration for authentication flows including browser behavior
|
||||
// and interactive prompting capabilities.
|
||||
type LoginOptions struct {
|
||||
// NoBrowser indicates whether to skip opening the browser automatically.
|
||||
NoBrowser bool
|
||||
|
||||
// Prompt allows the caller to provide interactive input when needed.
|
||||
Prompt func(prompt string) (string, error)
|
||||
}
|
||||
|
||||
// DoCodexLogin handles the Codex OAuth login process
|
||||
// DoCodexLogin triggers the Codex OAuth flow through the shared authentication manager.
|
||||
// It initiates the OAuth authentication process for OpenAI Codex services and saves
|
||||
// the authentication tokens to the configured auth directory.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration
|
||||
// - options: Login options including browser behavior and prompts
|
||||
func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
manager := newAuthManager()
|
||||
|
||||
log.Info("Initializing Codex authentication...")
|
||||
|
||||
// Generate PKCE codes
|
||||
pkceCodes, err := codex.GeneratePKCECodes()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate PKCE codes: %v", err)
|
||||
return
|
||||
authOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
Metadata: map[string]string{},
|
||||
Prompt: options.Prompt,
|
||||
}
|
||||
|
||||
// Generate random state parameter
|
||||
state, err := generateRandomState()
|
||||
_, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize OAuth server
|
||||
oauthServer := codex.NewOAuthServer(1455)
|
||||
|
||||
// Start OAuth callback server
|
||||
if err = oauthServer.Start(ctx); err != nil {
|
||||
if strings.Contains(err.Error(), "already in use") {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrPortInUse, err)
|
||||
var authErr *codex.AuthenticationError
|
||||
if errors.As(err, &authErr) {
|
||||
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||
os.Exit(13) // Exit code 13 for port-in-use error
|
||||
}
|
||||
authErr := codex.NewAuthenticationError(codex.ErrServerStartFailed, err)
|
||||
log.Fatalf("Failed to start OAuth callback server: %v", authErr)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err = oauthServer.Stop(ctx); err != nil {
|
||||
log.Warnf("Failed to stop OAuth server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize Codex auth service
|
||||
openaiAuth := codex.NewCodexAuth(cfg)
|
||||
|
||||
// Generate authorization URL
|
||||
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Open browser or display URL
|
||||
if !options.NoBrowser {
|
||||
log.Info("Opening browser for authentication...")
|
||||
|
||||
// Check if browser is available
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available on this system")
|
||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
} else {
|
||||
if err = browser.OpenURL(authURL); err != nil {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
|
||||
// Log platform info for debugging
|
||||
platformInfo := browser.GetPlatformInfo()
|
||||
log.Debugf("Browser platform info: %+v", platformInfo)
|
||||
} else {
|
||||
log.Debug("Browser opened successfully")
|
||||
if authErr.Type == codex.ErrPortInUse.Type {
|
||||
os.Exit(codex.ErrPortInUse.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for authentication callback...")
|
||||
|
||||
// Wait for OAuth callback
|
||||
result, err := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, err)
|
||||
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||
} else {
|
||||
log.Errorf("Authentication failed: %v", err)
|
||||
}
|
||||
fmt.Printf("Codex authentication failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
oauthErr := codex.NewOAuthError(result.Error, "", http.StatusBadRequest)
|
||||
log.Error(codex.GetUserFriendlyMessage(oauthErr))
|
||||
return
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if result.State != state {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, result.State))
|
||||
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("Authorization code received, exchanging for tokens...")
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
authBundle, err := openaiAuth.ExchangeCodeForTokens(ctx, result.Code, pkceCodes)
|
||||
if err != nil {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err)
|
||||
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
||||
log.Debug("This may be due to network issues or invalid authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Create token storage
|
||||
tokenStorage := openaiAuth.CreateTokenStorage(authBundle)
|
||||
|
||||
// Initialize Codex client
|
||||
openaiClient, err := client.NewCodexClient(cfg, tokenStorage)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize Codex client: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save token storage
|
||||
if err = openaiClient.SaveTokenToFile(); err != nil {
|
||||
log.Fatalf("Failed to save authentication tokens: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Authentication successful!")
|
||||
if authBundle.APIKey != "" {
|
||||
log.Info("API key obtained and saved")
|
||||
}
|
||||
|
||||
log.Info("You can now use Codex services through this CLI")
|
||||
}
|
||||
|
||||
// generateRandomState generates a cryptographically secure random state parameter
|
||||
func generateRandomState() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
fmt.Println("Codex authentication successful!")
|
||||
}
|
||||
|
||||
60
internal/cmd/qwen_login.go
Normal file
60
internal/cmd/qwen_login.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DoQwenLogin handles the Qwen device flow using the shared authentication manager.
|
||||
// It initiates the device-based authentication process for Qwen services and saves
|
||||
// the authentication tokens to the configured auth directory.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration
|
||||
// - options: Login options including browser behavior and prompts
|
||||
func DoQwenLogin(cfg *config.Config, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
manager := newAuthManager()
|
||||
|
||||
promptFn := options.Prompt
|
||||
if promptFn == nil {
|
||||
promptFn = func(prompt string) (string, error) {
|
||||
fmt.Println()
|
||||
fmt.Println(prompt)
|
||||
var value string
|
||||
_, err := fmt.Scanln(&value)
|
||||
return value, err
|
||||
}
|
||||
}
|
||||
|
||||
authOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
Metadata: map[string]string{},
|
||||
Prompt: promptFn,
|
||||
}
|
||||
|
||||
_, savedPath, err := manager.Login(context.Background(), "qwen", cfg, authOpts)
|
||||
if err != nil {
|
||||
var emailErr *sdkAuth.EmailRequiredError
|
||||
if errors.As(err, &emailErr) {
|
||||
log.Error(emailErr.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("Qwen authentication failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
fmt.Println("Qwen authentication successful!")
|
||||
}
|
||||
@@ -1,218 +1,69 @@
|
||||
// Package cmd provides the main service execution functionality for the CLIProxyAPI.
|
||||
// It contains the core logic for starting and managing the API proxy service,
|
||||
// including authentication client management, server initialization, and graceful shutdown handling.
|
||||
// The package handles loading authentication tokens, creating client pools, starting the API server,
|
||||
// and monitoring configuration changes through file watchers.
|
||||
// Package cmd provides command-line interface functionality for the CLI Proxy API server.
|
||||
// It includes authentication flows for various AI service providers, service startup,
|
||||
// and other command-line operations.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"errors"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/luispater/CLIProxyAPI/internal/api"
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||
"github.com/luispater/CLIProxyAPI/internal/auth/gemini"
|
||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||
"github.com/luispater/CLIProxyAPI/internal/watcher"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// StartService initializes and starts the main API proxy service.
|
||||
// It loads all available authentication tokens, creates a pool of clients,
|
||||
// starts the API server, and handles graceful shutdown signals.
|
||||
// StartService builds and runs the proxy service using the exported SDK.
|
||||
// It creates a new proxy service instance, sets up signal handling for graceful shutdown,
|
||||
// and starts the service with the provided configuration.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration
|
||||
// - configPath: The path to the configuration file
|
||||
func StartService(cfg *config.Config, configPath string) {
|
||||
// Create a pool of API clients, one for each token file found.
|
||||
cliClients := make([]client.Client, 0)
|
||||
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// - localPassword: Optional password accepted for local management requests
|
||||
func StartService(cfg *config.Config, configPath string, localPassword string) {
|
||||
builder := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath(configPath).
|
||||
WithLocalManagementPassword(localPassword)
|
||||
|
||||
// Process only JSON files in the auth directory.
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
|
||||
log.Debugf("Loading token from: %s", path)
|
||||
data, errReadFile := os.ReadFile(path)
|
||||
if errReadFile != nil {
|
||||
return errReadFile
|
||||
}
|
||||
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
tokenType := "gemini"
|
||||
typeResult := gjson.GetBytes(data, "type")
|
||||
if typeResult.Exists() {
|
||||
tokenType = typeResult.String()
|
||||
}
|
||||
runCtx := ctxSignal
|
||||
if localPassword != "" {
|
||||
var keepAliveCancel context.CancelFunc
|
||||
runCtx, keepAliveCancel = context.WithCancel(ctxSignal)
|
||||
builder = builder.WithServerOptions(api.WithKeepAliveEndpoint(10*time.Second, func() {
|
||||
log.Warn("keep-alive endpoint idle for 10s, shutting down")
|
||||
keepAliveCancel()
|
||||
}))
|
||||
}
|
||||
|
||||
clientCtx := context.Background()
|
||||
|
||||
if tokenType == "gemini" {
|
||||
var ts gemini.GeminiTokenStorage
|
||||
if err = json.Unmarshal(data, &ts); err == nil {
|
||||
// For each valid token, create an authenticated client.
|
||||
log.Info("Initializing gemini authentication for token...")
|
||||
geminiAuth := gemini.NewGeminiAuth()
|
||||
httpClient, errGetClient := geminiAuth.GetAuthenticatedClient(clientCtx, &ts, cfg)
|
||||
if errGetClient != nil {
|
||||
// Log fatal will exit, but we return the error for completeness.
|
||||
log.Fatalf("failed to get authenticated client for token %s: %v", path, errGetClient)
|
||||
return errGetClient
|
||||
}
|
||||
log.Info("Authentication successful.")
|
||||
|
||||
// Add the new client to the pool.
|
||||
cliClient := client.NewGeminiClient(httpClient, &ts, cfg)
|
||||
cliClients = append(cliClients, cliClient)
|
||||
}
|
||||
} else if tokenType == "codex" {
|
||||
var ts codex.CodexTokenStorage
|
||||
if err = json.Unmarshal(data, &ts); err == nil {
|
||||
// For each valid token, create an authenticated client.
|
||||
log.Info("Initializing codex authentication for token...")
|
||||
codexClient, errGetClient := client.NewCodexClient(cfg, &ts)
|
||||
if errGetClient != nil {
|
||||
// Log fatal will exit, but we return the error for completeness.
|
||||
log.Fatalf("failed to get authenticated client for token %s: %v", path, errGetClient)
|
||||
return errGetClient
|
||||
}
|
||||
log.Info("Authentication successful.")
|
||||
cliClients = append(cliClients, codexClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
service, err := builder.Build()
|
||||
if err != nil {
|
||||
log.Fatalf("Error walking auth directory: %v", err)
|
||||
log.Fatalf("failed to build proxy service: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.GlAPIKey) > 0 {
|
||||
for i := 0; i < len(cfg.GlAPIKey); i++ {
|
||||
httpClient := util.SetProxy(cfg, &http.Client{})
|
||||
|
||||
log.Debug("Initializing with Generative Language API key...")
|
||||
cliClient := client.NewGeminiClient(httpClient, nil, cfg, cfg.GlAPIKey[i])
|
||||
cliClients = append(cliClients, cliClient)
|
||||
}
|
||||
}
|
||||
|
||||
// Create and start the API server with the pool of clients.
|
||||
apiServer := api.NewServer(cfg, cliClients)
|
||||
log.Infof("Starting API server on port %d", cfg.Port)
|
||||
|
||||
// Start the API server in a goroutine so it doesn't block the main thread
|
||||
go func() {
|
||||
if err = apiServer.Start(); err != nil {
|
||||
log.Fatalf("API server failed to start: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give the server a moment to start up
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
log.Info("API server started successfully")
|
||||
|
||||
// Setup file watcher for config and auth directory changes
|
||||
fileWatcher, errNewWatcher := watcher.NewWatcher(configPath, cfg.AuthDir, func(newClients []client.Client, newCfg *config.Config) {
|
||||
// Update the API server with new clients and configuration
|
||||
apiServer.UpdateClients(newClients, newCfg)
|
||||
})
|
||||
if errNewWatcher != nil {
|
||||
log.Fatalf("failed to create file watcher: %v", errNewWatcher)
|
||||
}
|
||||
|
||||
// Set initial state for the watcher
|
||||
fileWatcher.SetConfig(cfg)
|
||||
fileWatcher.SetClients(cliClients)
|
||||
|
||||
// Start the file watcher
|
||||
watcherCtx, watcherCancel := context.WithCancel(context.Background())
|
||||
if errStartWatcher := fileWatcher.Start(watcherCtx); errStartWatcher != nil {
|
||||
log.Fatalf("failed to start file watcher: %v", errStartWatcher)
|
||||
}
|
||||
log.Info("file watcher started for config and auth directory changes")
|
||||
|
||||
defer func() {
|
||||
watcherCancel()
|
||||
errStopWatcher := fileWatcher.Stop()
|
||||
if errStopWatcher != nil {
|
||||
log.Errorf("error stopping file watcher: %v", errStopWatcher)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set up a channel to listen for OS signals for graceful shutdown.
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Background token refresh ticker for Codex clients
|
||||
ctxRefresh, cancelRefresh := context.WithCancel(context.Background())
|
||||
var wgRefresh sync.WaitGroup
|
||||
wgRefresh.Add(1)
|
||||
go func() {
|
||||
defer wgRefresh.Done()
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
checkAndRefresh := func() {
|
||||
for i := 0; i < len(cliClients); i++ {
|
||||
if codexCli, ok := cliClients[i].(*client.CodexClient); ok {
|
||||
ts := codexCli.TokenStorage().(*codex.CodexTokenStorage)
|
||||
if ts != nil && ts.Expire != "" {
|
||||
if expTime, errParse := time.Parse(time.RFC3339, ts.Expire); errParse == nil {
|
||||
if time.Until(expTime) <= 5*24*time.Hour {
|
||||
log.Debugf("refreshing codex tokens for %s", codexCli.GetEmail())
|
||||
_ = codexCli.RefreshTokens(ctxRefresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Initial check on start
|
||||
checkAndRefresh()
|
||||
for {
|
||||
select {
|
||||
case <-ctxRefresh.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
checkAndRefresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop to wait for shutdown signal.
|
||||
for {
|
||||
select {
|
||||
case <-sigChan:
|
||||
log.Debugf("Received shutdown signal. Cleaning up...")
|
||||
|
||||
cancelRefresh()
|
||||
wgRefresh.Wait()
|
||||
|
||||
// Create a context with a timeout for the shutdown process.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_ = cancel
|
||||
|
||||
// Stop the API server gracefully.
|
||||
if err = apiServer.Stop(ctx); err != nil {
|
||||
log.Debugf("Error stopping API server: %v", err)
|
||||
}
|
||||
|
||||
log.Debugf("Cleanup completed. Exiting...")
|
||||
os.Exit(0)
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
err = service.Run(runCtx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("proxy service exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode
|
||||
// when no configuration file is available.
|
||||
func WaitForCloudDeploy() {
|
||||
// Clarify that we are intentionally idle for configuration and not running the API server.
|
||||
log.Info("Cloud deploy mode: No config found; standing by for configuration. API server is not started. Press Ctrl+C to exit.")
|
||||
|
||||
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Block until shutdown signal is received
|
||||
<-ctxSignal.Done()
|
||||
log.Info("Cloud deploy mode: Shutdown signal received; exiting")
|
||||
}
|
||||
|
||||
123
internal/cmd/vertex_import.go
Normal file
123
internal/cmd/vertex_import.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Package cmd contains CLI helpers. This file implements importing a Vertex AI
|
||||
// service account JSON into the auth store as a dedicated "vertex" credential.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DoVertexImport imports a Google Cloud service account key JSON and persists
|
||||
// it as a "vertex" provider credential. The file content is embedded in the auth
|
||||
// file to allow portable deployment across stores.
|
||||
func DoVertexImport(cfg *config.Config, keyPath string) {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
if resolved, errResolve := util.ResolveAuthDir(cfg.AuthDir); errResolve == nil {
|
||||
cfg.AuthDir = resolved
|
||||
}
|
||||
rawPath := strings.TrimSpace(keyPath)
|
||||
if rawPath == "" {
|
||||
log.Fatalf("vertex-import: missing service account key path")
|
||||
return
|
||||
}
|
||||
data, errRead := os.ReadFile(rawPath)
|
||||
if errRead != nil {
|
||||
log.Fatalf("vertex-import: read file failed: %v", errRead)
|
||||
return
|
||||
}
|
||||
var sa map[string]any
|
||||
if errUnmarshal := json.Unmarshal(data, &sa); errUnmarshal != nil {
|
||||
log.Fatalf("vertex-import: invalid service account json: %v", errUnmarshal)
|
||||
return
|
||||
}
|
||||
// Validate and normalize private_key before saving
|
||||
normalizedSA, errFix := vertex.NormalizeServiceAccountMap(sa)
|
||||
if errFix != nil {
|
||||
log.Fatalf("vertex-import: %v", errFix)
|
||||
return
|
||||
}
|
||||
sa = normalizedSA
|
||||
email, _ := sa["client_email"].(string)
|
||||
projectID, _ := sa["project_id"].(string)
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
log.Fatalf("vertex-import: project_id missing in service account json")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(email) == "" {
|
||||
// Keep empty email but warn
|
||||
log.Warn("vertex-import: client_email missing in service account json")
|
||||
}
|
||||
// Default location if not provided by user. Can be edited in the saved file later.
|
||||
location := "us-central1"
|
||||
|
||||
fileName := fmt.Sprintf("vertex-%s.json", sanitizeFilePart(projectID))
|
||||
// Build auth record
|
||||
storage := &vertex.VertexCredentialStorage{
|
||||
ServiceAccount: sa,
|
||||
ProjectID: projectID,
|
||||
Email: email,
|
||||
Location: location,
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"service_account": sa,
|
||||
"project_id": projectID,
|
||||
"email": email,
|
||||
"location": location,
|
||||
"type": "vertex",
|
||||
"label": labelForVertex(projectID, email),
|
||||
}
|
||||
record := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "vertex",
|
||||
FileName: fileName,
|
||||
Storage: storage,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
store := sdkAuth.GetTokenStore()
|
||||
if setter, ok := store.(interface{ SetBaseDir(string) }); ok {
|
||||
setter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
path, errSave := store.Save(context.Background(), record)
|
||||
if errSave != nil {
|
||||
log.Fatalf("vertex-import: save credential failed: %v", errSave)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Vertex credentials imported: %s\n", path)
|
||||
}
|
||||
|
||||
func sanitizeFilePart(s string) string {
|
||||
out := strings.TrimSpace(s)
|
||||
replacers := []string{"/", "_", "\\", "_", ":", "_", " ", "-"}
|
||||
for i := 0; i < len(replacers); i += 2 {
|
||||
out = strings.ReplaceAll(out, replacers[i], replacers[i+1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func labelForVertex(projectID, email string) string {
|
||||
p := strings.TrimSpace(projectID)
|
||||
e := strings.TrimSpace(email)
|
||||
if p != "" && e != "" {
|
||||
return fmt.Sprintf("%s (%s)", p, e)
|
||||
}
|
||||
if p != "" {
|
||||
return p
|
||||
}
|
||||
if e != "" {
|
||||
return e
|
||||
}
|
||||
return "vertex"
|
||||
}
|
||||
@@ -5,47 +5,216 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the application's configuration, loaded from a YAML file.
|
||||
type Config struct {
|
||||
config.SDKConfig `yaml:",inline"`
|
||||
// Port is the network port on which the API server will listen.
|
||||
Port int `yaml:"port"`
|
||||
Port int `yaml:"port" json:"-"`
|
||||
|
||||
// AuthDir is the directory where authentication token files are stored.
|
||||
AuthDir string `yaml:"auth-dir"`
|
||||
AuthDir string `yaml:"auth-dir" json:"-"`
|
||||
|
||||
// Debug enables or disables debug-level logging and other debug features.
|
||||
Debug bool `yaml:"debug"`
|
||||
Debug bool `yaml:"debug" json:"debug"`
|
||||
|
||||
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
||||
ProxyURL string `yaml:"proxy-url"`
|
||||
// LoggingToFile controls whether application logs are written to rotating files or stdout.
|
||||
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
|
||||
|
||||
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||
APIKeys []string `yaml:"api-keys"`
|
||||
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
|
||||
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
|
||||
|
||||
// DisableCooling disables quota cooldown scheduling when true.
|
||||
DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"`
|
||||
|
||||
// QuotaExceeded defines the behavior when a quota is exceeded.
|
||||
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded"`
|
||||
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
||||
|
||||
// GlAPIKey is the API key for the generative language API.
|
||||
GlAPIKey []string `yaml:"generative-language-api-key"`
|
||||
// WebsocketAuth enables or disables authentication for the WebSocket API.
|
||||
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
|
||||
|
||||
// RequestLog enables or disables detailed request logging functionality.
|
||||
RequestLog bool `yaml:"request-log"`
|
||||
// GlAPIKey exposes the legacy generative language API key list for backward compatibility.
|
||||
GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"`
|
||||
|
||||
// GeminiKey defines Gemini API key configurations with optional routing overrides.
|
||||
GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
|
||||
|
||||
// RequestRetry defines the retry times when the request failed.
|
||||
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
||||
|
||||
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
|
||||
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
|
||||
|
||||
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
|
||||
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
|
||||
|
||||
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
|
||||
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"`
|
||||
|
||||
// RemoteManagement nests management-related options under 'remote-management'.
|
||||
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
|
||||
|
||||
// Payload defines default and override rules for provider payload parameters.
|
||||
Payload PayloadConfig `yaml:"payload" json:"payload"`
|
||||
}
|
||||
|
||||
// RemoteManagement holds management API configuration under 'remote-management'.
|
||||
type RemoteManagement struct {
|
||||
// AllowRemote toggles remote (non-localhost) access to management API.
|
||||
AllowRemote bool `yaml:"allow-remote"`
|
||||
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
|
||||
SecretKey string `yaml:"secret-key"`
|
||||
// DisableControlPanel skips serving and syncing the bundled management UI when true.
|
||||
DisableControlPanel bool `yaml:"disable-control-panel"`
|
||||
}
|
||||
|
||||
// QuotaExceeded defines the behavior when API quota limits are exceeded.
|
||||
// It provides configuration options for automatic failover mechanisms.
|
||||
type QuotaExceeded struct {
|
||||
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
|
||||
SwitchProject bool `yaml:"switch-project"`
|
||||
SwitchProject bool `yaml:"switch-project" json:"switch-project"`
|
||||
|
||||
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
|
||||
SwitchPreviewModel bool `yaml:"switch-preview-model"`
|
||||
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
|
||||
}
|
||||
|
||||
// PayloadConfig defines default and override parameter rules applied to provider payloads.
|
||||
type PayloadConfig struct {
|
||||
// Default defines rules that only set parameters when they are missing in the payload.
|
||||
Default []PayloadRule `yaml:"default" json:"default"`
|
||||
// Override defines rules that always set parameters, overwriting any existing values.
|
||||
Override []PayloadRule `yaml:"override" json:"override"`
|
||||
}
|
||||
|
||||
// PayloadRule describes a single rule targeting a list of models with parameter updates.
|
||||
type PayloadRule struct {
|
||||
// Models lists model entries with name pattern and protocol constraint.
|
||||
Models []PayloadModelRule `yaml:"models" json:"models"`
|
||||
// Params maps JSON paths (gjson/sjson syntax) to values written into the payload.
|
||||
Params map[string]any `yaml:"params" json:"params"`
|
||||
}
|
||||
|
||||
// PayloadModelRule ties a model name pattern to a specific translator protocol.
|
||||
type PayloadModelRule struct {
|
||||
// Name is the model name or wildcard pattern (e.g., "gpt-*", "*-5", "gemini-*-pro").
|
||||
Name string `yaml:"name" json:"name"`
|
||||
// Protocol restricts the rule to a specific translator format (e.g., "gemini", "responses").
|
||||
Protocol string `yaml:"protocol" json:"protocol"`
|
||||
}
|
||||
|
||||
// ClaudeKey represents the configuration for a Claude API key,
|
||||
// including the API key itself and an optional base URL for the API endpoint.
|
||||
type ClaudeKey struct {
|
||||
// APIKey is the authentication key for accessing Claude API services.
|
||||
APIKey string `yaml:"api-key" json:"api-key"`
|
||||
|
||||
// BaseURL is the base URL for the Claude API endpoint.
|
||||
// If empty, the default Claude API URL will be used.
|
||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||
|
||||
// ProxyURL overrides the global proxy setting for this API key if provided.
|
||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||
|
||||
// Models defines upstream model names and aliases for request routing.
|
||||
Models []ClaudeModel `yaml:"models" json:"models"`
|
||||
|
||||
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// ClaudeModel describes a mapping between an alias and the actual upstream model name.
|
||||
type ClaudeModel struct {
|
||||
// Name is the upstream model identifier used when issuing requests.
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
||||
// Alias is the client-facing model name that maps to Name.
|
||||
Alias string `yaml:"alias" json:"alias"`
|
||||
}
|
||||
|
||||
// CodexKey represents the configuration for a Codex API key,
|
||||
// including the API key itself and an optional base URL for the API endpoint.
|
||||
type CodexKey struct {
|
||||
// APIKey is the authentication key for accessing Codex API services.
|
||||
APIKey string `yaml:"api-key" json:"api-key"`
|
||||
|
||||
// BaseURL is the base URL for the Codex API endpoint.
|
||||
// If empty, the default Codex API URL will be used.
|
||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||
|
||||
// ProxyURL overrides the global proxy setting for this API key if provided.
|
||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||
|
||||
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// GeminiKey represents the configuration for a Gemini API key,
|
||||
// including optional overrides for upstream base URL, proxy routing, and headers.
|
||||
type GeminiKey struct {
|
||||
// APIKey is the authentication key for accessing Gemini API services.
|
||||
APIKey string `yaml:"api-key" json:"api-key"`
|
||||
|
||||
// BaseURL optionally overrides the Gemini API endpoint.
|
||||
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
|
||||
|
||||
// ProxyURL optionally overrides the global proxy for this API key.
|
||||
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
|
||||
|
||||
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
||||
// with external providers, allowing model aliases to be routed through OpenAI API format.
|
||||
type OpenAICompatibility struct {
|
||||
// Name is the identifier for this OpenAI compatibility configuration.
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
||||
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
||||
|
||||
// APIKeys are the authentication keys for accessing the external API services.
|
||||
// Deprecated: Use APIKeyEntries instead to support per-key proxy configuration.
|
||||
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
||||
|
||||
// APIKeyEntries defines API keys with optional per-key proxy configuration.
|
||||
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
|
||||
|
||||
// Models defines the model configurations including aliases for routing.
|
||||
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
|
||||
|
||||
// Headers optionally adds extra HTTP headers for requests sent to this provider.
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
|
||||
type OpenAICompatibilityAPIKey struct {
|
||||
// APIKey is the authentication key for accessing the external API services.
|
||||
APIKey string `yaml:"api-key" json:"api-key"`
|
||||
|
||||
// ProxyURL overrides the global proxy setting for this API key if provided.
|
||||
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
|
||||
// including the actual model name and its alias for API routing.
|
||||
type OpenAICompatibilityModel struct {
|
||||
// Name is the actual model name used by the external provider.
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
||||
// Alias is the model name alias that clients will use to reference this model.
|
||||
Alias string `yaml:"alias" json:"alias"`
|
||||
}
|
||||
|
||||
// LoadConfig reads a YAML configuration file from the given path,
|
||||
@@ -59,18 +228,736 @@ type QuotaExceeded struct {
|
||||
// - *Config: The loaded configuration
|
||||
// - error: An error if the configuration could not be loaded
|
||||
func LoadConfig(configFile string) (*Config, error) {
|
||||
return LoadConfigOptional(configFile, false)
|
||||
}
|
||||
|
||||
// LoadConfigOptional reads YAML from configFile.
|
||||
// If optional is true and the file is missing, it returns an empty Config.
|
||||
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
||||
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||
// Read the entire configuration file into memory.
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
if optional {
|
||||
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
|
||||
// Missing and optional: return empty config (cloud deploy standby).
|
||||
return &Config{}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.
|
||||
if optional && len(data) == 0 {
|
||||
return &Config{}, nil
|
||||
}
|
||||
|
||||
// Unmarshal the YAML data into the Config struct.
|
||||
var config Config
|
||||
if err = yaml.Unmarshal(data, &config); err != nil {
|
||||
var cfg Config
|
||||
// Set defaults before unmarshal so that absent keys keep defaults.
|
||||
cfg.LoggingToFile = false
|
||||
cfg.UsageStatisticsEnabled = false
|
||||
cfg.DisableCooling = false
|
||||
if err = yaml.Unmarshal(data, &cfg); err != nil {
|
||||
if optional {
|
||||
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
|
||||
return &Config{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
// Hash remote management key if plaintext is detected (nested)
|
||||
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
|
||||
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
|
||||
hashed, errHash := hashSecret(cfg.RemoteManagement.SecretKey)
|
||||
if errHash != nil {
|
||||
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
|
||||
}
|
||||
cfg.RemoteManagement.SecretKey = hashed
|
||||
|
||||
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
|
||||
// Preserve YAML comments and ordering; update only the nested key.
|
||||
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed)
|
||||
}
|
||||
|
||||
// Sync request authentication providers with inline API keys for backwards compatibility.
|
||||
syncInlineAccessProvider(&cfg)
|
||||
|
||||
// Sanitize Gemini API key configuration and migrate legacy entries.
|
||||
cfg.SanitizeGeminiKeys()
|
||||
|
||||
// Sanitize Codex keys: drop entries without base-url
|
||||
cfg.SanitizeCodexKeys()
|
||||
|
||||
// Sanitize Claude key headers
|
||||
cfg.SanitizeClaudeKeys()
|
||||
|
||||
// Sanitize OpenAI compatibility providers: drop entries without base-url
|
||||
cfg.SanitizeOpenAICompatibility()
|
||||
|
||||
// Return the populated configuration struct.
|
||||
return &config, nil
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
|
||||
// not actionable, specifically those missing a BaseURL. It trims whitespace before
|
||||
// evaluation and preserves the relative order of remaining entries.
|
||||
func (cfg *Config) SanitizeOpenAICompatibility() {
|
||||
if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
e := cfg.OpenAICompatibility[i]
|
||||
e.Name = strings.TrimSpace(e.Name)
|
||||
e.BaseURL = strings.TrimSpace(e.BaseURL)
|
||||
e.Headers = NormalizeHeaders(e.Headers)
|
||||
if e.BaseURL == "" {
|
||||
// Skip providers with no base-url; treated as removed
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
cfg.OpenAICompatibility = out
|
||||
}
|
||||
|
||||
// SanitizeCodexKeys removes Codex API key entries missing a BaseURL.
|
||||
// It trims whitespace and preserves order for remaining entries.
|
||||
func (cfg *Config) SanitizeCodexKeys() {
|
||||
if cfg == nil || len(cfg.CodexKey) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]CodexKey, 0, len(cfg.CodexKey))
|
||||
for i := range cfg.CodexKey {
|
||||
e := cfg.CodexKey[i]
|
||||
e.BaseURL = strings.TrimSpace(e.BaseURL)
|
||||
e.Headers = NormalizeHeaders(e.Headers)
|
||||
if e.BaseURL == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
cfg.CodexKey = out
|
||||
}
|
||||
|
||||
// SanitizeClaudeKeys normalizes headers for Claude credentials.
|
||||
func (cfg *Config) SanitizeClaudeKeys() {
|
||||
if cfg == nil || len(cfg.ClaudeKey) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range cfg.ClaudeKey {
|
||||
entry := &cfg.ClaudeKey[i]
|
||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
|
||||
func (cfg *Config) SanitizeGeminiKeys() {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(cfg.GeminiKey))
|
||||
out := cfg.GeminiKey[:0]
|
||||
for i := range cfg.GeminiKey {
|
||||
entry := cfg.GeminiKey[i]
|
||||
entry.APIKey = strings.TrimSpace(entry.APIKey)
|
||||
if entry.APIKey == "" {
|
||||
continue
|
||||
}
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||
if _, exists := seen[entry.APIKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[entry.APIKey] = struct{}{}
|
||||
out = append(out, entry)
|
||||
}
|
||||
cfg.GeminiKey = out
|
||||
|
||||
if len(cfg.GlAPIKey) > 0 {
|
||||
for _, raw := range cfg.GlAPIKey {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key})
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.GlAPIKey = nil
|
||||
}
|
||||
|
||||
func syncInlineAccessProvider(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
|
||||
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
|
||||
}
|
||||
}
|
||||
cfg.Access.Providers = nil
|
||||
}
|
||||
|
||||
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
|
||||
func looksLikeBcrypt(s string) bool {
|
||||
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
|
||||
}
|
||||
|
||||
// NormalizeHeaders trims header keys and values and removes empty pairs.
|
||||
func NormalizeHeaders(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
clean := make(map[string]string, len(headers))
|
||||
for k, v := range headers {
|
||||
key := strings.TrimSpace(k)
|
||||
val := strings.TrimSpace(v)
|
||||
if key == "" || val == "" {
|
||||
continue
|
||||
}
|
||||
clean[key] = val
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return nil
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// hashSecret hashes the given secret using bcrypt.
|
||||
func hashSecret(secret string) (string, error) {
|
||||
// Use default cost for simplicity.
|
||||
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashedBytes), nil
|
||||
}
|
||||
|
||||
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
|
||||
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
|
||||
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
persistCfg := sanitizeConfigForPersist(cfg)
|
||||
// Load original YAML as a node tree to preserve comments and ordering.
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var original yaml.Node
|
||||
if err = yaml.Unmarshal(data, &original); err != nil {
|
||||
return err
|
||||
}
|
||||
if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
|
||||
return fmt.Errorf("invalid yaml document structure")
|
||||
}
|
||||
if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("expected root mapping node")
|
||||
}
|
||||
|
||||
// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
|
||||
rendered, err := yaml.Marshal(persistCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var generated yaml.Node
|
||||
if err = yaml.Unmarshal(rendered, &generated); err != nil {
|
||||
return err
|
||||
}
|
||||
if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
|
||||
return fmt.Errorf("invalid generated yaml structure")
|
||||
}
|
||||
if generated.Content[0].Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("expected generated root mapping node")
|
||||
}
|
||||
|
||||
// Remove deprecated auth block before merging to avoid persisting it again.
|
||||
removeMapKey(original.Content[0], "auth")
|
||||
|
||||
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
||||
mergeMappingPreserve(original.Content[0], generated.Content[0])
|
||||
normalizeCollectionNodeStyles(original.Content[0])
|
||||
|
||||
// Write back.
|
||||
f, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
if err = enc.Encode(&original); err != nil {
|
||||
_ = enc.Close()
|
||||
return err
|
||||
}
|
||||
if err = enc.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
data = NormalizeCommentIndentation(buf.Bytes())
|
||||
_, err = f.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func sanitizeConfigForPersist(cfg *Config) *Config {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *cfg
|
||||
clone.SDKConfig = cfg.SDKConfig
|
||||
clone.SDKConfig.Access = config.AccessConfig{}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
|
||||
// while preserving comments and positions.
|
||||
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var root yaml.Node
|
||||
if err = yaml.Unmarshal(data, &root); err != nil {
|
||||
return err
|
||||
}
|
||||
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
|
||||
return fmt.Errorf("invalid yaml document structure")
|
||||
}
|
||||
node := root.Content[0]
|
||||
// descend mapping nodes following path
|
||||
for i, key := range path {
|
||||
if i == len(path)-1 {
|
||||
// set final scalar
|
||||
v := getOrCreateMapValue(node, key)
|
||||
v.Kind = yaml.ScalarNode
|
||||
v.Tag = "!!str"
|
||||
v.Value = value
|
||||
} else {
|
||||
next := getOrCreateMapValue(node, key)
|
||||
if next.Kind != yaml.MappingNode {
|
||||
next.Kind = yaml.MappingNode
|
||||
next.Tag = "!!map"
|
||||
}
|
||||
node = next
|
||||
}
|
||||
}
|
||||
f, err := os.Create(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
if err = enc.Encode(&root); err != nil {
|
||||
_ = enc.Close()
|
||||
return err
|
||||
}
|
||||
if err = enc.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
data = NormalizeCommentIndentation(buf.Bytes())
|
||||
_, err = f.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.
|
||||
func NormalizeCommentIndentation(data []byte) []byte {
|
||||
lines := bytes.Split(data, []byte("\n"))
|
||||
changed := false
|
||||
for i, line := range lines {
|
||||
trimmed := bytes.TrimLeft(line, " \t")
|
||||
if len(trimmed) == 0 || trimmed[0] != '#' {
|
||||
continue
|
||||
}
|
||||
if len(trimmed) == len(line) {
|
||||
continue
|
||||
}
|
||||
lines[i] = append([]byte(nil), trimmed...)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return data
|
||||
}
|
||||
return bytes.Join(lines, []byte("\n"))
|
||||
}
|
||||
|
||||
// getOrCreateMapValue finds the value node for a given key in a mapping node.
|
||||
// If not found, it appends a new key/value pair and returns the new value node.
|
||||
func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
|
||||
if mapNode.Kind != yaml.MappingNode {
|
||||
mapNode.Kind = yaml.MappingNode
|
||||
mapNode.Tag = "!!map"
|
||||
mapNode.Content = nil
|
||||
}
|
||||
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||
k := mapNode.Content[i]
|
||||
if k.Value == key {
|
||||
return mapNode.Content[i+1]
|
||||
}
|
||||
}
|
||||
// append new key/value
|
||||
mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
|
||||
val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
|
||||
mapNode.Content = append(mapNode.Content, val)
|
||||
return val
|
||||
}
|
||||
|
||||
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
|
||||
// key order and comments of existing keys in dst. Unknown keys from src are appended
|
||||
// to dst at the end, copying their node structure from src.
|
||||
func mergeMappingPreserve(dst, src *yaml.Node) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
|
||||
// If kinds do not match, prefer replacing dst with src semantics in-place
|
||||
// but keep dst node object to preserve any attached comments at the parent level.
|
||||
copyNodeShallow(dst, src)
|
||||
return
|
||||
}
|
||||
// Build a lookup of existing keys in dst
|
||||
for i := 0; i+1 < len(src.Content); i += 2 {
|
||||
sk := src.Content[i]
|
||||
sv := src.Content[i+1]
|
||||
idx := findMapKeyIndex(dst, sk.Value)
|
||||
if idx >= 0 {
|
||||
// Merge into existing value node
|
||||
dv := dst.Content[idx+1]
|
||||
mergeNodePreserve(dv, sv)
|
||||
} else {
|
||||
if shouldSkipEmptyCollectionOnPersist(sk.Value, sv) {
|
||||
continue
|
||||
}
|
||||
// Append new key/value pair by deep-copying from src
|
||||
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
||||
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
||||
// in-place by index.
|
||||
func mergeNodePreserve(dst, src *yaml.Node) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
switch src.Kind {
|
||||
case yaml.MappingNode:
|
||||
if dst.Kind != yaml.MappingNode {
|
||||
copyNodeShallow(dst, src)
|
||||
}
|
||||
mergeMappingPreserve(dst, src)
|
||||
case yaml.SequenceNode:
|
||||
// Preserve explicit null style if dst was null and src is empty sequence
|
||||
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
|
||||
// Keep as null to preserve original style
|
||||
return
|
||||
}
|
||||
if dst.Kind != yaml.SequenceNode {
|
||||
dst.Kind = yaml.SequenceNode
|
||||
dst.Tag = "!!seq"
|
||||
dst.Content = nil
|
||||
}
|
||||
reorderSequenceForMerge(dst, src)
|
||||
// Update elements in place
|
||||
minContent := len(dst.Content)
|
||||
if len(src.Content) < minContent {
|
||||
minContent = len(src.Content)
|
||||
}
|
||||
for i := 0; i < minContent; i++ {
|
||||
if dst.Content[i] == nil {
|
||||
dst.Content[i] = deepCopyNode(src.Content[i])
|
||||
continue
|
||||
}
|
||||
mergeNodePreserve(dst.Content[i], src.Content[i])
|
||||
}
|
||||
// Append any extra items from src
|
||||
for i := len(dst.Content); i < len(src.Content); i++ {
|
||||
dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
|
||||
}
|
||||
// Truncate if dst has extra items not in src
|
||||
if len(src.Content) < len(dst.Content) {
|
||||
dst.Content = dst.Content[:len(src.Content)]
|
||||
}
|
||||
case yaml.ScalarNode, yaml.AliasNode:
|
||||
// For scalars, update Tag and Value but keep Style from dst to preserve quoting
|
||||
dst.Kind = src.Kind
|
||||
dst.Tag = src.Tag
|
||||
dst.Value = src.Value
|
||||
// Keep dst.Style as-is intentionally
|
||||
case 0:
|
||||
// Unknown/empty kind; do nothing
|
||||
default:
|
||||
// Fallback: replace shallowly
|
||||
copyNodeShallow(dst, src)
|
||||
}
|
||||
}
|
||||
|
||||
// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
|
||||
// Returns -1 when not found.
|
||||
func findMapKeyIndex(mapNode *yaml.Node, key string) int {
|
||||
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
||||
return -1
|
||||
}
|
||||
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shouldSkipEmptyCollectionOnPersist(key string, node *yaml.Node) bool {
|
||||
switch key {
|
||||
case "generative-language-api-key",
|
||||
"gemini-api-key",
|
||||
"claude-api-key",
|
||||
"codex-api-key",
|
||||
"openai-compatibility":
|
||||
return isEmptyCollectionNode(node)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isEmptyCollectionNode(node *yaml.Node) bool {
|
||||
if node == nil {
|
||||
return true
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.SequenceNode:
|
||||
return len(node.Content) == 0
|
||||
case yaml.ScalarNode:
|
||||
return node.Tag == "!!null"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// deepCopyNode creates a deep copy of a yaml.Node graph.
|
||||
func deepCopyNode(n *yaml.Node) *yaml.Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *n
|
||||
if len(n.Content) > 0 {
|
||||
cp.Content = make([]*yaml.Node, len(n.Content))
|
||||
for i := range n.Content {
|
||||
cp.Content[i] = deepCopyNode(n.Content[i])
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
// copyNodeShallow copies type/tag/value and resets content to match src, but
|
||||
// keeps the same destination node pointer to preserve parent relations/comments.
|
||||
func copyNodeShallow(dst, src *yaml.Node) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.Kind = src.Kind
|
||||
dst.Tag = src.Tag
|
||||
dst.Value = src.Value
|
||||
// Replace content with deep copy from src
|
||||
if len(src.Content) > 0 {
|
||||
dst.Content = make([]*yaml.Node, len(src.Content))
|
||||
for i := range src.Content {
|
||||
dst.Content[i] = deepCopyNode(src.Content[i])
|
||||
}
|
||||
} else {
|
||||
dst.Content = nil
|
||||
}
|
||||
}
|
||||
|
||||
func reorderSequenceForMerge(dst, src *yaml.Node) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
if len(dst.Content) == 0 {
|
||||
return
|
||||
}
|
||||
if len(src.Content) == 0 {
|
||||
return
|
||||
}
|
||||
original := append([]*yaml.Node(nil), dst.Content...)
|
||||
used := make([]bool, len(original))
|
||||
ordered := make([]*yaml.Node, len(src.Content))
|
||||
for i := range src.Content {
|
||||
if idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 {
|
||||
ordered[i] = original[idx]
|
||||
used[idx] = true
|
||||
}
|
||||
}
|
||||
dst.Content = ordered
|
||||
}
|
||||
|
||||
func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int {
|
||||
if target == nil {
|
||||
return -1
|
||||
}
|
||||
switch target.Kind {
|
||||
case yaml.MappingNode:
|
||||
id := sequenceElementIdentity(target)
|
||||
if id != "" {
|
||||
for i := range original {
|
||||
if used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode {
|
||||
continue
|
||||
}
|
||||
if sequenceElementIdentity(original[i]) == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
val := strings.TrimSpace(target.Value)
|
||||
if val != "" {
|
||||
for i := range original {
|
||||
if used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(original[i].Value) == val {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
// Fallback to structural equality to preserve nodes lacking explicit identifiers.
|
||||
for i := range original {
|
||||
if used[i] || original[i] == nil {
|
||||
continue
|
||||
}
|
||||
if nodesStructurallyEqual(original[i], target) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func sequenceElementIdentity(node *yaml.Node) string {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return ""
|
||||
}
|
||||
identityKeys := []string{"id", "name", "alias", "api-key", "api_key", "apikey", "key", "provider", "model"}
|
||||
for _, k := range identityKeys {
|
||||
if v := mappingScalarValue(node, k); v != "" {
|
||||
return k + "=" + v
|
||||
}
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
keyNode := node.Content[i]
|
||||
valNode := node.Content[i+1]
|
||||
if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(valNode.Value)
|
||||
if val != "" {
|
||||
return strings.ToLower(strings.TrimSpace(keyNode.Value)) + "=" + val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mappingScalarValue(node *yaml.Node, key string) string {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return ""
|
||||
}
|
||||
lowerKey := strings.ToLower(key)
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
keyNode := node.Content[i]
|
||||
valNode := node.Content[i+1]
|
||||
if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey {
|
||||
return strings.TrimSpace(valNode.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nodesStructurallyEqual(a, b *yaml.Node) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return false
|
||||
}
|
||||
switch a.Kind {
|
||||
case yaml.MappingNode:
|
||||
if len(a.Content) != len(b.Content) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i+1 < len(a.Content); i += 2 {
|
||||
if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
|
||||
return false
|
||||
}
|
||||
if !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case yaml.SequenceNode:
|
||||
if len(a.Content) != len(b.Content) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Content {
|
||||
if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case yaml.ScalarNode:
|
||||
return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
|
||||
case yaml.AliasNode:
|
||||
return nodesStructurallyEqual(a.Alias, b.Alias)
|
||||
default:
|
||||
return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func removeMapKey(mapNode *yaml.Node, key string) {
|
||||
if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" {
|
||||
return
|
||||
}
|
||||
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
|
||||
mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping
|
||||
// lists and maps readable. Empty sequences retain flow style ([]) so empty list markers
|
||||
// remain compact.
|
||||
func normalizeCollectionNodeStyles(node *yaml.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
node.Style = 0
|
||||
for i := range node.Content {
|
||||
normalizeCollectionNodeStyles(node.Content[i])
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
if len(node.Content) == 0 {
|
||||
node.Style = yaml.FlowStyle
|
||||
} else {
|
||||
node.Style = 0
|
||||
}
|
||||
for i := range node.Content {
|
||||
normalizeCollectionNodeStyles(node.Content[i])
|
||||
}
|
||||
default:
|
||||
// Scalars keep their existing style to preserve quoting
|
||||
}
|
||||
}
|
||||
|
||||
24
internal/constant/constant.go
Normal file
24
internal/constant/constant.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package constant defines provider name constants used throughout the CLI Proxy API.
|
||||
// These constants identify different AI service providers and their variants,
|
||||
// ensuring consistent naming across the application.
|
||||
package constant
|
||||
|
||||
const (
|
||||
// Gemini represents the Google Gemini provider identifier.
|
||||
Gemini = "gemini"
|
||||
|
||||
// GeminiCLI represents the Google Gemini CLI provider identifier.
|
||||
GeminiCLI = "gemini-cli"
|
||||
|
||||
// Codex represents the OpenAI Codex provider identifier.
|
||||
Codex = "codex"
|
||||
|
||||
// Claude represents the Anthropic Claude provider identifier.
|
||||
Claude = "claude"
|
||||
|
||||
// OpenAI represents the OpenAI provider identifier.
|
||||
OpenAI = "openai"
|
||||
|
||||
// OpenaiResponse represents the OpenAI response format identifier.
|
||||
OpenaiResponse = "openai-response"
|
||||
)
|
||||
17
internal/interfaces/api_handler.go
Normal file
17
internal/interfaces/api_handler.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
|
||||
// These interfaces provide a common contract for different components of the application,
|
||||
// such as AI service clients, API handlers, and data models.
|
||||
package interfaces
|
||||
|
||||
// APIHandler defines the interface that all API handlers must implement.
|
||||
// This interface provides methods for identifying handler types and retrieving
|
||||
// supported models for different AI service endpoints.
|
||||
type APIHandler interface {
|
||||
// HandlerType returns the type identifier for this API handler.
|
||||
// This is used to determine which request/response translators to use.
|
||||
HandlerType() string
|
||||
|
||||
// Models returns a list of supported models for this API handler.
|
||||
// Each model is represented as a map containing model metadata.
|
||||
Models() []map[string]any
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
// Package client defines the data structures used across all AI API clients.
|
||||
// These structures represent the common data models for requests, responses,
|
||||
// and configuration parameters used when communicating with various AI services.
|
||||
package client
|
||||
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
|
||||
// These interfaces provide a common contract for different components of the application,
|
||||
// such as AI service clients, API handlers, and data models.
|
||||
package interfaces
|
||||
|
||||
import "time"
|
||||
|
||||
// ErrorMessage encapsulates an error with an associated HTTP status code.
|
||||
// This structure is used to provide detailed error information including
|
||||
// both the HTTP status and the underlying error.
|
||||
type ErrorMessage struct {
|
||||
// StatusCode is the HTTP status code returned by the API.
|
||||
StatusCode int
|
||||
|
||||
// Error is the underlying error that occurred.
|
||||
Error error
|
||||
}
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// GCPProject represents the response structure for a Google Cloud project list request.
|
||||
// This structure is used when fetching available projects for a Google Cloud account.
|
||||
20
internal/interfaces/error_message.go
Normal file
20
internal/interfaces/error_message.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
|
||||
// These interfaces provide a common contract for different components of the application,
|
||||
// such as AI service clients, API handlers, and data models.
|
||||
package interfaces
|
||||
|
||||
import "net/http"
|
||||
|
||||
// ErrorMessage encapsulates an error with an associated HTTP status code.
|
||||
// This structure is used to provide detailed error information including
|
||||
// both the HTTP status and the underlying error.
|
||||
type ErrorMessage struct {
|
||||
// StatusCode is the HTTP status code returned by the API.
|
||||
StatusCode int
|
||||
|
||||
// Error is the underlying error that occurred.
|
||||
Error error
|
||||
|
||||
// Addon contains additional headers to be added to the response.
|
||||
Addon http.Header
|
||||
}
|
||||
15
internal/interfaces/types.go
Normal file
15
internal/interfaces/types.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Package interfaces provides type aliases for backwards compatibility with translator functions.
|
||||
// It defines common interface types used throughout the CLI Proxy API for request and response
|
||||
// transformation operations, maintaining compatibility with the SDK translator package.
|
||||
package interfaces
|
||||
|
||||
import sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
|
||||
// Backwards compatible aliases for translator function types.
|
||||
type TranslateRequestFunc = sdktranslator.RequestTransform
|
||||
|
||||
type TranslateResponseFunc = sdktranslator.ResponseStreamTransform
|
||||
|
||||
type TranslateResponseNonStreamFunc = sdktranslator.ResponseNonStreamTransform
|
||||
|
||||
type TranslateResponse = sdktranslator.ResponseTransform
|
||||
79
internal/logging/gin_logger.go
Normal file
79
internal/logging/gin_logger.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package logging provides Gin middleware for HTTP request logging and panic recovery.
|
||||
// It integrates Gin web framework with logrus for structured logging of HTTP requests,
|
||||
// responses, and error handling with panic recovery capabilities.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
|
||||
// using logrus. It captures request details including method, path, status code, latency,
|
||||
// client IP, and any error messages, formatting them in a Gin-style log format.
|
||||
//
|
||||
// Returns:
|
||||
// - gin.HandlerFunc: A middleware handler for request logging
|
||||
func GinLogrusLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
raw := util.MaskSensitiveQuery(c.Request.URL.RawQuery)
|
||||
|
||||
c.Next()
|
||||
|
||||
if raw != "" {
|
||||
path = path + "?" + raw
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
if latency > time.Minute {
|
||||
latency = latency.Truncate(time.Second)
|
||||
} else {
|
||||
latency = latency.Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
statusCode := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||
timestamp := time.Now().Format("2006/01/02 - 15:04:05")
|
||||
logLine := fmt.Sprintf("[GIN] %s | %3d | %13v | %15s | %-7s \"%s\"", timestamp, statusCode, latency, clientIP, method, path)
|
||||
if errorMessage != "" {
|
||||
logLine = logLine + " | " + errorMessage
|
||||
}
|
||||
|
||||
switch {
|
||||
case statusCode >= http.StatusInternalServerError:
|
||||
log.Error(logLine)
|
||||
case statusCode >= http.StatusBadRequest:
|
||||
log.Warn(logLine)
|
||||
default:
|
||||
log.Info(logLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GinLogrusRecovery returns a Gin middleware handler that recovers from panics and logs
|
||||
// them using logrus. When a panic occurs, it captures the panic value, stack trace,
|
||||
// and request path, then returns a 500 Internal Server Error response to the client.
|
||||
//
|
||||
// Returns:
|
||||
// - gin.HandlerFunc: A middleware handler for panic recovery
|
||||
func GinLogrusRecovery() gin.HandlerFunc {
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.WithFields(log.Fields{
|
||||
"panic": recovered,
|
||||
"stack": string(debug.Stack()),
|
||||
"path": c.Request.URL.Path,
|
||||
}).Error("recovered from panic")
|
||||
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
})
|
||||
}
|
||||
121
internal/logging/global_logger.go
Normal file
121
internal/logging/global_logger.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
setupOnce sync.Once
|
||||
writerMu sync.Mutex
|
||||
logWriter *lumberjack.Logger
|
||||
ginInfoWriter *io.PipeWriter
|
||||
ginErrorWriter *io.PipeWriter
|
||||
)
|
||||
|
||||
// LogFormatter defines a custom log format for logrus.
|
||||
// This formatter adds timestamp, level, and source location to each log entry.
|
||||
type LogFormatter struct{}
|
||||
|
||||
// Format renders a single log entry with custom formatting.
|
||||
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
||||
var buffer *bytes.Buffer
|
||||
if entry.Buffer != nil {
|
||||
buffer = entry.Buffer
|
||||
} else {
|
||||
buffer = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||
message := strings.TrimRight(entry.Message, "\r\n")
|
||||
formatted := fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
|
||||
buffer.WriteString(formatted)
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// SetupBaseLogger configures the shared logrus instance and Gin writers.
|
||||
// It is safe to call multiple times; initialization happens only once.
|
||||
func SetupBaseLogger() {
|
||||
setupOnce.Do(func() {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetReportCaller(true)
|
||||
log.SetFormatter(&LogFormatter{})
|
||||
|
||||
ginInfoWriter = log.StandardLogger().Writer()
|
||||
gin.DefaultWriter = ginInfoWriter
|
||||
ginErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)
|
||||
gin.DefaultErrorWriter = ginErrorWriter
|
||||
gin.DebugPrintFunc = func(format string, values ...interface{}) {
|
||||
format = strings.TrimRight(format, "\r\n")
|
||||
log.StandardLogger().Infof(format, values...)
|
||||
}
|
||||
|
||||
log.RegisterExitHandler(closeLogOutputs)
|
||||
})
|
||||
}
|
||||
|
||||
// ConfigureLogOutput switches the global log destination between rotating files and stdout.
|
||||
func ConfigureLogOutput(loggingToFile bool) error {
|
||||
SetupBaseLogger()
|
||||
|
||||
writerMu.Lock()
|
||||
defer writerMu.Unlock()
|
||||
|
||||
if loggingToFile {
|
||||
logDir := "logs"
|
||||
if base := util.WritablePath(); base != "" {
|
||||
logDir = filepath.Join(base, "logs")
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return fmt.Errorf("logging: failed to create log directory: %w", err)
|
||||
}
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
}
|
||||
logWriter = &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "main.log"),
|
||||
MaxSize: 10,
|
||||
MaxBackups: 0,
|
||||
MaxAge: 0,
|
||||
Compress: false,
|
||||
}
|
||||
log.SetOutput(logWriter)
|
||||
return nil
|
||||
}
|
||||
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
logWriter = nil
|
||||
}
|
||||
log.SetOutput(os.Stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeLogOutputs() {
|
||||
writerMu.Lock()
|
||||
defer writerMu.Unlock()
|
||||
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
logWriter = nil
|
||||
}
|
||||
if ginInfoWriter != nil {
|
||||
_ = ginInfoWriter.Close()
|
||||
ginInfoWriter = nil
|
||||
}
|
||||
if ginErrorWriter != nil {
|
||||
_ = ginErrorWriter.Close()
|
||||
ginErrorWriter = nil
|
||||
}
|
||||
}
|
||||
@@ -14,40 +14,109 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
)
|
||||
|
||||
// RequestLogger defines the interface for logging HTTP requests and responses.
|
||||
// It provides methods for logging both regular and streaming HTTP request/response cycles.
|
||||
type RequestLogger interface {
|
||||
// LogRequest logs a complete non-streaming request/response cycle
|
||||
LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response []byte) error
|
||||
// LogRequest logs a complete non-streaming request/response cycle.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
// - method: The HTTP method
|
||||
// - requestHeaders: The request headers
|
||||
// - body: The request body
|
||||
// - statusCode: The response status code
|
||||
// - responseHeaders: The response headers
|
||||
// - response: The raw response data
|
||||
// - apiRequest: The API request data
|
||||
// - apiResponse: The API response data
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if logging fails, nil otherwise
|
||||
LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error
|
||||
|
||||
// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks
|
||||
// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
// - method: The HTTP method
|
||||
// - headers: The request headers
|
||||
// - body: The request body
|
||||
//
|
||||
// Returns:
|
||||
// - StreamingLogWriter: A writer for streaming response chunks
|
||||
// - error: An error if logging initialization fails, nil otherwise
|
||||
LogStreamingRequest(url, method string, headers map[string][]string, body []byte) (StreamingLogWriter, error)
|
||||
|
||||
// IsEnabled returns whether request logging is currently enabled
|
||||
// IsEnabled returns whether request logging is currently enabled.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if logging is enabled, false otherwise
|
||||
IsEnabled() bool
|
||||
}
|
||||
|
||||
// StreamingLogWriter handles real-time logging of streaming response chunks.
|
||||
// It provides methods for writing streaming response data asynchronously.
|
||||
type StreamingLogWriter interface {
|
||||
// WriteChunkAsync writes a response chunk asynchronously (non-blocking)
|
||||
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
|
||||
//
|
||||
// Parameters:
|
||||
// - chunk: The response chunk to write
|
||||
WriteChunkAsync(chunk []byte)
|
||||
|
||||
// WriteStatus writes the response status and headers to the log
|
||||
// WriteStatus writes the response status and headers to the log.
|
||||
//
|
||||
// Parameters:
|
||||
// - status: The response status code
|
||||
// - headers: The response headers
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if writing fails, nil otherwise
|
||||
WriteStatus(status int, headers map[string][]string) error
|
||||
|
||||
// Close finalizes the log file and cleans up resources
|
||||
// Close finalizes the log file and cleans up resources.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if closing fails, nil otherwise
|
||||
Close() error
|
||||
}
|
||||
|
||||
// FileRequestLogger implements RequestLogger using file-based storage.
|
||||
// It provides file-based logging functionality for HTTP requests and responses.
|
||||
type FileRequestLogger struct {
|
||||
// enabled indicates whether request logging is currently enabled.
|
||||
enabled bool
|
||||
|
||||
// logsDir is the directory where log files are stored.
|
||||
logsDir string
|
||||
}
|
||||
|
||||
// NewFileRequestLogger creates a new file-based request logger.
|
||||
func NewFileRequestLogger(enabled bool, logsDir string) *FileRequestLogger {
|
||||
//
|
||||
// Parameters:
|
||||
// - enabled: Whether request logging should be enabled
|
||||
// - logsDir: The directory where log files should be stored (can be relative)
|
||||
// - configDir: The directory of the configuration file; when logsDir is
|
||||
// relative, it will be resolved relative to this directory
|
||||
//
|
||||
// Returns:
|
||||
// - *FileRequestLogger: A new file-based request logger instance
|
||||
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
|
||||
// Resolve logsDir relative to the configuration file directory when it's not absolute.
|
||||
if !filepath.IsAbs(logsDir) {
|
||||
// If configDir is provided, resolve logsDir relative to it.
|
||||
if configDir != "" {
|
||||
logsDir = filepath.Join(configDir, logsDir)
|
||||
}
|
||||
}
|
||||
return &FileRequestLogger{
|
||||
enabled: enabled,
|
||||
logsDir: logsDir,
|
||||
@@ -55,12 +124,38 @@ func NewFileRequestLogger(enabled bool, logsDir string) *FileRequestLogger {
|
||||
}
|
||||
|
||||
// IsEnabled returns whether request logging is currently enabled.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if logging is enabled, false otherwise
|
||||
func (l *FileRequestLogger) IsEnabled() bool {
|
||||
return l.enabled
|
||||
}
|
||||
|
||||
// SetEnabled updates the request logging enabled state.
|
||||
// This method allows dynamic enabling/disabling of request logging.
|
||||
//
|
||||
// Parameters:
|
||||
// - enabled: Whether request logging should be enabled
|
||||
func (l *FileRequestLogger) SetEnabled(enabled bool) {
|
||||
l.enabled = enabled
|
||||
}
|
||||
|
||||
// LogRequest logs a complete non-streaming request/response cycle to a file.
|
||||
func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response []byte) error {
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
// - method: The HTTP method
|
||||
// - requestHeaders: The request headers
|
||||
// - body: The request body
|
||||
// - statusCode: The response status code
|
||||
// - responseHeaders: The response headers
|
||||
// - response: The raw response data
|
||||
// - apiRequest: The API request data
|
||||
// - apiResponse: The API response data
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if logging fails, nil otherwise
|
||||
func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error {
|
||||
if !l.enabled {
|
||||
return nil
|
||||
}
|
||||
@@ -82,10 +177,10 @@ func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[st
|
||||
}
|
||||
|
||||
// Create log content
|
||||
content := l.formatLogContent(url, method, requestHeaders, body, decompressedResponse, statusCode, responseHeaders)
|
||||
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders, apiResponseErrors)
|
||||
|
||||
// Write to file
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write log file: %w", err)
|
||||
}
|
||||
|
||||
@@ -93,6 +188,16 @@ func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[st
|
||||
}
|
||||
|
||||
// LogStreamingRequest initiates logging for a streaming request.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
// - method: The HTTP method
|
||||
// - headers: The request headers
|
||||
// - body: The request body
|
||||
//
|
||||
// Returns:
|
||||
// - StreamingLogWriter: A writer for streaming response chunks
|
||||
// - error: An error if logging initialization fails, nil otherwise
|
||||
func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[string][]string, body []byte) (StreamingLogWriter, error) {
|
||||
if !l.enabled {
|
||||
return &NoOpStreamingLogWriter{}, nil
|
||||
@@ -115,7 +220,7 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
|
||||
|
||||
// Write initial request information
|
||||
requestInfo := l.formatRequestInfo(url, method, headers, body)
|
||||
if _, err := file.WriteString(requestInfo); err != nil {
|
||||
if _, err = file.WriteString(requestInfo); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("failed to write request info: %w", err)
|
||||
}
|
||||
@@ -135,6 +240,9 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
|
||||
}
|
||||
|
||||
// ensureLogsDir creates the logs directory if it doesn't exist.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if directory creation fails, nil otherwise
|
||||
func (l *FileRequestLogger) ensureLogsDir() error {
|
||||
if _, err := os.Stat(l.logsDir); os.IsNotExist(err) {
|
||||
return os.MkdirAll(l.logsDir, 0755)
|
||||
@@ -143,6 +251,12 @@ func (l *FileRequestLogger) ensureLogsDir() error {
|
||||
}
|
||||
|
||||
// generateFilename creates a sanitized filename from the URL path and current timestamp.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
//
|
||||
// Returns:
|
||||
// - string: A sanitized filename for the log file
|
||||
func (l *FileRequestLogger) generateFilename(url string) string {
|
||||
// Extract path from URL
|
||||
path := url
|
||||
@@ -159,12 +273,19 @@ func (l *FileRequestLogger) generateFilename(url string) string {
|
||||
sanitized := l.sanitizeForFilename(path)
|
||||
|
||||
// Add timestamp
|
||||
timestamp := time.Now().UnixNano()
|
||||
timestamp := time.Now().Format("2006-01-02T150405-.000000000")
|
||||
timestamp = strings.Replace(timestamp, ".", "", -1)
|
||||
|
||||
return fmt.Sprintf("%s-%d.log", sanitized, timestamp)
|
||||
return fmt.Sprintf("%s-%s.log", sanitized, timestamp)
|
||||
}
|
||||
|
||||
// sanitizeForFilename replaces characters that are not safe for filenames.
|
||||
//
|
||||
// Parameters:
|
||||
// - path: The path to sanitize
|
||||
//
|
||||
// Returns:
|
||||
// - string: A sanitized filename
|
||||
func (l *FileRequestLogger) sanitizeForFilename(path string) string {
|
||||
// Replace slashes with hyphens
|
||||
sanitized := strings.ReplaceAll(path, "/", "-")
|
||||
@@ -192,14 +313,62 @@ func (l *FileRequestLogger) sanitizeForFilename(path string) string {
|
||||
}
|
||||
|
||||
// formatLogContent creates the complete log content for non-streaming requests.
|
||||
func (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body []byte, response []byte, status int, responseHeaders map[string][]string) string {
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
// - method: The HTTP method
|
||||
// - headers: The request headers
|
||||
// - body: The request body
|
||||
// - apiRequest: The API request data
|
||||
// - apiResponse: The API response data
|
||||
// - response: The raw response data
|
||||
// - status: The response status code
|
||||
// - responseHeaders: The response headers
|
||||
//
|
||||
// Returns:
|
||||
// - string: The formatted log content
|
||||
func (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body, apiRequest, apiResponse, response []byte, status int, responseHeaders map[string][]string, apiResponseErrors []*interfaces.ErrorMessage) string {
|
||||
var content strings.Builder
|
||||
|
||||
// Request info
|
||||
content.WriteString(l.formatRequestInfo(url, method, headers, body))
|
||||
|
||||
if len(apiRequest) > 0 {
|
||||
if bytes.HasPrefix(apiRequest, []byte("=== API REQUEST")) {
|
||||
content.Write(apiRequest)
|
||||
if !bytes.HasSuffix(apiRequest, []byte("\n")) {
|
||||
content.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
content.WriteString("=== API REQUEST ===\n")
|
||||
content.Write(apiRequest)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
for i := 0; i < len(apiResponseErrors); i++ {
|
||||
content.WriteString("=== API ERROR RESPONSE ===\n")
|
||||
content.WriteString(fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode))
|
||||
content.WriteString(apiResponseErrors[i].Error.Error())
|
||||
content.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if len(apiResponse) > 0 {
|
||||
if bytes.HasPrefix(apiResponse, []byte("=== API RESPONSE")) {
|
||||
content.Write(apiResponse)
|
||||
if !bytes.HasSuffix(apiResponse, []byte("\n")) {
|
||||
content.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
content.WriteString("=== API RESPONSE ===\n")
|
||||
content.Write(apiResponse)
|
||||
content.WriteString("\n")
|
||||
}
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Response section
|
||||
content.WriteString("========================================\n")
|
||||
content.WriteString("=== RESPONSE ===\n")
|
||||
content.WriteString(fmt.Sprintf("Status: %d\n", status))
|
||||
|
||||
@@ -219,6 +388,14 @@ func (l *FileRequestLogger) formatLogContent(url, method string, headers map[str
|
||||
}
|
||||
|
||||
// decompressResponse decompresses response data based on Content-Encoding header.
|
||||
//
|
||||
// Parameters:
|
||||
// - responseHeaders: The response headers
|
||||
// - response: The response data to decompress
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The decompressed response data
|
||||
// - error: An error if decompression fails, nil otherwise
|
||||
func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]string, response []byte) ([]byte, error) {
|
||||
if responseHeaders == nil || len(response) == 0 {
|
||||
return response, nil
|
||||
@@ -238,6 +415,10 @@ func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]stri
|
||||
return l.decompressGzip(response)
|
||||
case "deflate":
|
||||
return l.decompressDeflate(response)
|
||||
case "br":
|
||||
return l.decompressBrotli(response)
|
||||
case "zstd":
|
||||
return l.decompressZstd(response)
|
||||
default:
|
||||
// No compression or unsupported compression
|
||||
return response, nil
|
||||
@@ -245,12 +426,23 @@ func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]stri
|
||||
}
|
||||
|
||||
// decompressGzip decompresses gzip-encoded data.
|
||||
//
|
||||
// Parameters:
|
||||
// - data: The gzip-encoded data to decompress
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The decompressed data
|
||||
// - error: An error if decompression fails, nil otherwise
|
||||
func (l *FileRequestLogger) decompressGzip(data []byte) ([]byte, error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
defer func() {
|
||||
if errClose := reader.Close(); errClose != nil {
|
||||
log.WithError(errClose).Warn("failed to close gzip reader in request logger")
|
||||
}
|
||||
}()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
@@ -261,9 +453,20 @@ func (l *FileRequestLogger) decompressGzip(data []byte) ([]byte, error) {
|
||||
}
|
||||
|
||||
// decompressDeflate decompresses deflate-encoded data.
|
||||
//
|
||||
// Parameters:
|
||||
// - data: The deflate-encoded data to decompress
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The decompressed data
|
||||
// - error: An error if decompression fails, nil otherwise
|
||||
func (l *FileRequestLogger) decompressDeflate(data []byte) ([]byte, error) {
|
||||
reader := flate.NewReader(bytes.NewReader(data))
|
||||
defer reader.Close()
|
||||
defer func() {
|
||||
if errClose := reader.Close(); errClose != nil {
|
||||
log.WithError(errClose).Warn("failed to close deflate reader in request logger")
|
||||
}
|
||||
}()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
@@ -273,7 +476,58 @@ func (l *FileRequestLogger) decompressDeflate(data []byte) ([]byte, error) {
|
||||
return decompressed, nil
|
||||
}
|
||||
|
||||
// decompressBrotli decompresses brotli-encoded data.
|
||||
//
|
||||
// Parameters:
|
||||
// - data: The brotli-encoded data to decompress
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The decompressed data
|
||||
// - error: An error if decompression fails, nil otherwise
|
||||
func (l *FileRequestLogger) decompressBrotli(data []byte) ([]byte, error) {
|
||||
reader := brotli.NewReader(bytes.NewReader(data))
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decompress brotli data: %w", err)
|
||||
}
|
||||
|
||||
return decompressed, nil
|
||||
}
|
||||
|
||||
// decompressZstd decompresses zstd-encoded data.
|
||||
//
|
||||
// Parameters:
|
||||
// - data: The zstd-encoded data to decompress
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The decompressed data
|
||||
// - error: An error if decompression fails, nil otherwise
|
||||
func (l *FileRequestLogger) decompressZstd(data []byte) ([]byte, error) {
|
||||
decoder, err := zstd.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create zstd reader: %w", err)
|
||||
}
|
||||
defer decoder.Close()
|
||||
|
||||
decompressed, err := io.ReadAll(decoder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decompress zstd data: %w", err)
|
||||
}
|
||||
|
||||
return decompressed, nil
|
||||
}
|
||||
|
||||
// formatRequestInfo creates the request information section of the log.
|
||||
//
|
||||
// Parameters:
|
||||
// - url: The request URL
|
||||
// - method: The HTTP method
|
||||
// - headers: The request headers
|
||||
// - body: The request body
|
||||
//
|
||||
// Returns:
|
||||
// - string: The formatted request information
|
||||
func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[string][]string, body []byte) string {
|
||||
var content strings.Builder
|
||||
|
||||
@@ -286,7 +540,8 @@ func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[st
|
||||
content.WriteString("=== HEADERS ===\n")
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
||||
masked := util.MaskSensitiveHeaderValue(key, value)
|
||||
content.WriteString(fmt.Sprintf("%s: %s\n", key, masked))
|
||||
}
|
||||
}
|
||||
content.WriteString("\n")
|
||||
@@ -299,15 +554,28 @@ func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[st
|
||||
}
|
||||
|
||||
// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.
|
||||
// It handles asynchronous writing of streaming response chunks to a file.
|
||||
type FileStreamingLogWriter struct {
|
||||
file *os.File
|
||||
chunkChan chan []byte
|
||||
closeChan chan struct{}
|
||||
errorChan chan error
|
||||
// file is the file where log data is written.
|
||||
file *os.File
|
||||
|
||||
// chunkChan is a channel for receiving response chunks to write.
|
||||
chunkChan chan []byte
|
||||
|
||||
// closeChan is a channel for signaling when the writer is closed.
|
||||
closeChan chan struct{}
|
||||
|
||||
// errorChan is a channel for reporting errors during writing.
|
||||
errorChan chan error
|
||||
|
||||
// statusWritten indicates whether the response status has been written.
|
||||
statusWritten bool
|
||||
}
|
||||
|
||||
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
|
||||
//
|
||||
// Parameters:
|
||||
// - chunk: The response chunk to write
|
||||
func (w *FileStreamingLogWriter) WriteChunkAsync(chunk []byte) {
|
||||
if w.chunkChan == nil {
|
||||
return
|
||||
@@ -326,6 +594,13 @@ func (w *FileStreamingLogWriter) WriteChunkAsync(chunk []byte) {
|
||||
}
|
||||
|
||||
// WriteStatus writes the response status and headers to the log.
|
||||
//
|
||||
// Parameters:
|
||||
// - status: The response status code
|
||||
// - headers: The response headers
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if writing fails, nil otherwise
|
||||
func (w *FileStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {
|
||||
if w.file == nil || w.statusWritten {
|
||||
return nil
|
||||
@@ -351,6 +626,9 @@ func (w *FileStreamingLogWriter) WriteStatus(status int, headers map[string][]st
|
||||
}
|
||||
|
||||
// Close finalizes the log file and cleans up resources.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if closing fails, nil otherwise
|
||||
func (w *FileStreamingLogWriter) Close() error {
|
||||
if w.chunkChan != nil {
|
||||
close(w.chunkChan)
|
||||
@@ -370,6 +648,7 @@ func (w *FileStreamingLogWriter) Close() error {
|
||||
}
|
||||
|
||||
// asyncWriter runs in a goroutine to handle async chunk writing.
|
||||
// It continuously reads chunks from the channel and writes them to the file.
|
||||
func (w *FileStreamingLogWriter) asyncWriter() {
|
||||
defer close(w.closeChan)
|
||||
|
||||
@@ -381,10 +660,29 @@ func (w *FileStreamingLogWriter) asyncWriter() {
|
||||
}
|
||||
|
||||
// NoOpStreamingLogWriter is a no-operation implementation for when logging is disabled.
|
||||
// It implements the StreamingLogWriter interface but performs no actual logging operations.
|
||||
type NoOpStreamingLogWriter struct{}
|
||||
|
||||
func (w *NoOpStreamingLogWriter) WriteChunkAsync(chunk []byte) {}
|
||||
func (w *NoOpStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {
|
||||
// WriteChunkAsync is a no-op implementation that does nothing.
|
||||
//
|
||||
// Parameters:
|
||||
// - chunk: The response chunk (ignored)
|
||||
func (w *NoOpStreamingLogWriter) WriteChunkAsync(_ []byte) {}
|
||||
|
||||
// WriteStatus is a no-op implementation that does nothing and always returns nil.
|
||||
//
|
||||
// Parameters:
|
||||
// - status: The response status code (ignored)
|
||||
// - headers: The response headers (ignored)
|
||||
//
|
||||
// Returns:
|
||||
// - error: Always returns nil
|
||||
func (w *NoOpStreamingLogWriter) WriteStatus(_ int, _ map[string][]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close is a no-op implementation that does nothing and always returns nil.
|
||||
//
|
||||
// Returns:
|
||||
// - error: Always returns nil
|
||||
func (w *NoOpStreamingLogWriter) Close() error { return nil }
|
||||
|
||||
390
internal/managementasset/updater.go
Normal file
390
internal/managementasset/updater.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package managementasset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
|
||||
managementAssetName = "management.html"
|
||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||
updateCheckInterval = 3 * time.Hour
|
||||
)
|
||||
|
||||
// ManagementFileName exposes the control panel asset filename.
|
||||
const ManagementFileName = managementAssetName
|
||||
|
||||
var (
|
||||
lastUpdateCheckMu sync.Mutex
|
||||
lastUpdateCheckTime time.Time
|
||||
|
||||
currentConfigPtr atomic.Pointer[config.Config]
|
||||
disableControlPanel atomic.Bool
|
||||
schedulerOnce sync.Once
|
||||
schedulerConfigPath atomic.Value
|
||||
)
|
||||
|
||||
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
|
||||
func SetCurrentConfig(cfg *config.Config) {
|
||||
if cfg == nil {
|
||||
currentConfigPtr.Store(nil)
|
||||
return
|
||||
}
|
||||
|
||||
prevDisabled := disableControlPanel.Load()
|
||||
currentConfigPtr.Store(cfg)
|
||||
disableControlPanel.Store(cfg.RemoteManagement.DisableControlPanel)
|
||||
|
||||
if prevDisabled && !cfg.RemoteManagement.DisableControlPanel {
|
||||
lastUpdateCheckMu.Lock()
|
||||
lastUpdateCheckTime = time.Time{}
|
||||
lastUpdateCheckMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date.
|
||||
// It respects the disable-control-panel flag on every iteration and supports hot-reloaded configurations.
|
||||
func StartAutoUpdater(ctx context.Context, configFilePath string) {
|
||||
configFilePath = strings.TrimSpace(configFilePath)
|
||||
if configFilePath == "" {
|
||||
log.Debug("management asset auto-updater skipped: empty config path")
|
||||
return
|
||||
}
|
||||
|
||||
schedulerConfigPath.Store(configFilePath)
|
||||
|
||||
schedulerOnce.Do(func() {
|
||||
go runAutoUpdater(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func runAutoUpdater(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(updateCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runOnce := func() {
|
||||
cfg := currentConfigPtr.Load()
|
||||
if cfg == nil {
|
||||
log.Debug("management asset auto-updater skipped: config not yet available")
|
||||
return
|
||||
}
|
||||
if disableControlPanel.Load() {
|
||||
log.Debug("management asset auto-updater skipped: control panel disabled")
|
||||
return
|
||||
}
|
||||
|
||||
configPath, _ := schedulerConfigPath.Load().(string)
|
||||
staticDir := StaticDir(configPath)
|
||||
EnsureLatestManagementHTML(ctx, staticDir, cfg.ProxyURL)
|
||||
}
|
||||
|
||||
runOnce()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPClient(proxyURL string) *http.Client {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}
|
||||
util.SetProxy(sdkCfg, client)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
type releaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type releaseResponse struct {
|
||||
Assets []releaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// StaticDir resolves the directory that stores the management control panel asset.
|
||||
func StaticDir(configFilePath string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("MANAGEMENT_STATIC_PATH")); override != "" {
|
||||
cleaned := filepath.Clean(override)
|
||||
if strings.EqualFold(filepath.Base(cleaned), managementAssetName) {
|
||||
return filepath.Dir(cleaned)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
if writable := util.WritablePath(); writable != "" {
|
||||
return filepath.Join(writable, "static")
|
||||
}
|
||||
|
||||
configFilePath = strings.TrimSpace(configFilePath)
|
||||
if configFilePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
base := filepath.Dir(configFilePath)
|
||||
fileInfo, err := os.Stat(configFilePath)
|
||||
if err == nil {
|
||||
if fileInfo.IsDir() {
|
||||
base = configFilePath
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(base, "static")
|
||||
}
|
||||
|
||||
// FilePath resolves the absolute path to the management control panel asset.
|
||||
func FilePath(configFilePath string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("MANAGEMENT_STATIC_PATH")); override != "" {
|
||||
cleaned := filepath.Clean(override)
|
||||
if strings.EqualFold(filepath.Base(cleaned), managementAssetName) {
|
||||
return cleaned
|
||||
}
|
||||
return filepath.Join(cleaned, ManagementFileName)
|
||||
}
|
||||
|
||||
dir := StaticDir(configFilePath)
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, ManagementFileName)
|
||||
}
|
||||
|
||||
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
||||
// The function is designed to run in a background goroutine and will never panic.
|
||||
// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes.
|
||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
if disableControlPanel.Load() {
|
||||
log.Debug("management asset sync skipped: control panel disabled by configuration")
|
||||
return
|
||||
}
|
||||
|
||||
staticDir = strings.TrimSpace(staticDir)
|
||||
if staticDir == "" {
|
||||
log.Debug("management asset sync skipped: empty static directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limiting: check only once every 3 hours
|
||||
lastUpdateCheckMu.Lock()
|
||||
now := time.Now()
|
||||
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
|
||||
if timeSinceLastCheck < updateCheckInterval {
|
||||
lastUpdateCheckMu.Unlock()
|
||||
log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval)
|
||||
return
|
||||
}
|
||||
lastUpdateCheckTime = now
|
||||
lastUpdateCheckMu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(staticDir, 0o755); err != nil {
|
||||
log.WithError(err).Warn("failed to prepare static directory for management asset")
|
||||
return
|
||||
}
|
||||
|
||||
client := newHTTPClient(proxyURL)
|
||||
|
||||
localPath := filepath.Join(staticDir, managementAssetName)
|
||||
localHash, err := fileSHA256(localPath)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.WithError(err).Debug("failed to read local management asset hash")
|
||||
}
|
||||
localHash = ""
|
||||
}
|
||||
|
||||
asset, remoteHash, err := fetchLatestAsset(ctx, client)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("failed to fetch latest management release information")
|
||||
return
|
||||
}
|
||||
|
||||
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||
log.Debug("management asset is already up to date")
|
||||
return
|
||||
}
|
||||
|
||||
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("failed to download management asset")
|
||||
return
|
||||
}
|
||||
|
||||
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
||||
}
|
||||
|
||||
if err = atomicWriteFile(localPath, data); err != nil {
|
||||
log.WithError(err).Warn("failed to update management asset on disk")
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
||||
}
|
||||
|
||||
func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create release request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", httpUserAgent)
|
||||
gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL")))
|
||||
if tok := strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")); tok != "" && strings.Contains(gitURL, "github.com") {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("execute release request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var release releaseResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, "", fmt.Errorf("decode release response: %w", err)
|
||||
}
|
||||
|
||||
for i := range release.Assets {
|
||||
asset := &release.Assets[i]
|
||||
if strings.EqualFold(asset.Name, managementAssetName) {
|
||||
remoteHash := parseDigest(asset.Digest)
|
||||
return asset, remoteHash, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName)
|
||||
}
|
||||
|
||||
func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) {
|
||||
if strings.TrimSpace(downloadURL) == "" {
|
||||
return nil, "", fmt.Errorf("empty download url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", httpUserAgent)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("execute download request: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read download body: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
return data, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err = io.Copy(h, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpName := tmpFile.Name()
|
||||
defer func() {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
}()
|
||||
|
||||
if _, err = tmpFile.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tmpFile.Chmod(0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = os.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDigest(digest string) string {
|
||||
digest = strings.TrimSpace(digest)
|
||||
if digest == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if idx := strings.Index(digest, ":"); idx >= 0 {
|
||||
digest = digest[idx+1:]
|
||||
}
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(digest))
|
||||
}
|
||||
13
internal/misc/claude_code_instructions.go
Normal file
13
internal/misc/claude_code_instructions.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
|
||||
// This package contains general-purpose helpers and embedded resources that do not fit into
|
||||
// more specific domain packages. It includes embedded instructional text for Claude Code-related operations.
|
||||
package misc
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// ClaudeCodeInstructions holds the content of the claude_code_instructions.txt file,
|
||||
// which is embedded into the application binary at compile time. This variable
|
||||
// contains specific instructions for Claude Code model interactions and code generation guidance.
|
||||
//
|
||||
//go:embed claude_code_instructions.txt
|
||||
var ClaudeCodeInstructions string
|
||||
1
internal/misc/claude_code_instructions.txt
Normal file
1
internal/misc/claude_code_instructions.txt
Normal file
@@ -0,0 +1 @@
|
||||
[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user