Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5485f368b2 | ||
|
|
f8351e9e33 | ||
|
|
2031c1d5ad | ||
|
|
8193b80a59 | ||
|
|
f6d580200a | ||
|
|
b491c3271b | ||
|
|
d106b3b5a0 | ||
|
|
10fe591e54 | ||
|
|
66cd8a72bc | ||
|
|
dc46daa690 | ||
|
|
65246e69d7 | ||
|
|
de69bf1e03 | ||
|
|
eba85dc478 | ||
|
|
abf70750fd | ||
|
|
88b697d5df | ||
|
|
3eb5a84ea1 | ||
|
|
71e5a4baf7 | ||
|
|
4bfcbd0c78 | ||
|
|
2b1e5e7f5c | ||
|
|
cbb4b7e53e | ||
|
|
81c0f11d7a | ||
|
|
561fc1c3e4 | ||
|
|
43491f67a1 | ||
|
|
c05bc4d3cc | ||
|
|
b9ebc8f209 | ||
|
|
f7c48ba59a | ||
|
|
22c16d0517 | ||
|
|
2cc1e7ef90 | ||
|
|
81ff9384cb | ||
|
|
0aaed397cc | ||
|
|
909b8c7ef4 | ||
|
|
b8ff74ad70 | ||
|
|
96cf8bd76a | ||
|
|
1f716aa181 | ||
|
|
111c41cf3f | ||
|
|
787a6a33df | ||
|
|
f8e1e7fed6 | ||
|
|
f09a5b602e | ||
|
|
027e3f2d54 | ||
|
|
8116d235ef | ||
|
|
959cbe3d87 | ||
|
|
c0420e2b8a | ||
|
|
9578ddbddd | ||
|
|
e274de45b7 | ||
|
|
ce328fb37a | ||
|
|
8f7f2b5d92 | ||
|
|
3efeba33a4 | ||
|
|
ce157b32ed | ||
|
|
3cb2381bdc | ||
|
|
a7d2c782df | ||
|
|
a3aa9bae6d | ||
|
|
b939fa9d4a | ||
|
|
d03c42d798 | ||
|
|
6aabbdf547 | ||
|
|
ad4b2f30f5 | ||
|
|
ff758f37d2 | ||
|
|
2e31d8b702 | ||
|
|
8c8fd6e298 | ||
|
|
f62ec2e4a0 | ||
|
|
415a0dc9d0 | ||
|
|
b59f3bf46c | ||
|
|
2db78bd3d6 | ||
|
|
f88d62ad28 | ||
|
|
8363d537e0 | ||
|
|
b8ed245085 | ||
|
|
dc6b318c34 | ||
|
|
96884dd1ae | ||
|
|
0b061c5fa1 | ||
|
|
958a21ad6e | ||
|
|
28167bbf85 | ||
|
|
985428d9d2 | ||
|
|
4a4fd35e0c | ||
|
|
f46038820b | ||
|
|
05ad6b49a1 | ||
|
|
a3675552bd | ||
|
|
b3ad9b67cf | ||
|
|
f49a393566 | ||
|
|
8a46947865 | ||
|
|
7ae0312091 | ||
|
|
58e35a4f32 | ||
|
|
95023e7d49 | ||
|
|
7a70de401c | ||
|
|
666a5c6f87 | ||
|
|
c7343d2aa2 | ||
|
|
a6430bfe20 | ||
|
|
32bf3d0f33 | ||
|
|
cb31033972 | ||
|
|
3aeab296d8 | ||
|
|
f3bbb95310 | ||
|
|
00eabe8ab9 | ||
|
|
a16070ccf6 | ||
|
|
ab99d56e96 | ||
|
|
b1ce821c49 | ||
|
|
578785ee7f | ||
|
|
ed7c8f2208 | ||
|
|
2e1c7c00a6 | ||
|
|
994ca26e92 | ||
|
|
d2f87c612e | ||
|
|
62cd63bfda | ||
|
|
dee35a841d | ||
|
|
fb44e2dd09 | ||
|
|
c42d78e26c | ||
|
|
2dc287f2e0 | ||
|
|
83094abf21 | ||
|
|
aafe93b335 | ||
|
|
cc52c93ad7 | ||
|
|
d7f048a265 | ||
|
|
55c5eed9fc | ||
|
|
0d9e7e1530 | ||
|
|
9b4fcfb897 | ||
|
|
cff3f89850 | ||
|
|
9517f12cdd | ||
|
|
40f4d450be | ||
|
|
620eb3fd00 | ||
|
|
c6ff69fd12 | ||
|
|
7b271bbee9 | ||
|
|
68f099707e | ||
|
|
3bdb869fca | ||
|
|
c227863d51 | ||
|
|
3a8c9f16de | ||
|
|
2da31290a1 | ||
|
|
3c53ba01a7 | ||
|
|
07bf8b92fd | ||
|
|
63dc24b5e8 | ||
|
|
8f1d6af3ed | ||
|
|
85f431a072 | ||
|
|
d7fb9b5d35 | ||
|
|
eba6ea5639 | ||
|
|
2ad6e8fb75 | ||
|
|
570bd084c2 | ||
|
|
15278fe2f9 | ||
|
|
c4e6dcfd80 | ||
|
|
701f844755 | ||
|
|
91cf660377 | ||
|
|
78e1aaedb9 | ||
|
|
708b85a6a4 | ||
|
|
1cab4d8c22 | ||
|
|
d8b9a83f8c | ||
|
|
a016bd4ba5 | ||
|
|
983a3db3af | ||
|
|
7969751f1f | ||
|
|
6271c517da | ||
|
|
4aaf5cb73b | ||
|
|
e9bad40b10 | ||
|
|
1e28ef5942 | ||
|
|
28f673e4cd | ||
|
|
9ddf69698d | ||
|
|
51631d0324 | ||
|
|
c7538cd256 | ||
|
|
6cf645f87c | ||
|
|
aa556c372f | ||
|
|
126ac24c8c | ||
|
|
3c8be00131 | ||
|
|
f5ae32e53f | ||
|
|
a3a6a85b73 | ||
|
|
db15e0090a | ||
|
|
994192ef4f | ||
|
|
3e60c3b86b | ||
|
|
de27905e88 | ||
|
|
28dde982cb | ||
|
|
de8d756afa | ||
|
|
b1748c4ea9 | ||
|
|
432ebc498c | ||
|
|
abc07bb079 | ||
|
|
78fc7b171e | ||
|
|
4c1f920d58 | ||
|
|
e9e3c46cb7 | ||
|
|
62198286e9 | ||
|
|
af90a9bd1f | ||
|
|
5e4958c889 | ||
|
|
fdc2ec17e6 | ||
|
|
b6740c937f | ||
|
|
10c8f523c4 | ||
|
|
8249ab37db | ||
|
|
9be9c0b68c | ||
|
|
a7f1ed8fa4 | ||
|
|
9ad3865690 | ||
|
|
5097b31457 | ||
|
|
aa23648835 | ||
|
|
a774230661 | ||
|
|
222181efd6 | ||
|
|
1a33dc8cc4 | ||
|
|
3510aa22b5 | ||
|
|
77729e2723 | ||
|
|
05ea883e26 | ||
|
|
6566039bc8 | ||
|
|
e50bffb7e3 | ||
|
|
f2804eb2c8 | ||
|
|
559960865a | ||
|
|
a4fb58e428 | ||
|
|
a668aa8b36 | ||
|
|
8d0b5ede28 | ||
|
|
2786ae9de6 | ||
|
|
34b8bf1a6e | ||
|
|
52c7d88340 | ||
|
|
9e2d97f250 | ||
|
|
bfe0a5a873 | ||
|
|
504919a054 | ||
|
|
0d27f45ead | ||
|
|
65380be9c8 | ||
|
|
d596d0844f | ||
|
|
cd04848b97 | ||
|
|
e377c4358c | ||
|
|
3438580213 | ||
|
|
e93e6368fc | ||
|
|
200b1406d7 | ||
|
|
421a83282a | ||
|
|
a2fb09260a | ||
|
|
aa7a811e66 | ||
|
|
2ac784c51a | ||
|
|
6b39d0b453 | ||
|
|
f218c8d40a | ||
|
|
1cce2b38ee | ||
|
|
9d9ed1cc62 | ||
|
|
2d7767eaf2 | ||
|
|
064d9b74e8 | ||
|
|
e87c47c957 | ||
|
|
bdedd6c544 | ||
|
|
5fb5dfb34c | ||
|
|
9908d3ca2f | ||
|
|
4df6b0636b | ||
|
|
498e06c582 | ||
|
|
e6722126ba | ||
|
|
e619ca386e | ||
|
|
8224434d21 | ||
|
|
fd55f3248f | ||
|
|
08d6c153b7 | ||
|
|
636d6be690 | ||
|
|
7dcf728461 | ||
|
|
fb409787c6 | ||
|
|
e7caedc22a | ||
|
|
eebf4e3ffb | ||
|
|
c7cf84ad39 | ||
|
|
0681aa2566 | ||
|
|
17747df842 | ||
|
|
3fbce499d8 | ||
|
|
1a40e5690f | ||
|
|
7903716ad1 | ||
|
|
f8eae45048 | ||
|
|
a44a1019f8 | ||
|
|
9fba8ed43b | ||
|
|
7f5e915dd1 | ||
|
|
7e06816571 | ||
|
|
e9e66da5a8 | ||
|
|
94bb9354ce | ||
|
|
95982a508f | ||
|
|
563979e91e | ||
|
|
1cb28fd429 | ||
|
|
aa679efe41 | ||
|
|
e2cfaa46e7 | ||
|
|
593fc93659 | ||
|
|
530fb20f25 | ||
|
|
200602b41b | ||
|
|
e2624fb65c | ||
|
|
cc6a8e5174 | ||
|
|
6d5f027848 | ||
|
|
e21b849a92 | ||
|
|
ba345f1052 | ||
|
|
c74a4f521c | ||
|
|
7c01ec9be8 | ||
|
|
52374af940 | ||
|
|
b6a7b8b7df | ||
|
|
1dc0a09009 | ||
|
|
e98ef32eb2 | ||
|
|
863667ce67 | ||
|
|
8f5451587e | ||
|
|
85b593fd2f | ||
|
|
0fcec63b68 | ||
|
|
8553c91c1a | ||
|
|
c66e700f05 | ||
|
|
fdf9751c12 | ||
|
|
1be69e56aa | ||
|
|
e7c029875c | ||
|
|
0678b24d58 | ||
|
|
b369fbca15 | ||
|
|
36ee193e3c | ||
|
|
1dd0f2e5dc | ||
|
|
f34735f462 | ||
|
|
a2d7d41185 | ||
|
|
03ab687686 | ||
|
|
2e756b71f2 | ||
|
|
fbc69322af | ||
|
|
414489d152 | ||
|
|
50a9dd7a64 | ||
|
|
a4156eea10 | ||
|
|
e0ecb55d4b | ||
|
|
897faea333 | ||
|
|
0e08c39999 | ||
|
|
ca9b4d73f2 | ||
|
|
94edcd5d81 | ||
|
|
3673702af5 | ||
|
|
5778739cdb | ||
|
|
81e461f651 | ||
|
|
b7aca87fd0 | ||
|
|
ae1bcdc25d | ||
|
|
263605779c | ||
|
|
9eabd2a5b7 | ||
|
|
d66bc88e68 | ||
|
|
8177f27aa3 | ||
|
|
d5a0124637 | ||
|
|
30b29e51e8 | ||
|
|
9f1effd71c | ||
|
|
6532d5b61e | ||
|
|
57c76584a6 | ||
|
|
82105f260a | ||
|
|
50ad6e2e50 | ||
|
|
f323d84644 | ||
|
|
3a9070359b | ||
|
|
d39fc491ec | ||
|
|
2b8cb628b6 | ||
|
|
8b4b3f4a94 | ||
|
|
624e4eef64 | ||
|
|
a3bbf33c05 | ||
|
|
7c616db07a | ||
|
|
0aef702b66 | ||
|
|
b5827cfd54 | ||
|
|
ea4291a92c | ||
|
|
32ac921f24 | ||
|
|
ed12ea7163 | ||
|
|
eb4237a2b0 | ||
|
|
706b5676a6 | ||
|
|
c31fe2ed9e | ||
|
|
987588eaa1 | ||
|
|
804679d996 | ||
|
|
571d1e1d9b | ||
|
|
3c22e78af0 | ||
|
|
5d9c7abed5 | ||
|
|
ac18c588c8 | ||
|
|
b83284071a | ||
|
|
fd13c72e95 | ||
|
|
3c2e9a43b9 | ||
|
|
ca319d09f2 | ||
|
|
46598dd491 | ||
|
|
3045b3ade1 | ||
|
|
604360d1cc | ||
|
|
25dc5742ea | ||
|
|
1136e290fd | ||
|
|
6506c0ea68 | ||
|
|
1323d1645d | ||
|
|
f1d845c630 | ||
|
|
5688bcba91 | ||
|
|
1a61eb7151 | ||
|
|
1cc6999ada | ||
|
|
88cb79d8b3 | ||
|
|
32ad1dcdce | ||
|
|
c037766040 | ||
|
|
da10822d7d | ||
|
|
5345881b1d | ||
|
|
c5ff640dff | ||
|
|
02eee39ddf | ||
|
|
e652f6e20a | ||
|
|
c4e46c3be8 | ||
|
|
0756c98102 | ||
|
|
7b693ae429 | ||
|
|
d4754c211a | ||
|
|
423f895755 | ||
|
|
c8a6e93f33 | ||
|
|
446042e518 | ||
|
|
1e6926cb9c | ||
|
|
b4b5371640 | ||
|
|
e866ff6ec4 | ||
|
|
c83dced4ba | ||
|
|
d674a04f2a | ||
|
|
9b61c1bdd9 | ||
|
|
add86cc3bc | ||
|
|
dc332d141c | ||
|
|
59bb131df4 | ||
|
|
443537daae | ||
|
|
b588c0bc42 | ||
|
|
4e9c1d7c8b | ||
|
|
a0b313018a | ||
|
|
fafc4d288d | ||
|
|
de084cdfc2 | ||
|
|
7eed2d3b7d | ||
|
|
f7dca22ea5 | ||
|
|
5eb8628ff2 | ||
|
|
002aaaa3d0 | ||
|
|
0f6ad105fe | ||
|
|
28fa458dc7 | ||
|
|
eca8d6b9ab | ||
|
|
81ac8ccd64 | ||
|
|
d24de611f8 | ||
|
|
f83fe6435b | ||
|
|
ee2c8b97bb | ||
|
|
d36f4dd6c6 | ||
|
|
1e8745c887 | ||
|
|
96e9ab8662 | ||
|
|
0eddfe1cfa | ||
|
|
acd2f09ddd | ||
|
|
5929cdd38c | ||
|
|
60756c0ca4 | ||
|
|
b56c61eccd | ||
|
|
f4e3eb1c5a | ||
|
|
fd872550d5 | ||
|
|
f4820c00dc | ||
|
|
de6ef7134d | ||
|
|
e6ea7b9af0 | ||
|
|
9e7aff59df | ||
|
|
00efee4303 | ||
|
|
00b7c855cc | ||
|
|
4a02e54171 | ||
|
|
9192426d2e | ||
|
|
d077002eba | ||
|
|
5f09442fce | ||
|
|
44003d7f6b | ||
|
|
d881bfc0de | ||
|
|
6ff771d3d3 | ||
|
|
efb46db0a9 | ||
|
|
8ba81615e0 | ||
|
|
50ba146298 | ||
|
|
b9083fc7ad | ||
|
|
1bf76d26d7 | ||
|
|
2abce65031 | ||
|
|
6ca1e15b0a | ||
|
|
195b219707 | ||
|
|
66d664ff08 | ||
|
|
4795650d21 | ||
|
|
d522aed65e | ||
|
|
7634e3c23b | ||
|
|
d8694f03bc | ||
|
|
e8cb019fab | ||
|
|
7a7bd3729c | ||
|
|
49ad7021d7 | ||
|
|
4952b919a4 | ||
|
|
fa0685a4a3 | ||
|
|
8040428716 | ||
|
|
4ff136cfd9 | ||
|
|
832cf4df65 | ||
|
|
77b6c5bb0c | ||
|
|
f1aadd48d5 | ||
|
|
5934981e22 | ||
|
|
db01fb98f8 | ||
|
|
3802268e2d | ||
|
|
8e60d57475 | ||
|
|
32064d39e7 | ||
|
|
92a550973a | ||
|
|
b20f29d115 | ||
|
|
a8cfe746b3 | ||
|
|
5705f4a4aa | ||
|
|
11ada80d58 | ||
|
|
1a9898a6a4 | ||
|
|
aa2c4e8d13 | ||
|
|
5a7d469622 | ||
|
|
095b525360 | ||
|
|
027d442f9b | ||
|
|
810d50a00f | ||
|
|
41d712be09 | ||
|
|
3c714d8736 | ||
|
|
636d7debf9 | ||
|
|
3dd717ab0d | ||
|
|
1dc1b44268 | ||
|
|
4ca86c52e3 | ||
|
|
3f1581b2d3 | ||
|
|
e55727d4c8 | ||
|
|
b7ec3cdadf | ||
|
|
3b7753a8e4 | ||
|
|
d5e43ef7d1 | ||
|
|
d6012b0b35 | ||
|
|
53d76ad800 | ||
|
|
4347e5555b | ||
|
|
329b92ef04 | ||
|
|
e2d9b823f6 | ||
|
|
eb594eefc9 | ||
|
|
3af5443b22 | ||
|
|
7ba3e1ac52 | ||
|
|
9fb260439f | ||
|
|
07931bbb18 | ||
|
|
2307aa5ca7 | ||
|
|
1563db3f82 | ||
|
|
e440ac48c7 | ||
|
|
71aa6e3574 | ||
|
|
99ec347eaf | ||
|
|
03e8ab4134 | ||
|
|
c40edb4070 | ||
|
|
7bb9c0ed7d | ||
|
|
5f43696a9a | ||
|
|
847c0c4526 | ||
|
|
4a02d80543 | ||
|
|
bd17411d0e | ||
|
|
c4ae3550dd | ||
|
|
2f43e72bb9 | ||
|
|
b8651947c3 | ||
|
|
96b160d9a0 | ||
|
|
6f972145b7 | ||
|
|
695ebede70 | ||
|
|
269a409241 | ||
|
|
73aabc861c | ||
|
|
3b81776429 | ||
|
|
cbe258fd12 | ||
|
|
1591477a3e | ||
|
|
e457f0fa22 | ||
|
|
c0c3e1cb47 | ||
|
|
f06bf181d2 | ||
|
|
7075ae0d82 | ||
|
|
a177b88e52 | ||
|
|
cfecc8d113 | ||
|
|
c416467882 | ||
|
|
f19095f96d | ||
|
|
625f1693cb | ||
|
|
f25f399be0 | ||
|
|
748b84a15a | ||
|
|
fa42c3d706 | ||
|
|
fb01f5df2c | ||
|
|
8238956799 | ||
|
|
e00a8abdd5 | ||
|
|
a52665d079 | ||
|
|
ba5b861ec0 | ||
|
|
817fd1214c | ||
|
|
41f6e63799 | ||
|
|
b07d85c497 | ||
|
|
bc4a9ae432 | ||
|
|
cc894ef578 | ||
|
|
83d4eb6a43 | ||
|
|
ecce0769c0 | ||
|
|
377d1230fc | ||
|
|
6466978d2f | ||
|
|
3b8a148bcf | ||
|
|
3e45d4e030 | ||
|
|
f82d32207f | ||
|
|
40aaf7b3ae | ||
|
|
391d69e086 | ||
|
|
684d9efb1f | ||
|
|
26ac0498f2 | ||
|
|
40ea3aebad | ||
|
|
3fd66a33be | ||
|
|
4a1da29509 | ||
|
|
243921dc0a | ||
|
|
616df479e8 | ||
|
|
19da0f240e | ||
|
|
75faedda95 | ||
|
|
fc589bce17 | ||
|
|
4eadee1074 | ||
|
|
31d5b67baa | ||
|
|
49752060fc | ||
|
|
913ec5d74b | ||
|
|
a6b11ccced | ||
|
|
77e0409f7c | ||
|
|
6a1efda0cc | ||
|
|
7498d8fdc8 | ||
|
|
cd05772e91 | ||
|
|
7a09a2eaf7 | ||
|
|
d493ce0bb2 | ||
|
|
e0e5e45cd2 | ||
|
|
e39fce270d | ||
|
|
5dfad32b87 | ||
|
|
ee62dfbaf1 | ||
|
|
5a2f542ab0 | ||
|
|
b16ed03cad | ||
|
|
3e98a5a1ad | ||
|
|
e471be7302 | ||
|
|
22e1b8144a | ||
|
|
d45135d87d | ||
|
|
d29d7f8cbc | ||
|
|
fd6cd474d6 | ||
|
|
73f6a590bf | ||
|
|
ffc01f9c66 | ||
|
|
6244090c1c | ||
|
|
844b118785 | ||
|
|
7c64a48dc2 | ||
|
|
86470628df | ||
|
|
fa4587041c | ||
|
|
cb8dabc12f | ||
|
|
b30651a0ad | ||
|
|
e2263d2565 | ||
|
|
cef18b4ad3 | ||
|
|
30ae15268f | ||
|
|
10081a8127 | ||
|
|
9f2f2738fd | ||
|
|
67380eafd5 | ||
|
|
a53150a7cf | ||
|
|
2ddda67354 | ||
|
|
40c26f343a | ||
|
|
d9d2854d27 | ||
|
|
219368ec6d | ||
|
|
fb56bcbd07 | ||
|
|
beaeaaed06 | ||
|
|
ccfced36ae | ||
|
|
77d8516e87 | ||
|
|
afa21336a5 | ||
|
|
5fc9778468 | ||
|
|
3445485ebc | ||
|
|
e45490a360 | ||
|
|
68653fb2cc | ||
|
|
f646a1f974 | ||
|
|
fd24923f61 | ||
|
|
b2d5ce0395 | ||
|
|
f96035462f | ||
|
|
4eb7630ab5 | ||
|
|
6816c63883 | ||
|
|
9f74eb57f4 | ||
|
|
98d60931c1 | ||
|
|
c2572535d8 | ||
|
|
9d5171c5d1 | ||
|
|
f6aa9dc3c8 | ||
|
|
a749673de3 | ||
|
|
2e279c81d2 | ||
|
|
d5824f6a5b | ||
|
|
1429e8f714 | ||
|
|
b2b2adcf8d | ||
|
|
3548256745 | ||
|
|
16b15e870d | ||
|
|
7e9af4c100 | ||
|
|
7c53a842e4 | ||
|
|
4033387fd4 | ||
|
|
f98a056f75 | ||
|
|
1bff06ea50 | ||
|
|
2e68b10aad | ||
|
|
39aaf2fea3 | ||
|
|
d78e366db5 | ||
|
|
cca235365b | ||
|
|
c628b3eea7 | ||
|
|
f7bc1970ef | ||
|
|
0b1f79c83c | ||
|
|
2ab24a1e77 | ||
|
|
ab9cb69e7e | ||
|
|
5ae254f771 | ||
|
|
17c0ee3859 | ||
|
|
ad87ddee12 | ||
|
|
8db8791a73 | ||
|
|
e62ec2c42f | ||
|
|
6c3edc0956 | ||
|
|
726de30101 | ||
|
|
2074fe3ba8 | ||
|
|
66808413df | ||
|
|
25da02dddd | ||
|
|
3243983b72 | ||
|
|
f7145c7508 | ||
|
|
0e05c61d36 | ||
|
|
01a0083948 | ||
|
|
b27cd76a85 | ||
|
|
b0b2734b0b | ||
|
|
f75c4161f2 | ||
|
|
e85348be80 | ||
|
|
61c2f5ad28 | ||
|
|
611a7c8ed3 | ||
|
|
ad26172b83 | ||
|
|
39485f2e28 | ||
|
|
787f875dd6 | ||
|
|
ed4207a16f | ||
|
|
5ade094318 | ||
|
|
953028718d | ||
|
|
2ab41b92b6 | ||
|
|
0117452551 | ||
|
|
48615d5d1f | ||
|
|
ccb20ace88 | ||
|
|
2bc139ab52 | ||
|
|
c0e073a798 | ||
|
|
d1c6456d08 | ||
|
|
e4f869e0c7 | ||
|
|
c374ff2951 | ||
|
|
acce29af27 | ||
|
|
b1737cc841 | ||
|
|
cf3577a3c5 | ||
|
|
fbc9ee626d | ||
|
|
e2d9ce0cad | ||
|
|
b410c39133 | ||
|
|
b1e60ba453 | ||
|
|
98c7b0e88e | ||
|
|
1e1798d90d | ||
|
|
bafd2e293f | ||
|
|
21c07e8351 | ||
|
|
3c5553e253 | ||
|
|
901a77cfb1 | ||
|
|
e7a3c1202e | ||
|
|
4d4a0be1ad |
@@ -49,6 +49,9 @@ coverage/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。
|
||||
/plugins/
|
||||
|
||||
# Local config
|
||||
config.yaml
|
||||
config.local.yaml
|
||||
|
||||
@@ -35,3 +35,10 @@ exceptions:
|
||||
mitigation: "Proxy configuration not user-controlled; upgrade when axios releases fix"
|
||||
expires_on: "2026-07-10"
|
||||
owner: "security@your-domain"
|
||||
- package: nanoid
|
||||
advisory: "GHSA-2v37-7h3g-55p8"
|
||||
severity: high
|
||||
reason: "Custom generator with size=0 not used; all nanoid calls use default size or positive values"
|
||||
mitigation: "No zero-size custom generators in codebase; upgrade when nanoid releases fix"
|
||||
expires_on: "2026-10-13"
|
||||
owner: "security@your-domain"
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
/bin/bash -n deploy/apple-container.sh
|
||||
/bin/bash deploy/tests/apple-container-test.sh
|
||||
/bin/sh deploy/tests/docker-compose-security-test.sh
|
||||
/bin/sh deploy/tests/docker-compose-gateway-env-test.sh
|
||||
/bin/sh deploy/tests/docker-runtime-resources-test.sh
|
||||
/bin/sh deploy/test-caddyfile-cache.sh
|
||||
|
||||
@@ -32,7 +33,7 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.26.6'
|
||||
go version | grep -q 'go1.27.0'
|
||||
- name: Unit tests
|
||||
working-directory: backend
|
||||
run: make test-unit
|
||||
@@ -72,10 +73,10 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.26.6'
|
||||
go version | grep -q 'go1.27.0'
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.9
|
||||
version: v2.13
|
||||
args: --timeout=30m
|
||||
working-directory: backend
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.26.6'
|
||||
go version | grep -q 'go1.27.0'
|
||||
|
||||
# Docker setup for GoReleaser
|
||||
- name: Set up QEMU
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.26.6'
|
||||
go version | grep -q 'go1.27.0'
|
||||
- name: Run govulncheck
|
||||
working-directory: backend
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
docs/claude-relay-service/
|
||||
# 本地杂项文档、测试数据和外部项目副本
|
||||
/docs-local/
|
||||
.codex
|
||||
|
||||
# ===================
|
||||
@@ -136,13 +137,23 @@ docs/*
|
||||
!docs/PAYMENT_CN.md
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
!docs/ASYNC_IMAGE_TASKS.md
|
||||
!docs/BATCH_IMAGE_MVP.md
|
||||
!docs/COMPOSITE_GROUPS.md
|
||||
!docs/PLUGIN_DEVELOPMENT.md
|
||||
!docs/channel-monitor-v2-safe-defaults.md
|
||||
!docs/legal/
|
||||
!docs/legal/*.md
|
||||
!docs/screenshots/
|
||||
docs/screenshots/*
|
||||
!docs/screenshots/mobile-account-actions-menu.png
|
||||
.serena/
|
||||
.codex/
|
||||
frontend/coverage/
|
||||
aicodex
|
||||
output/
|
||||
.codegraph/
|
||||
|
||||
# Vitest / Vite cache at repo root
|
||||
.vite/
|
||||
# 本地闭源插件开发目录及构建产物不进入 Sub2API 仓库
|
||||
/plugins/
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
### 开发工具
|
||||
|
||||
```bash
|
||||
# golangci-lint(CI 用 v2.9,本地建议装同一版以免版本差异带来的噪音)
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9
|
||||
# golangci-lint(CI 用 v2.13,本地建议装同一版以免版本差异带来的噪音)
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13
|
||||
|
||||
# pnpm (前端包管理)
|
||||
npm install -g pnpm
|
||||
@@ -47,13 +47,13 @@ npm install -g pnpm
|
||||
|
||||
| Workflow | 触发条件 | 检查内容 |
|
||||
|----------|----------|----------|
|
||||
| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.9 |
|
||||
| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.13 |
|
||||
| **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit |
|
||||
| **release.yml** | tag `v*` | 构建发布(PR 不触发) |
|
||||
|
||||
### CI 要求
|
||||
|
||||
- Go 版本必须是 **1.26.6**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.6'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。
|
||||
- Go 版本必须是 **1.27.0**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.0'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。
|
||||
- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml`
|
||||
|
||||
### 本地测试命令
|
||||
@@ -203,7 +203,7 @@ go test -tags=integration ./...
|
||||
**解决**:
|
||||
```bash
|
||||
cd backend
|
||||
go generate ./ent # 重新生成 ent 代码
|
||||
go generate ./ent # 重新生成 ent 代码(json.RawMessage 字段会生成为同类型的 jsontext.Value,属预期)
|
||||
git add ent/ # 生成的文件也要提交
|
||||
```
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_IMAGE=node:24-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.26.6-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.27.0-alpine
|
||||
ARG ALPINE_IMAGE=alpine:3.21
|
||||
ARG POSTGRES_IMAGE=postgres:18-alpine
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
|
||||
|
||||
FRONTEND_CRITICAL_VITEST := \
|
||||
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
|
||||
src/api/__tests__/client.spec.ts \
|
||||
src/api/__tests__/tokenRefresh.spec.ts \
|
||||
src/api/__tests__/channelMonitorV2.spec.ts \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# Sub2API
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
@@ -58,6 +58,11 @@ Please read the following carefully before using this project:
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
|
||||
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
|
||||
@@ -91,12 +96,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
|
||||
@@ -117,28 +116,11 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://www.novada.com/?sub2api/">Novada</a> for sponsoring this project! Novada provides residential, ISP, datacenter, and mobile proxies, along with Web Unlocker and Scraper APIs for developers building AI applications and automation workflows. With global IP coverage, flexible rotating and sticky sessions, and precise geo-targeting, Novada helps teams access web data reliably for AI agent workflows, cross-region testing, web research, and browser automation. Explore Novada to build more stable and scalable AI workflows.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
|
||||
<td>Thanks to Qiniu AI for sponsoring this project! Qiniu AI is the enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK), offering one-stop access to 150+ mainstream models worldwide, compatible with the protocols of major global model providers, and covering full-modality capabilities including text, image, audio, video, and file processing, serving over 1.69 million enterprises and developers. Qiniu AI offers an exclusive benefit for Sub2API users: register via <a href="https://s.qiniu.com/u6rQrq">this link</a> — enterprise users get 12 million tokens free, and developers get 3 million tokens free.</td>
|
||||
@@ -174,6 +156,16 @@ Please read the following carefully before using this project:
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>Thanks to AxisNow for sponsoring this project! <a href="https://www.axisnow.io/">AxisNow</a> protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — <strong>self-hosted private-deployment CDN</strong> | <strong>subscription-based DDoS-protected CDN</strong> | <strong>autonomous, flexibly composable CDN network</strong>.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
@@ -206,7 +198,7 @@ Community projects that extend or integrate with Sub2API:
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Backend | Go 1.26.5, Gin, Ent |
|
||||
| Backend | Go 1.27.0, Gin, Ent |
|
||||
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| Database | PostgreSQL 15+ |
|
||||
| Cache/Queue | Redis 7+ |
|
||||
@@ -652,6 +644,32 @@ Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment.
|
||||
Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out
|
||||
or mitigating upstream WebSocket issues.
|
||||
|
||||
#### Force OpenAI upstream HTTP/SSE
|
||||
|
||||
When an egress proxy or network repeatedly reconnects OpenAI Responses
|
||||
WebSockets, set the global fallback in the persisted deployment configuration:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
openai_ws:
|
||||
force_http: true
|
||||
```
|
||||
|
||||
For Compose and Apple container deployments, the equivalent `.env` setting is:
|
||||
|
||||
```bash
|
||||
GATEWAY_OPENAI_WS_FORCE_HTTP=true
|
||||
```
|
||||
|
||||
This selects HTTP/SSE for OpenAI upstream Responses traffic that would
|
||||
otherwise use WebSocket. It does not change the client-facing protocol or force
|
||||
HTTP/1.1; configure `gateway.openai_http2.enabled` (or
|
||||
`GATEWAY_OPENAI_HTTP2_ENABLED=false`) separately when a proxy is incompatible
|
||||
with HTTP/2. Unlike the account-level `http_bridge` mode, this global fallback
|
||||
takes effect without enabling `mode_router_v2_enabled`. Keep the setting in the
|
||||
deployment's persisted `.env` or `config.yaml`, rather than inside a running
|
||||
container, so it is read again after an image update or container recreation.
|
||||
|
||||
#### ⚠️ Important: Creating the Admin Account
|
||||
|
||||
The initial admin account is **only created via the setup wizard** (served at `http://<host>:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons.
|
||||
@@ -730,7 +748,7 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard
|
||||
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
|
||||
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission.
|
||||
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
|
||||
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-image-2.0`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
|
||||
- JSON image-edit and video-generation requests accept image references in `image`, `images`, `reference_images`, and `mask` objects. Use `url` for xAI-compatible payloads; the legacy `image_url` field remains accepted and is normalized to `url` before forwarding.
|
||||
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
|
||||
|
||||
@@ -798,7 +816,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
|
||||
|
||||
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
|
||||
|
||||
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
|
||||
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
|
||||
|
||||
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# Sub2API
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
@@ -59,6 +59,11 @@
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
|
||||
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
|
||||
@@ -94,12 +99,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
|
||||
@@ -120,28 +119,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
|
||||
<td>感谢 <a href="https://www.novada.com/?sub2api/">Novada</a> 赞助本项目!Novada 为构建 AI 应用与自动化工作流的开发者提供住宅代理、ISP 代理、数据中心代理与移动代理,以及 Web Unlocker 和 Scraper API。凭借全球 IP 覆盖、灵活的轮换与粘性会话以及精准的地理定位,Novada 帮助团队在 AI Agent 工作流、跨区域测试、网络调研与浏览器自动化等场景中稳定获取网络数据。立即体验 Novada,构建更稳定、更可扩展的 AI 工作流。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="七牛云AI" width="150"></a></td>
|
||||
<td>感谢 七牛云AI 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过169万企业及开发者用户。七牛云 AI 为 Sub2API 的用户提供了专属福利:通过<a href="https://s.qiniu.com/u6rQrq">此链接</a>注册,企业用户免费领1200万Token,开发者免费领300万Token。</td>
|
||||
@@ -177,6 +159,16 @@
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
@@ -208,7 +200,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go 1.26.5, Gin, Ent |
|
||||
| 后端 | Go 1.27.0, Gin, Ent |
|
||||
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| 数据库 | PostgreSQL 15+ |
|
||||
| 缓存/队列 | Redis 7+ |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# Sub2API
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
@@ -58,6 +58,11 @@
|
||||
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
|
||||
<td>OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — <a href="https://codex-everywhere.com">CodexEverywhere</a> は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。<a href="https://codex-everywhere.com">codex-everywhere.com</a> で $20 の無料トライアルから始めましょう。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
|
||||
@@ -93,12 +98,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
|
||||
@@ -119,28 +118,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
|
||||
<td>Novada のご支援に感謝します!<a href="https://www.novada.com/?sub2api/">Novada</a> は、AI アプリケーションや自動化ワークフローを構築する開発者向けに、レジデンシャル、ISP、データセンター、モバイルプロキシに加え、Web Unlocker と Scraper API を提供しています。グローバルな IP カバレッジ、柔軟なローテーション/スティッキーセッション、精密なジオターゲティングにより、AI エージェントワークフロー、クロスリージョンテスト、Web リサーチ、ブラウザ自動化などのシーンで、チームが Web データへ確実にアクセスできるよう支援します。Novada で、より安定しスケーラブルな AI ワークフローを構築しましょう。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
|
||||
<td>七牛云AI のご支援に感謝します!七牛云AI は、七牛云(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル 150+ をワンストップで利用でき、グローバル主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバー。169万を超える企業・開発者ユーザーにサービスを提供しています。Sub2API ユーザー向けの限定特典として、<a href="https://s.qiniu.com/u6rQrq">こちらのリンク</a>から登録すると、企業ユーザーは 1200万 Token、開発者は 300万 Token を無料で獲得できます。</td>
|
||||
@@ -176,6 +158,16 @@
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
@@ -207,7 +199,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
|
||||
|
||||
| コンポーネント | 技術 |
|
||||
|-----------|------------|
|
||||
| バックエンド | Go 1.26.5, Gin, Ent |
|
||||
| バックエンド | Go 1.27.0, Gin, Ent |
|
||||
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| データベース | PostgreSQL 15+ |
|
||||
| キャッシュ/キュー | Redis 7+ |
|
||||
|
||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -121,6 +121,16 @@ linters:
|
||||
# Default: true — must be true, ent generates 130K+ lines of code
|
||||
generated-is-used: true
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的
|
||||
# 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的
|
||||
# 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免,
|
||||
# 必须逐点 //nolint:gosec 并写明信任边界。
|
||||
- path: '_test\.go$'
|
||||
linters: [ gosec ]
|
||||
text: 'G70[34]'
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26.6-alpine
|
||||
FROM golang:1.27.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.178
|
||||
0.2.1
|
||||
|
||||
@@ -153,6 +153,11 @@ func runMainServer() {
|
||||
log.Fatalf("Failed to initialize application: %v", err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
if app.PluginManager != nil {
|
||||
if err := app.PluginManager.Start(context.Background()); err != nil {
|
||||
log.Printf("Plugin manager started in degraded state: %v", err)
|
||||
}
|
||||
}
|
||||
if app.PromptAudit != nil {
|
||||
if err := app.PromptAudit.Start(context.Background()); err != nil {
|
||||
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
|
||||
|
||||
@@ -25,9 +25,10 @@ import (
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
PromptAudit *securityaudit.PromptService
|
||||
Cleanup func()
|
||||
Server *http.Server
|
||||
PromptAudit *securityaudit.PromptService
|
||||
PluginManager *service.PluginManager
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
@@ -51,12 +52,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
|
||||
// BuildInfo provider
|
||||
provideServiceBuildInfo,
|
||||
providePluginHostInfo,
|
||||
|
||||
// Cleanup function provider
|
||||
provideCleanup,
|
||||
|
||||
// Application struct
|
||||
wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"),
|
||||
wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -72,6 +74,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
|
||||
return service.PluginHostInfo{
|
||||
Version: buildInfo.Version,
|
||||
BuildType: buildInfo.BuildType,
|
||||
}
|
||||
}
|
||||
|
||||
func provideCleanup(
|
||||
entClient *ent.Client,
|
||||
rdb *redis.Client,
|
||||
@@ -116,7 +125,9 @@ func provideCleanup(
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService,
|
||||
auditLog *service.AuditLogService,
|
||||
openAIAutoReset *service.OpenAIQuotaAutoResetService,
|
||||
promptAudit *securityaudit.PromptService,
|
||||
pluginManager *service.PluginManager,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -129,6 +140,18 @@ func provideCleanup(
|
||||
|
||||
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
|
||||
parallelSteps := []cleanupStep{
|
||||
{"PluginManager", func() error {
|
||||
if pluginManager != nil {
|
||||
pluginManager.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIQuotaAutoResetService", func() error {
|
||||
if openAIAutoReset != nil {
|
||||
openAIAutoReset.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsIngressRejectAggregator", func() error {
|
||||
if opsIngressReject != nil {
|
||||
opsIngressReject.Stop()
|
||||
@@ -328,12 +351,12 @@ func provideCleanup(
|
||||
return nil
|
||||
}},
|
||||
{"ChannelMonitorV2Aggregator", func() error {
|
||||
if channelMonitorV2Aggregator != nil {
|
||||
channelMonitorV2Aggregator.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"ChannelMonitorRunner", func() error {
|
||||
if channelMonitorV2Aggregator != nil {
|
||||
channelMonitorV2Aggregator.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"ChannelMonitorRunner", func() error {
|
||||
if channelMonitorRunner != nil {
|
||||
channelMonitorRunner.Stop()
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
|
||||
channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db)
|
||||
channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService)
|
||||
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service)
|
||||
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service, apiKeyService)
|
||||
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
||||
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
||||
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
|
||||
@@ -189,25 +189,28 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
|
||||
adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService)
|
||||
pluginRepository := repository.NewPluginRepository(db)
|
||||
pluginHostInfo := providePluginHostInfo(buildInfo)
|
||||
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
|
||||
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
|
||||
dbDumper := repository.NewPgDumper(configConfig)
|
||||
dbDumper := repository.NewPgDumper(configConfig, db)
|
||||
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
|
||||
imageStorageFactory := repository.ProvideImageStorageFactory()
|
||||
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
|
||||
@@ -251,6 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
|
||||
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
|
||||
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
|
||||
pluginHandler := admin.NewPluginHandler(pluginManager)
|
||||
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
|
||||
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
|
||||
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
|
||||
@@ -280,14 +284,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
|
||||
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
|
||||
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
|
||||
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
|
||||
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
|
||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
|
||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
@@ -300,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
|
||||
modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver)
|
||||
modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService)
|
||||
imageTaskStore := repository.NewImageTaskStore(redisClient)
|
||||
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
|
||||
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
|
||||
@@ -314,7 +319,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
@@ -341,11 +347,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
|
||||
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
Cleanup: v,
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
PluginManager: pluginManager,
|
||||
Cleanup: v,
|
||||
}
|
||||
return application, nil
|
||||
}
|
||||
@@ -353,9 +360,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
// wire.go:
|
||||
|
||||
type Application struct {
|
||||
Server *http.Server
|
||||
PromptAudit *securityaudit.PromptService
|
||||
Cleanup func()
|
||||
Server *http.Server
|
||||
PromptAudit *securityaudit.PromptService
|
||||
PluginManager *service.PluginManager
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
func providePrivacyClientFactory() service.PrivacyClientFactory {
|
||||
@@ -369,6 +377,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
|
||||
return service.PluginHostInfo{
|
||||
Version: buildInfo.Version,
|
||||
BuildType: buildInfo.BuildType,
|
||||
}
|
||||
}
|
||||
|
||||
func provideCleanup(
|
||||
entClient *ent.Client,
|
||||
rdb *redis.Client,
|
||||
@@ -413,7 +428,9 @@ func provideCleanup(
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService,
|
||||
auditLog *service.AuditLogService,
|
||||
openAIAutoReset *service.OpenAIQuotaAutoResetService,
|
||||
promptAudit *securityaudit.PromptService,
|
||||
pluginManager *service.PluginManager,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -425,6 +442,18 @@ func provideCleanup(
|
||||
}
|
||||
|
||||
parallelSteps := []cleanupStep{
|
||||
{"PluginManager", func() error {
|
||||
if pluginManager != nil {
|
||||
pluginManager.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIQuotaAutoResetService", func() error {
|
||||
if openAIAutoReset != nil {
|
||||
openAIAutoReset.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsIngressRejectAggregator", func() error {
|
||||
if opsIngressReject != nil {
|
||||
opsIngressReject.Stop()
|
||||
|
||||
@@ -94,7 +94,9 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // upstreamBillingProbe
|
||||
nil, // ollamaCloudUsage
|
||||
nil, // auditLog
|
||||
nil, // openAIAutoReset
|
||||
nil, // promptAudit
|
||||
nil, // pluginManager
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Upstream error proxy attribution
|
||||
|
||||
## Scope
|
||||
|
||||
Gateway inference errors are stored in `ops_error_logs.upstream_errors`. Each
|
||||
array item represents one upstream attempt, including same-account retries and
|
||||
cross-account failover attempts. Proxy attribution is stored on that item; it
|
||||
must not be reconstructed later from `accounts.proxy_id`.
|
||||
|
||||
This repository has one gateway implementation under `backend/internal/service`.
|
||||
There is no separate `gateway/backend` source copy to synchronize.
|
||||
|
||||
## Event contract
|
||||
|
||||
Every newly persisted event contains these fields. `proxy_id` is emitted even
|
||||
when its value is JSON `null`:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `proxy_id` | Managed proxy ID used by the attempt; `null` for direct or unknown routes. |
|
||||
| `proxy_name` | Snapshotted proxy name, `direct/no_proxy`, or `unknown`. |
|
||||
|
||||
Only proxy ID and name are stored. Proxy URL, protocol endpoint, host, port,
|
||||
username, password, and authorization data are excluded.
|
||||
|
||||
The two sentinel names have strict meanings:
|
||||
|
||||
- `direct/no_proxy`: the transport was explicitly given no proxy.
|
||||
- `unknown`: the route cannot be proven from event-time evidence. This includes
|
||||
legacy events, pre-transport credential failures, and OpenAI WebSocket use of
|
||||
the default HTTP client.
|
||||
|
||||
Invariant: `proxy_id` is `null` if and only if `proxy_name` is one of the two
|
||||
sentinels. A managed proxy with a blank name (defensive only; `proxies.name` is
|
||||
non-empty) is labeled `proxy`. Both the struct normalizer used at append time
|
||||
and the JSON normalizer used on detail reads enforce this, so an event can never
|
||||
carry `proxy_id=null` together with a real proxy name.
|
||||
|
||||
## Why the account snapshot is event-time evidence
|
||||
|
||||
Account selection loads `ProxyID` and `Proxy` into one in-memory snapshot. HTTP,
|
||||
TLS-fingerprint, Bedrock, Gemini, Antigravity, OpenAI, and OpenAI WebSocket
|
||||
forwarding use that snapshot for managed proxy routing.
|
||||
Inference transports use a managed proxy only when both the binding ID and its
|
||||
hydrated proxy object are present; the attribution accessors use the same rule.
|
||||
OpenAI WebSocket additionally retains the existing default-client environment
|
||||
proxy behavior when that snapshot does not yield a usable proxy. The transport
|
||||
does not switch to a database backup proxy during a request.
|
||||
|
||||
Expired proxy fallback happens before scheduling by atomically rewriting the
|
||||
account binding:
|
||||
|
||||
- backup proxy: `proxy_id` becomes the backup ID and
|
||||
`proxy_fallback_origin_id` stores the expired proxy ID;
|
||||
- direct fallback: `proxy_id` becomes null and
|
||||
`proxy_fallback_origin_id` stores the expired proxy ID.
|
||||
|
||||
Custom Anthropic relays receive the same snapshotted proxy as their encoded
|
||||
relay proxy parameter. OpenAI WebSocket requests keep the existing transport
|
||||
behavior: configured account proxies use the proxy client, while an empty
|
||||
account proxy leaves the HTTP client unset so `coder/websocket` uses
|
||||
`http.DefaultClient`, including its `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
|
||||
handling.
|
||||
|
||||
For WebSocket errors without a usable account proxy, the event stores
|
||||
`proxy_id=null` and `proxy_name=unknown`. It does not infer whether the default
|
||||
client selected an environment proxy or a direct connection after the fact.
|
||||
This applies to every event produced while a request is on the WebSocket
|
||||
transport, including WS handshake/fallback errors and the first-output timeout
|
||||
raised by the WS passthrough relay; `opsUpstreamWSProxyAttribution` is the
|
||||
accessor for those sites and never returns `direct/no_proxy`.
|
||||
|
||||
Credential acquisition failures that occur before the inference transport is
|
||||
opened are also recorded as unknown. The account's proxy binding is not treated
|
||||
as proof that the credential operation or an inference request used that route.
|
||||
|
||||
Each failure site constructs a complete `OpsUpstreamErrorEvent`. Its literal
|
||||
sets `proxy_id` and `proxy_name` from the same route input used by the attempt.
|
||||
Credential-free accessors copy only the managed proxy ID and name. A fallback
|
||||
proxy needs no special event flag: its event-time ID and name are the historical
|
||||
route evidence. Shared helpers that assemble the event on behalf of a caller
|
||||
(`handleUpstreamTransportError`, `handleOpenAIUpstreamTransportError`,
|
||||
`appendOpenAICompactFallbackRetryOps`, `recordOpenAIRawStreamTruncation`,
|
||||
`newOpenAIFirstOutputTimeoutError`) stamp attribution themselves, so a caller
|
||||
cannot forget it. Every non-test `OpsUpstreamErrorEvent{` literal in
|
||||
`backend/internal/service` must either set `ProxyName` or be passed to one of
|
||||
those helpers; grep for literals missing `ProxyName` before merging changes
|
||||
that add a failure site.
|
||||
|
||||
`appendOpsUpstreamError` accepts only the completed event. It does not receive
|
||||
an `Account`, query current account state, or infer proxy attribution while
|
||||
appending. Once appended, the event contains only scalar snapshot values, so
|
||||
later account mutation or failover cannot change an earlier event.
|
||||
|
||||
Queue sanitization keeps attempt events newest-first under hard bounds:
|
||||
|
||||
- the newest 16 attempts retain `detail` and `upstream_response_body` (each
|
||||
capped at the queue body limit) plus `upstream_url`/`message` up to 2048
|
||||
bytes;
|
||||
- older attempts keep timestamps, account, proxy, status, kind, stage, scope,
|
||||
reason and a `upstream_url`/`message` trimmed to 512 bytes; an attempt whose
|
||||
only payload was a cleared `detail` is still retained;
|
||||
- at most 256 attempts and roughly 512 KiB of serialized JSON are kept per
|
||||
request. When either bound drops attempts, the oldest retained event carries
|
||||
`dropped_earlier_attempts` with the number of discarded earlier attempts. The
|
||||
newest attempt is always retained because it decides the request outcome.
|
||||
|
||||
## Legacy events and analytics
|
||||
|
||||
Legacy JSON is not rewritten in the database. Detail reads edit only the
|
||||
`proxy_id`/`proxy_name` keys of events that lack valid attribution, so keys
|
||||
written by older struct versions and the stored key order are preserved. The
|
||||
shared parser applies the same rule to the decoded structs. Both materialize
|
||||
missing attribution as:
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy_id": null,
|
||||
"proxy_name": "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Proxy and region aggregation must use the event fields first. A safe grouping
|
||||
rule is:
|
||||
|
||||
1. A non-null event `proxy_id` groups by that ID and uses the event
|
||||
`proxy_name` as the historical label. A join to `proxies` may enrich current
|
||||
metadata but must not replace the event label.
|
||||
2. `proxy_id=null` and `proxy_name=direct/no_proxy` groups as direct.
|
||||
3. Missing attribution, or `proxy_id=null` and `proxy_name=unknown`, groups as
|
||||
unknown. Do not join through the account's current `proxy_id` and present it
|
||||
as historical attribution.
|
||||
|
||||
If an operator deliberately produces a current-account cohort for legacy data,
|
||||
the report must be labeled as a current snapshot; it is not historical proxy
|
||||
attribution.
|
||||
@@ -4,6 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -100,7 +101,7 @@ type Group struct {
|
||||
// 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价
|
||||
LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"`
|
||||
// 分组逐模型定价;优先级高于渠道和内置定价
|
||||
ModelPricing json.RawMessage `json:"model_pricing,omitempty"`
|
||||
ModelPricing jsontext.Value `json:"model_pricing,omitempty"`
|
||||
// 是否仅允许 Claude Code 客户端
|
||||
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
|
||||
// 非 Claude Code 请求降级使用的分组 ID
|
||||
@@ -121,6 +122,10 @@ type Group struct {
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
|
||||
// 是否允许此 OpenAI 分组访问 Live 接口
|
||||
AllowLive bool `json:"allow_live,omitempty"`
|
||||
// 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority
|
||||
ForceOpenaiFast bool `json:"force_openai_fast,omitempty"`
|
||||
// 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费
|
||||
FreeOpenaiFast bool `json:"free_openai_fast,omitempty"`
|
||||
// 仅允许非 apikey 类型账号关联到此分组
|
||||
RequireOauthOnly bool `json:"require_oauth_only,omitempty"`
|
||||
// 调度时仅允许 privacy 已成功设置的账号
|
||||
@@ -129,13 +134,17 @@ type Group struct {
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
// 分组模型白名单:同时约束模型列表接口与请求准入
|
||||
ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,omitempty"`
|
||||
// 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)
|
||||
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"`
|
||||
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
|
||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
|
||||
@@ -249,15 +258,15 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -615,6 +624,18 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.AllowLive = value.Bool
|
||||
}
|
||||
case group.FieldForceOpenaiFast:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ForceOpenaiFast = value.Bool
|
||||
}
|
||||
case group.FieldFreeOpenaiFast:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i])
|
||||
} else if value.Valid {
|
||||
_m.FreeOpenaiFast = value.Bool
|
||||
}
|
||||
case group.FieldRequireOauthOnly:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i])
|
||||
@@ -641,12 +662,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldModelsListConfig:
|
||||
case group.FieldModelAllowlist:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field models_list_config", values[i])
|
||||
return fmt.Errorf("unexpected type %T for field model_allowlist", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field models_list_config: %w", err)
|
||||
if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil {
|
||||
return fmt.Errorf("unmarshal field model_allowlist: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldRpmLimit:
|
||||
@@ -661,6 +690,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffort = value.String
|
||||
}
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i])
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffortOverLimit = value.String
|
||||
}
|
||||
case group.FieldReasoningEffortMappings:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i])
|
||||
@@ -954,6 +989,12 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("allow_live=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.AllowLive))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("force_openai_fast=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("free_openai_fast=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("require_oauth_only=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly))
|
||||
builder.WriteString(", ")
|
||||
@@ -966,8 +1007,11 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("messages_dispatch_model_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("models_list_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
|
||||
builder.WriteString("model_allowlist=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("codex_models_manifest_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("rpm_limit=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
|
||||
@@ -975,6 +1019,9 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("max_reasoning_effort=")
|
||||
builder.WriteString(_m.MaxReasoningEffort)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("max_reasoning_effort_over_limit=")
|
||||
builder.WriteString(_m.MaxReasoningEffortOverLimit)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("reasoning_effort_mappings=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -118,6 +118,10 @@ const (
|
||||
FieldAllowMessagesDispatch = "allow_messages_dispatch"
|
||||
// FieldAllowLive holds the string denoting the allow_live field in the database.
|
||||
FieldAllowLive = "allow_live"
|
||||
// FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database.
|
||||
FieldForceOpenaiFast = "force_openai_fast"
|
||||
// FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database.
|
||||
FieldFreeOpenaiFast = "free_openai_fast"
|
||||
// FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database.
|
||||
FieldRequireOauthOnly = "require_oauth_only"
|
||||
// FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database.
|
||||
@@ -126,12 +130,16 @@ const (
|
||||
FieldDefaultMappedModel = "default_mapped_model"
|
||||
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
|
||||
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
||||
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
|
||||
FieldModelsListConfig = "models_list_config"
|
||||
// FieldModelAllowlist holds the string denoting the model_allowlist field in the database.
|
||||
FieldModelAllowlist = "model_allowlist"
|
||||
// FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database.
|
||||
FieldCodexModelsManifestConfig = "codex_models_manifest_config"
|
||||
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
|
||||
FieldRpmLimit = "rpm_limit"
|
||||
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
|
||||
FieldMaxReasoningEffort = "max_reasoning_effort"
|
||||
// FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database.
|
||||
FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit"
|
||||
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
|
||||
FieldReasoningEffortMappings = "reasoning_effort_mappings"
|
||||
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
|
||||
@@ -266,13 +274,17 @@ var Columns = []string{
|
||||
FieldSortOrder,
|
||||
FieldAllowMessagesDispatch,
|
||||
FieldAllowLive,
|
||||
FieldForceOpenaiFast,
|
||||
FieldFreeOpenaiFast,
|
||||
FieldRequireOauthOnly,
|
||||
FieldRequirePrivacySet,
|
||||
FieldDefaultMappedModel,
|
||||
FieldMessagesDispatchModelConfig,
|
||||
FieldModelsListConfig,
|
||||
FieldModelAllowlist,
|
||||
FieldCodexModelsManifestConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldMaxReasoningEffortOverLimit,
|
||||
FieldReasoningEffortMappings,
|
||||
FieldProfitControlEnabled,
|
||||
FieldProfitMinMargin,
|
||||
@@ -386,6 +398,10 @@ var (
|
||||
DefaultAllowMessagesDispatch bool
|
||||
// DefaultAllowLive holds the default value on creation for the "allow_live" field.
|
||||
DefaultAllowLive bool
|
||||
// DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field.
|
||||
DefaultForceOpenaiFast bool
|
||||
// DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field.
|
||||
DefaultFreeOpenaiFast bool
|
||||
// DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field.
|
||||
DefaultRequireOauthOnly bool
|
||||
// DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field.
|
||||
@@ -396,14 +412,20 @@ var (
|
||||
DefaultMappedModelValidator func(string) error
|
||||
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
|
||||
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
||||
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
|
||||
DefaultModelsListConfig domain.GroupModelsListConfig
|
||||
// DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field.
|
||||
DefaultModelAllowlist domain.GroupModelAllowlist
|
||||
// DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field.
|
||||
DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig
|
||||
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
|
||||
DefaultRpmLimit int
|
||||
// DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field.
|
||||
DefaultMaxReasoningEffort string
|
||||
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
MaxReasoningEffortValidator func(string) error
|
||||
// DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field.
|
||||
DefaultMaxReasoningEffortOverLimit string
|
||||
// MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
MaxReasoningEffortOverLimitValidator func(string) error
|
||||
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
|
||||
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
|
||||
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
|
||||
@@ -657,6 +679,16 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAllowLive, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByForceOpenaiFast orders the results by the force_openai_fast field.
|
||||
func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByFreeOpenaiFast orders the results by the free_openai_fast field.
|
||||
func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByRequireOauthOnly orders the results by the require_oauth_only field.
|
||||
func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc()
|
||||
@@ -682,6 +714,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field.
|
||||
func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
|
||||
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
|
||||
|
||||
@@ -290,6 +290,16 @@ func AllowLive(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ.
|
||||
func ForceOpenaiFast(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ.
|
||||
func FreeOpenaiFast(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ.
|
||||
func RequireOauthOnly(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
|
||||
@@ -315,6 +325,11 @@ func MaxReasoningEffort(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ.
|
||||
func MaxReasoningEffortOverLimit(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
|
||||
func ProfitControlEnabled(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
@@ -2270,6 +2285,26 @@ func AllowLiveNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldAllowLive, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field.
|
||||
func ForceOpenaiFastEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field.
|
||||
func ForceOpenaiFastNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field.
|
||||
func FreeOpenaiFastEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field.
|
||||
func FreeOpenaiFastNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field.
|
||||
func RequireOauthOnlyEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
|
||||
@@ -2460,6 +2495,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
|
||||
func ProfitControlEnabledEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
|
||||
@@ -4,7 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -575,7 +575,7 @@ func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreat
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (_c *GroupCreate) SetModelPricing(v json.RawMessage) *GroupCreate {
|
||||
func (_c *GroupCreate) SetModelPricing(v jsontext.Value) *GroupCreate {
|
||||
_c.mutation.SetModelPricing(v)
|
||||
return _c
|
||||
}
|
||||
@@ -704,6 +704,34 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate {
|
||||
_c.mutation.SetForceOpenaiFast(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate {
|
||||
_c.mutation.SetFreeOpenaiFast(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate {
|
||||
_c.mutation.SetRequireOauthOnly(v)
|
||||
@@ -760,16 +788,30 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate {
|
||||
_c.mutation.SetModelAllowlist(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetModelsListConfig(*v)
|
||||
_c.SetModelAllowlist(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate {
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
@@ -802,6 +844,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate {
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1095,6 +1151,14 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultAllowLive
|
||||
_c.mutation.SetAllowLive(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
|
||||
v := group.DefaultForceOpenaiFast
|
||||
_c.mutation.SetForceOpenaiFast(v)
|
||||
}
|
||||
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
|
||||
v := group.DefaultFreeOpenaiFast
|
||||
_c.mutation.SetFreeOpenaiFast(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
|
||||
v := group.DefaultRequireOauthOnly
|
||||
_c.mutation.SetRequireOauthOnly(v)
|
||||
@@ -1111,9 +1175,13 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMessagesDispatchModelConfig
|
||||
_c.mutation.SetMessagesDispatchModelConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||
v := group.DefaultModelsListConfig
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
if _, ok := _c.mutation.ModelAllowlist(); !ok {
|
||||
v := group.DefaultModelAllowlist
|
||||
_c.mutation.SetModelAllowlist(v)
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
v := group.DefaultCodexModelsManifestConfig
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
v := group.DefaultRpmLimit
|
||||
@@ -1123,6 +1191,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMaxReasoningEffort
|
||||
_c.mutation.SetMaxReasoningEffort(v)
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
v := group.DefaultMaxReasoningEffortOverLimit
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
v := group.DefaultReasoningEffortMappings
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1286,6 +1358,12 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.AllowLive(); !ok {
|
||||
return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
|
||||
return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
|
||||
return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
|
||||
return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)}
|
||||
}
|
||||
@@ -1303,8 +1381,11 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
|
||||
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||
return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)}
|
||||
if _, ok := _c.mutation.ModelAllowlist(); !ok {
|
||||
return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
|
||||
@@ -1317,6 +1398,14 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
|
||||
}
|
||||
@@ -1560,6 +1649,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
_node.AllowLive = value
|
||||
}
|
||||
if value, ok := _c.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
_node.ForceOpenaiFast = value
|
||||
}
|
||||
if value, ok := _c.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
_node.FreeOpenaiFast = value
|
||||
}
|
||||
if value, ok := _c.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
_node.RequireOauthOnly = value
|
||||
@@ -1576,9 +1673,13 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
_node.MessagesDispatchModelConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
_node.ModelsListConfig = value
|
||||
if value, ok := _c.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
_node.ModelAllowlist = value
|
||||
}
|
||||
if value, ok := _c.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
_node.CodexModelsManifestConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
@@ -1588,6 +1689,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
_node.MaxReasoningEffort = value
|
||||
}
|
||||
if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
_node.MaxReasoningEffortOverLimit = value
|
||||
}
|
||||
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
_node.ReasoningEffortMappings = value
|
||||
@@ -2445,7 +2550,7 @@ func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert {
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (u *GroupUpsert) SetModelPricing(v json.RawMessage) *GroupUpsert {
|
||||
func (u *GroupUpsert) SetModelPricing(v jsontext.Value) *GroupUpsert {
|
||||
u.Set(group.FieldModelPricing, v)
|
||||
return u
|
||||
}
|
||||
@@ -2618,6 +2723,30 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldForceOpenaiFast, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldForceOpenaiFast)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldFreeOpenaiFast, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldFreeOpenaiFast)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldRequireOauthOnly, v)
|
||||
@@ -2666,15 +2795,27 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
|
||||
u.Set(group.FieldModelsListConfig, v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert {
|
||||
u.Set(group.FieldModelAllowlist, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldModelsListConfig)
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldModelAllowlist)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert {
|
||||
u.Set(group.FieldCodexModelsManifestConfig, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldCodexModelsManifestConfig)
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -2708,6 +2849,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert {
|
||||
u.Set(group.FieldMaxReasoningEffortOverLimit, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldMaxReasoningEffortOverLimit)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
|
||||
u.Set(group.FieldReasoningEffortMappings, v)
|
||||
@@ -3615,7 +3768,7 @@ func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne {
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (u *GroupUpsertOne) SetModelPricing(v json.RawMessage) *GroupUpsertOne {
|
||||
func (u *GroupUpsertOne) SetModelPricing(v jsontext.Value) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelPricing(v)
|
||||
})
|
||||
@@ -3817,6 +3970,34 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetForceOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateForceOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetFreeOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateFreeOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -3873,17 +4054,31 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelsListConfig(v)
|
||||
s.SetModelAllowlist(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateModelsListConfig()
|
||||
s.UpdateModelAllowlist()
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetCodexModelsManifestConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateCodexModelsManifestConfig()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3922,6 +4117,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5005,7 +5214,7 @@ func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk {
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (u *GroupUpsertBulk) SetModelPricing(v json.RawMessage) *GroupUpsertBulk {
|
||||
func (u *GroupUpsertBulk) SetModelPricing(v jsontext.Value) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelPricing(v)
|
||||
})
|
||||
@@ -5207,6 +5416,34 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetForceOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateForceOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetFreeOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateFreeOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5263,17 +5500,31 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelsListConfig(v)
|
||||
s.SetModelAllowlist(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateModelsListConfig()
|
||||
s.UpdateModelAllowlist()
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetCodexModelsManifestConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateCodexModelsManifestConfig()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5312,6 +5563,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -4,7 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -803,13 +803,13 @@ func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdat
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (_u *GroupUpdate) SetModelPricing(v json.RawMessage) *GroupUpdate {
|
||||
func (_u *GroupUpdate) SetModelPricing(v jsontext.Value) *GroupUpdate {
|
||||
_u.mutation.SetModelPricing(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendModelPricing appends value to the "model_pricing" field.
|
||||
func (_u *GroupUpdate) AppendModelPricing(v json.RawMessage) *GroupUpdate {
|
||||
func (_u *GroupUpdate) AppendModelPricing(v jsontext.Value) *GroupUpdate {
|
||||
_u.mutation.AppendModelPricing(v)
|
||||
return _u
|
||||
}
|
||||
@@ -989,6 +989,34 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate {
|
||||
_u.mutation.SetForceOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate {
|
||||
_u.mutation.SetFreeOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate {
|
||||
_u.mutation.SetRequireOauthOnly(v)
|
||||
@@ -1045,16 +1073,30 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
|
||||
_u.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate {
|
||||
_u.mutation.SetModelAllowlist(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetModelsListConfig(*v)
|
||||
_u.SetModelAllowlist(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate {
|
||||
_u.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
@@ -1094,6 +1136,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1487,6 +1543,11 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1797,6 +1858,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.AllowLive(); ok {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -1809,8 +1876,11 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
if value, ok := _u.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
@@ -1821,6 +1891,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
@@ -2924,13 +2997,13 @@ func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUp
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (_u *GroupUpdateOne) SetModelPricing(v json.RawMessage) *GroupUpdateOne {
|
||||
func (_u *GroupUpdateOne) SetModelPricing(v jsontext.Value) *GroupUpdateOne {
|
||||
_u.mutation.SetModelPricing(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendModelPricing appends value to the "model_pricing" field.
|
||||
func (_u *GroupUpdateOne) AppendModelPricing(v json.RawMessage) *GroupUpdateOne {
|
||||
func (_u *GroupUpdateOne) AppendModelPricing(v jsontext.Value) *GroupUpdateOne {
|
||||
_u.mutation.AppendModelPricing(v)
|
||||
return _u
|
||||
}
|
||||
@@ -3110,6 +3183,34 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetForceOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetFreeOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetRequireOauthOnly(v)
|
||||
@@ -3166,16 +3267,30 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne {
|
||||
_u.mutation.SetModelAllowlist(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetModelsListConfig(*v)
|
||||
_u.SetModelAllowlist(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
@@ -3215,6 +3330,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -3621,6 +3750,11 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3948,6 +4082,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.AllowLive(); ok {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -3960,8 +4100,11 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
if value, ok := _u.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
@@ -3972,6 +4115,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
|
||||
@@ -954,13 +954,17 @@ var (
|
||||
{Name: "sort_order", Type: field.TypeInt, Default: 0},
|
||||
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
|
||||
{Name: "allow_live", Type: field.TypeBool, Default: false},
|
||||
{Name: "force_openai_fast", Type: field.TypeBool, Default: false},
|
||||
{Name: "free_openai_fast", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
||||
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
|
||||
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "model_allowlist", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
|
||||
{Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
|
||||
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
@@ -1801,6 +1805,7 @@ var (
|
||||
{Name: "signup_source", Type: field.TypeString, Default: "email"},
|
||||
{Name: "last_login_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "last_active_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "restrict_public_groups", Type: field.TypeBool, Default: false},
|
||||
{Name: "balance_notify_enabled", Type: field.TypeBool, Default: true},
|
||||
{Name: "balance_notify_threshold_type", Type: field.TypeString, Default: "fixed"},
|
||||
{Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
|
||||
@@ -4,7 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
@@ -22142,8 +22142,8 @@ type GroupMutation struct {
|
||||
audio_stt_price_per_hour *float64
|
||||
addaudio_stt_price_per_hour *float64
|
||||
long_context_pricing_enabled *bool
|
||||
model_pricing *json.RawMessage
|
||||
appendmodel_pricing json.RawMessage
|
||||
model_pricing *jsontext.Value
|
||||
appendmodel_pricing jsontext.Value
|
||||
claude_code_only *bool
|
||||
fallback_group_id *int64
|
||||
addfallback_group_id *int64
|
||||
@@ -22158,14 +22158,18 @@ type GroupMutation struct {
|
||||
addsort_order *int
|
||||
allow_messages_dispatch *bool
|
||||
allow_live *bool
|
||||
force_openai_fast *bool
|
||||
free_openai_fast *bool
|
||||
require_oauth_only *bool
|
||||
require_privacy_set *bool
|
||||
default_mapped_model *string
|
||||
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
||||
models_list_config *domain.GroupModelsListConfig
|
||||
model_allowlist *domain.GroupModelAllowlist
|
||||
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
max_reasoning_effort_over_limit *string
|
||||
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
|
||||
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
|
||||
profit_control_enabled *bool
|
||||
@@ -24404,13 +24408,13 @@ func (m *GroupMutation) ResetLongContextPricingEnabled() {
|
||||
}
|
||||
|
||||
// SetModelPricing sets the "model_pricing" field.
|
||||
func (m *GroupMutation) SetModelPricing(jm json.RawMessage) {
|
||||
m.model_pricing = &jm
|
||||
func (m *GroupMutation) SetModelPricing(j jsontext.Value) {
|
||||
m.model_pricing = &j
|
||||
m.appendmodel_pricing = nil
|
||||
}
|
||||
|
||||
// ModelPricing returns the value of the "model_pricing" field in the mutation.
|
||||
func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) {
|
||||
func (m *GroupMutation) ModelPricing() (r jsontext.Value, exists bool) {
|
||||
v := m.model_pricing
|
||||
if v == nil {
|
||||
return
|
||||
@@ -24421,7 +24425,7 @@ func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) {
|
||||
// OldModelPricing returns the old "model_pricing" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, err error) {
|
||||
func (m *GroupMutation) OldModelPricing(ctx context.Context) (v jsontext.Value, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldModelPricing is only allowed on UpdateOne operations")
|
||||
}
|
||||
@@ -24435,13 +24439,13 @@ func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage,
|
||||
return oldValue.ModelPricing, nil
|
||||
}
|
||||
|
||||
// AppendModelPricing adds jm to the "model_pricing" field.
|
||||
func (m *GroupMutation) AppendModelPricing(jm json.RawMessage) {
|
||||
m.appendmodel_pricing = append(m.appendmodel_pricing, jm...)
|
||||
// AppendModelPricing adds j to the "model_pricing" field.
|
||||
func (m *GroupMutation) AppendModelPricing(j jsontext.Value) {
|
||||
m.appendmodel_pricing = append(m.appendmodel_pricing, j...)
|
||||
}
|
||||
|
||||
// AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation.
|
||||
func (m *GroupMutation) AppendedModelPricing() (json.RawMessage, bool) {
|
||||
func (m *GroupMutation) AppendedModelPricing() (jsontext.Value, bool) {
|
||||
if len(m.appendmodel_pricing) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
@@ -24944,6 +24948,78 @@ func (m *GroupMutation) ResetAllowLive() {
|
||||
m.allow_live = nil
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (m *GroupMutation) SetForceOpenaiFast(b bool) {
|
||||
m.force_openai_fast = &b
|
||||
}
|
||||
|
||||
// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation.
|
||||
func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) {
|
||||
v := m.force_openai_fast
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldForceOpenaiFast returns the old "force_openai_fast" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldForceOpenaiFast(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err)
|
||||
}
|
||||
return oldValue.ForceOpenaiFast, nil
|
||||
}
|
||||
|
||||
// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field.
|
||||
func (m *GroupMutation) ResetForceOpenaiFast() {
|
||||
m.force_openai_fast = nil
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (m *GroupMutation) SetFreeOpenaiFast(b bool) {
|
||||
m.free_openai_fast = &b
|
||||
}
|
||||
|
||||
// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation.
|
||||
func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) {
|
||||
v := m.free_openai_fast
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldFreeOpenaiFast returns the old "free_openai_fast" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err)
|
||||
}
|
||||
return oldValue.FreeOpenaiFast, nil
|
||||
}
|
||||
|
||||
// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field.
|
||||
func (m *GroupMutation) ResetFreeOpenaiFast() {
|
||||
m.free_openai_fast = nil
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (m *GroupMutation) SetRequireOauthOnly(b bool) {
|
||||
m.require_oauth_only = &b
|
||||
@@ -25088,40 +25164,76 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
|
||||
m.messages_dispatch_model_config = nil
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) {
|
||||
m.models_list_config = &dmlc
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) {
|
||||
m.model_allowlist = &dma
|
||||
}
|
||||
|
||||
// ModelsListConfig returns the value of the "models_list_config" field in the mutation.
|
||||
func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) {
|
||||
v := m.models_list_config
|
||||
// ModelAllowlist returns the value of the "model_allowlist" field in the mutation.
|
||||
func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) {
|
||||
v := m.model_allowlist
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity.
|
||||
// OldModelAllowlist returns the old "model_allowlist" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
|
||||
func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations")
|
||||
return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldModelsListConfig requires an ID field in the mutation")
|
||||
return v, errors.New("OldModelAllowlist requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
|
||||
return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err)
|
||||
}
|
||||
return oldValue.ModelsListConfig, nil
|
||||
return oldValue.ModelAllowlist, nil
|
||||
}
|
||||
|
||||
// ResetModelsListConfig resets all changes to the "models_list_config" field.
|
||||
func (m *GroupMutation) ResetModelsListConfig() {
|
||||
m.models_list_config = nil
|
||||
// ResetModelAllowlist resets all changes to the "model_allowlist" field.
|
||||
func (m *GroupMutation) ResetModelAllowlist() {
|
||||
m.model_allowlist = nil
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) {
|
||||
m.codex_models_manifest_config = &dcmmc
|
||||
}
|
||||
|
||||
// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation.
|
||||
func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) {
|
||||
v := m.codex_models_manifest_config
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err)
|
||||
}
|
||||
return oldValue.CodexModelsManifestConfig, nil
|
||||
}
|
||||
|
||||
// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field.
|
||||
func (m *GroupMutation) ResetCodexModelsManifestConfig() {
|
||||
m.codex_models_manifest_config = nil
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
@@ -25216,6 +25328,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() {
|
||||
m.max_reasoning_effort = nil
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) {
|
||||
m.max_reasoning_effort_over_limit = &s
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation.
|
||||
func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) {
|
||||
v := m.max_reasoning_effort_over_limit
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err)
|
||||
}
|
||||
return oldValue.MaxReasoningEffortOverLimit, nil
|
||||
}
|
||||
|
||||
// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() {
|
||||
m.max_reasoning_effort_over_limit = nil
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
|
||||
m.reasoning_effort_mappings = &dem
|
||||
@@ -25773,7 +25921,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 62)
|
||||
fields := make([]string, 0, 66)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25927,6 +26075,12 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.allow_live != nil {
|
||||
fields = append(fields, group.FieldAllowLive)
|
||||
}
|
||||
if m.force_openai_fast != nil {
|
||||
fields = append(fields, group.FieldForceOpenaiFast)
|
||||
}
|
||||
if m.free_openai_fast != nil {
|
||||
fields = append(fields, group.FieldFreeOpenaiFast)
|
||||
}
|
||||
if m.require_oauth_only != nil {
|
||||
fields = append(fields, group.FieldRequireOauthOnly)
|
||||
}
|
||||
@@ -25939,8 +26093,11 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.messages_dispatch_model_config != nil {
|
||||
fields = append(fields, group.FieldMessagesDispatchModelConfig)
|
||||
}
|
||||
if m.models_list_config != nil {
|
||||
fields = append(fields, group.FieldModelsListConfig)
|
||||
if m.model_allowlist != nil {
|
||||
fields = append(fields, group.FieldModelAllowlist)
|
||||
}
|
||||
if m.codex_models_manifest_config != nil {
|
||||
fields = append(fields, group.FieldCodexModelsManifestConfig)
|
||||
}
|
||||
if m.rpm_limit != nil {
|
||||
fields = append(fields, group.FieldRpmLimit)
|
||||
@@ -25948,6 +26105,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.max_reasoning_effort != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffort)
|
||||
}
|
||||
if m.max_reasoning_effort_over_limit != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffortOverLimit)
|
||||
}
|
||||
if m.reasoning_effort_mappings != nil {
|
||||
fields = append(fields, group.FieldReasoningEffortMappings)
|
||||
}
|
||||
@@ -26070,6 +26230,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.AllowMessagesDispatch()
|
||||
case group.FieldAllowLive:
|
||||
return m.AllowLive()
|
||||
case group.FieldForceOpenaiFast:
|
||||
return m.ForceOpenaiFast()
|
||||
case group.FieldFreeOpenaiFast:
|
||||
return m.FreeOpenaiFast()
|
||||
case group.FieldRequireOauthOnly:
|
||||
return m.RequireOauthOnly()
|
||||
case group.FieldRequirePrivacySet:
|
||||
@@ -26078,12 +26242,16 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.DefaultMappedModel()
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.MessagesDispatchModelConfig()
|
||||
case group.FieldModelsListConfig:
|
||||
return m.ModelsListConfig()
|
||||
case group.FieldModelAllowlist:
|
||||
return m.ModelAllowlist()
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.CodexModelsManifestConfig()
|
||||
case group.FieldRpmLimit:
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.MaxReasoningEffort()
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.MaxReasoningEffortOverLimit()
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.ReasoningEffortMappings()
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26203,6 +26371,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldAllowMessagesDispatch(ctx)
|
||||
case group.FieldAllowLive:
|
||||
return m.OldAllowLive(ctx)
|
||||
case group.FieldForceOpenaiFast:
|
||||
return m.OldForceOpenaiFast(ctx)
|
||||
case group.FieldFreeOpenaiFast:
|
||||
return m.OldFreeOpenaiFast(ctx)
|
||||
case group.FieldRequireOauthOnly:
|
||||
return m.OldRequireOauthOnly(ctx)
|
||||
case group.FieldRequirePrivacySet:
|
||||
@@ -26211,12 +26383,16 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldDefaultMappedModel(ctx)
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.OldMessagesDispatchModelConfig(ctx)
|
||||
case group.FieldModelsListConfig:
|
||||
return m.OldModelsListConfig(ctx)
|
||||
case group.FieldModelAllowlist:
|
||||
return m.OldModelAllowlist(ctx)
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.OldCodexModelsManifestConfig(ctx)
|
||||
case group.FieldRpmLimit:
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.OldMaxReasoningEffort(ctx)
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.OldMaxReasoningEffortOverLimit(ctx)
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.OldReasoningEffortMappings(ctx)
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26515,7 +26691,7 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
m.SetLongContextPricingEnabled(v)
|
||||
return nil
|
||||
case group.FieldModelPricing:
|
||||
v, ok := value.(json.RawMessage)
|
||||
v, ok := value.(jsontext.Value)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
@@ -26591,6 +26767,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetAllowLive(v)
|
||||
return nil
|
||||
case group.FieldForceOpenaiFast:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetForceOpenaiFast(v)
|
||||
return nil
|
||||
case group.FieldFreeOpenaiFast:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetFreeOpenaiFast(v)
|
||||
return nil
|
||||
case group.FieldRequireOauthOnly:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -26619,12 +26809,19 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMessagesDispatchModelConfig(v)
|
||||
return nil
|
||||
case group.FieldModelsListConfig:
|
||||
v, ok := value.(domain.GroupModelsListConfig)
|
||||
case group.FieldModelAllowlist:
|
||||
v, ok := value.(domain.GroupModelAllowlist)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetModelsListConfig(v)
|
||||
m.SetModelAllowlist(v)
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
v, ok := value.(domain.GroupCodexModelsManifestConfig)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetCodexModelsManifestConfig(v)
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
v, ok := value.(int)
|
||||
@@ -26640,6 +26837,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMaxReasoningEffort(v)
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetMaxReasoningEffortOverLimit(v)
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
v, ok := value.([]domain.ReasoningEffortMapping)
|
||||
if !ok {
|
||||
@@ -27332,6 +27536,12 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldAllowLive:
|
||||
m.ResetAllowLive()
|
||||
return nil
|
||||
case group.FieldForceOpenaiFast:
|
||||
m.ResetForceOpenaiFast()
|
||||
return nil
|
||||
case group.FieldFreeOpenaiFast:
|
||||
m.ResetFreeOpenaiFast()
|
||||
return nil
|
||||
case group.FieldRequireOauthOnly:
|
||||
m.ResetRequireOauthOnly()
|
||||
return nil
|
||||
@@ -27344,8 +27554,11 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
m.ResetMessagesDispatchModelConfig()
|
||||
return nil
|
||||
case group.FieldModelsListConfig:
|
||||
m.ResetModelsListConfig()
|
||||
case group.FieldModelAllowlist:
|
||||
m.ResetModelAllowlist()
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
m.ResetCodexModelsManifestConfig()
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
m.ResetRpmLimit()
|
||||
@@ -27353,6 +27566,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldMaxReasoningEffort:
|
||||
m.ResetMaxReasoningEffort()
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
m.ResetMaxReasoningEffortOverLimit()
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
m.ResetReasoningEffortMappings()
|
||||
return nil
|
||||
@@ -43151,8 +43367,8 @@ type UsageCleanupTaskMutation struct {
|
||||
created_at *time.Time
|
||||
updated_at *time.Time
|
||||
status *string
|
||||
filters *json.RawMessage
|
||||
appendfilters json.RawMessage
|
||||
filters *jsontext.Value
|
||||
appendfilters jsontext.Value
|
||||
created_by *int64
|
||||
addcreated_by *int64
|
||||
deleted_rows *int64
|
||||
@@ -43376,13 +43592,13 @@ func (m *UsageCleanupTaskMutation) ResetStatus() {
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (m *UsageCleanupTaskMutation) SetFilters(jm json.RawMessage) {
|
||||
m.filters = &jm
|
||||
func (m *UsageCleanupTaskMutation) SetFilters(j jsontext.Value) {
|
||||
m.filters = &j
|
||||
m.appendfilters = nil
|
||||
}
|
||||
|
||||
// Filters returns the value of the "filters" field in the mutation.
|
||||
func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) {
|
||||
func (m *UsageCleanupTaskMutation) Filters() (r jsontext.Value, exists bool) {
|
||||
v := m.filters
|
||||
if v == nil {
|
||||
return
|
||||
@@ -43393,7 +43609,7 @@ func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) {
|
||||
// OldFilters returns the old "filters" field's value of the UsageCleanupTask entity.
|
||||
// If the UsageCleanupTask object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMessage, err error) {
|
||||
func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v jsontext.Value, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldFilters is only allowed on UpdateOne operations")
|
||||
}
|
||||
@@ -43407,13 +43623,13 @@ func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMe
|
||||
return oldValue.Filters, nil
|
||||
}
|
||||
|
||||
// AppendFilters adds jm to the "filters" field.
|
||||
func (m *UsageCleanupTaskMutation) AppendFilters(jm json.RawMessage) {
|
||||
m.appendfilters = append(m.appendfilters, jm...)
|
||||
// AppendFilters adds j to the "filters" field.
|
||||
func (m *UsageCleanupTaskMutation) AppendFilters(j jsontext.Value) {
|
||||
m.appendfilters = append(m.appendfilters, j...)
|
||||
}
|
||||
|
||||
// AppendedFilters returns the list of values that were appended to the "filters" field in this mutation.
|
||||
func (m *UsageCleanupTaskMutation) AppendedFilters() (json.RawMessage, bool) {
|
||||
func (m *UsageCleanupTaskMutation) AppendedFilters() (jsontext.Value, bool) {
|
||||
if len(m.appendfilters) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
@@ -43964,7 +44180,7 @@ func (m *UsageCleanupTaskMutation) SetField(name string, value ent.Value) error
|
||||
m.SetStatus(v)
|
||||
return nil
|
||||
case usagecleanuptask.FieldFilters:
|
||||
v, ok := value.(json.RawMessage)
|
||||
v, ok := value.(jsontext.Value)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
@@ -48422,6 +48638,7 @@ type UserMutation struct {
|
||||
signup_source *string
|
||||
last_login_at *time.Time
|
||||
last_active_at *time.Time
|
||||
restrict_public_groups *bool
|
||||
balance_notify_enabled *bool
|
||||
balance_notify_threshold_type *string
|
||||
balance_notify_threshold *float64
|
||||
@@ -49347,6 +49564,42 @@ func (m *UserMutation) ResetLastActiveAt() {
|
||||
delete(m.clearedFields, user.FieldLastActiveAt)
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (m *UserMutation) SetRestrictPublicGroups(b bool) {
|
||||
m.restrict_public_groups = &b
|
||||
}
|
||||
|
||||
// RestrictPublicGroups returns the value of the "restrict_public_groups" field in the mutation.
|
||||
func (m *UserMutation) RestrictPublicGroups() (r bool, exists bool) {
|
||||
v := m.restrict_public_groups
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldRestrictPublicGroups returns the old "restrict_public_groups" field's value of the User entity.
|
||||
// If the User object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UserMutation) OldRestrictPublicGroups(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldRestrictPublicGroups is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldRestrictPublicGroups requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldRestrictPublicGroups: %w", err)
|
||||
}
|
||||
return oldValue.RestrictPublicGroups, nil
|
||||
}
|
||||
|
||||
// ResetRestrictPublicGroups resets all changes to the "restrict_public_groups" field.
|
||||
func (m *UserMutation) ResetRestrictPublicGroups() {
|
||||
m.restrict_public_groups = nil
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (m *UserMutation) SetBalanceNotifyEnabled(b bool) {
|
||||
m.balance_notify_enabled = &b
|
||||
@@ -50373,7 +50626,7 @@ func (m *UserMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *UserMutation) Fields() []string {
|
||||
fields := make([]string, 0, 24)
|
||||
fields := make([]string, 0, 25)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, user.FieldCreatedAt)
|
||||
}
|
||||
@@ -50428,6 +50681,9 @@ func (m *UserMutation) Fields() []string {
|
||||
if m.last_active_at != nil {
|
||||
fields = append(fields, user.FieldLastActiveAt)
|
||||
}
|
||||
if m.restrict_public_groups != nil {
|
||||
fields = append(fields, user.FieldRestrictPublicGroups)
|
||||
}
|
||||
if m.balance_notify_enabled != nil {
|
||||
fields = append(fields, user.FieldBalanceNotifyEnabled)
|
||||
}
|
||||
@@ -50490,6 +50746,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.LastLoginAt()
|
||||
case user.FieldLastActiveAt:
|
||||
return m.LastActiveAt()
|
||||
case user.FieldRestrictPublicGroups:
|
||||
return m.RestrictPublicGroups()
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
return m.BalanceNotifyEnabled()
|
||||
case user.FieldBalanceNotifyThresholdType:
|
||||
@@ -50547,6 +50805,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
|
||||
return m.OldLastLoginAt(ctx)
|
||||
case user.FieldLastActiveAt:
|
||||
return m.OldLastActiveAt(ctx)
|
||||
case user.FieldRestrictPublicGroups:
|
||||
return m.OldRestrictPublicGroups(ctx)
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
return m.OldBalanceNotifyEnabled(ctx)
|
||||
case user.FieldBalanceNotifyThresholdType:
|
||||
@@ -50694,6 +50954,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetLastActiveAt(v)
|
||||
return nil
|
||||
case user.FieldRestrictPublicGroups:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetRestrictPublicGroups(v)
|
||||
return nil
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -50953,6 +51220,9 @@ func (m *UserMutation) ResetField(name string) error {
|
||||
case user.FieldLastActiveAt:
|
||||
m.ResetLastActiveAt()
|
||||
return nil
|
||||
case user.FieldRestrictPublicGroups:
|
||||
m.ResetRestrictPublicGroups()
|
||||
return nil
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
m.ResetBalanceNotifyEnabled()
|
||||
return nil
|
||||
|
||||
@@ -1157,52 +1157,70 @@ func init() {
|
||||
groupDescAllowLive := groupFields[47].Descriptor()
|
||||
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
|
||||
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
|
||||
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
|
||||
groupDescForceOpenaiFast := groupFields[48].Descriptor()
|
||||
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
|
||||
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
|
||||
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
|
||||
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
|
||||
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
|
||||
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[48].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[50].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[49].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[51].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[50].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[52].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[52].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
|
||||
groupDescModelAllowlist := groupFields[54].Descriptor()
|
||||
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
|
||||
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
|
||||
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
|
||||
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
|
||||
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
|
||||
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[53].Descriptor()
|
||||
groupDescRpmLimit := groupFields[56].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[54].Descriptor()
|
||||
groupDescMaxReasoningEffort := groupFields[57].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
|
||||
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
|
||||
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
|
||||
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[59].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[56].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[60].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[57].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[61].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
@@ -2217,24 +2235,28 @@ func init() {
|
||||
user.DefaultSignupSource = userDescSignupSource.Default.(string)
|
||||
// user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
|
||||
user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error)
|
||||
// userDescRestrictPublicGroups is the schema descriptor for restrict_public_groups field.
|
||||
userDescRestrictPublicGroups := userFields[15].Descriptor()
|
||||
// user.DefaultRestrictPublicGroups holds the default value on creation for the restrict_public_groups field.
|
||||
user.DefaultRestrictPublicGroups = userDescRestrictPublicGroups.Default.(bool)
|
||||
// userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field.
|
||||
userDescBalanceNotifyEnabled := userFields[15].Descriptor()
|
||||
userDescBalanceNotifyEnabled := userFields[16].Descriptor()
|
||||
// user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field.
|
||||
user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool)
|
||||
// userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field.
|
||||
userDescBalanceNotifyThresholdType := userFields[16].Descriptor()
|
||||
userDescBalanceNotifyThresholdType := userFields[17].Descriptor()
|
||||
// user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field.
|
||||
user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string)
|
||||
// userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field.
|
||||
userDescBalanceNotifyExtraEmails := userFields[18].Descriptor()
|
||||
userDescBalanceNotifyExtraEmails := userFields[19].Descriptor()
|
||||
// user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field.
|
||||
user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string)
|
||||
// userDescTotalRecharged is the schema descriptor for total_recharged field.
|
||||
userDescTotalRecharged := userFields[19].Descriptor()
|
||||
userDescTotalRecharged := userFields[20].Descriptor()
|
||||
// user.DefaultTotalRecharged holds the default value on creation for the total_recharged field.
|
||||
user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64)
|
||||
// userDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
userDescRpmLimit := userFields[20].Descriptor()
|
||||
userDescRpmLimit := userFields[21].Descriptor()
|
||||
// user.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
user.DefaultRpmLimit = userDescRpmLimit.Default.(int)
|
||||
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
|
||||
|
||||
@@ -242,6 +242,12 @@ func (Group) Fields() []ent.Field {
|
||||
field.Bool("allow_live").
|
||||
Default(false).
|
||||
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
|
||||
field.Bool("force_openai_fast").
|
||||
Default(false).
|
||||
Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"),
|
||||
field.Bool("free_openai_fast").
|
||||
Default(false).
|
||||
Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"),
|
||||
field.Bool("require_oauth_only").
|
||||
Default(false).
|
||||
Comment("仅允许非 apikey 类型账号关联到此分组"),
|
||||
@@ -256,10 +262,14 @@ func (Group) Fields() []ent.Field {
|
||||
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
||||
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
|
||||
Default(domain.GroupModelsListConfig{}).
|
||||
field.JSON("model_allowlist", domain.GroupModelAllowlist{}).
|
||||
Default(domain.GroupModelAllowlist{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
|
||||
Comment("分组模型白名单:同时约束模型列表接口与请求准入"),
|
||||
field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}).
|
||||
Default(domain.GroupCodexModelsManifestConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"),
|
||||
|
||||
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
|
||||
field.Int("rpm_limit").
|
||||
@@ -271,10 +281,14 @@ func (Group) Fields() []ent.Field {
|
||||
MaxLen(20).
|
||||
Default("").
|
||||
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
|
||||
field.String("max_reasoning_effort_over_limit").
|
||||
MaxLen(20).
|
||||
Default("downgrade").
|
||||
Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"),
|
||||
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
|
||||
Comment("OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限"),
|
||||
|
||||
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
|
||||
// 的 token 分组可启用,composite 分组不能直接启用。
|
||||
|
||||
@@ -96,6 +96,11 @@ func (User) Fields() []ent.Field {
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// 公开分组访问限制:为 false 时用户可绑定任意非专属分组(默认行为),
|
||||
// 为 true 时仅可绑定 user_allowed_groups 中列出的公开分组。
|
||||
field.Bool("restrict_public_groups").
|
||||
Default(false),
|
||||
|
||||
// 余额不足通知
|
||||
field.Bool("balance_notify_enabled").
|
||||
Default(true),
|
||||
|
||||
@@ -4,6 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -25,7 +26,7 @@ type UsageCleanupTask struct {
|
||||
// Status holds the value of the "status" field.
|
||||
Status string `json:"status,omitempty"`
|
||||
// Filters holds the value of the "filters" field.
|
||||
Filters json.RawMessage `json:"filters,omitempty"`
|
||||
Filters jsontext.Value `json:"filters,omitempty"`
|
||||
// CreatedBy holds the value of the "created_by" field.
|
||||
CreatedBy int64 `json:"created_by,omitempty"`
|
||||
// DeletedRows holds the value of the "deleted_rows" field.
|
||||
|
||||
@@ -4,7 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -58,7 +58,7 @@ func (_c *UsageCleanupTaskCreate) SetStatus(v string) *UsageCleanupTaskCreate {
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (_c *UsageCleanupTaskCreate) SetFilters(v json.RawMessage) *UsageCleanupTaskCreate {
|
||||
func (_c *UsageCleanupTaskCreate) SetFilters(v jsontext.Value) *UsageCleanupTaskCreate {
|
||||
_c.mutation.SetFilters(v)
|
||||
return _c
|
||||
}
|
||||
@@ -375,7 +375,7 @@ func (u *UsageCleanupTaskUpsert) UpdateStatus() *UsageCleanupTaskUpsert {
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (u *UsageCleanupTaskUpsert) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsert {
|
||||
func (u *UsageCleanupTaskUpsert) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsert {
|
||||
u.Set(usagecleanuptask.FieldFilters, v)
|
||||
return u
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func (u *UsageCleanupTaskUpsertOne) UpdateStatus() *UsageCleanupTaskUpsertOne {
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (u *UsageCleanupTaskUpsertOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertOne {
|
||||
func (u *UsageCleanupTaskUpsertOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertOne {
|
||||
return u.Update(func(s *UsageCleanupTaskUpsert) {
|
||||
s.SetFilters(v)
|
||||
})
|
||||
@@ -999,7 +999,7 @@ func (u *UsageCleanupTaskUpsertBulk) UpdateStatus() *UsageCleanupTaskUpsertBulk
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (u *UsageCleanupTaskUpsertBulk) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertBulk {
|
||||
func (u *UsageCleanupTaskUpsertBulk) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertBulk {
|
||||
return u.Update(func(s *UsageCleanupTaskUpsert) {
|
||||
s.SetFilters(v)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -51,13 +51,13 @@ func (_u *UsageCleanupTaskUpdate) SetNillableStatus(v *string) *UsageCleanupTask
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (_u *UsageCleanupTaskUpdate) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdate {
|
||||
func (_u *UsageCleanupTaskUpdate) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdate {
|
||||
_u.mutation.SetFilters(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendFilters appends value to the "filters" field.
|
||||
func (_u *UsageCleanupTaskUpdate) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdate {
|
||||
func (_u *UsageCleanupTaskUpdate) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdate {
|
||||
_u.mutation.AppendFilters(v)
|
||||
return _u
|
||||
}
|
||||
@@ -374,13 +374,13 @@ func (_u *UsageCleanupTaskUpdateOne) SetNillableStatus(v *string) *UsageCleanupT
|
||||
}
|
||||
|
||||
// SetFilters sets the "filters" field.
|
||||
func (_u *UsageCleanupTaskUpdateOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne {
|
||||
func (_u *UsageCleanupTaskUpdateOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne {
|
||||
_u.mutation.SetFilters(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendFilters appends value to the "filters" field.
|
||||
func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne {
|
||||
func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne {
|
||||
_u.mutation.AppendFilters(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ type User struct {
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
// LastActiveAt holds the value of the "last_active_at" field.
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
// RestrictPublicGroups holds the value of the "restrict_public_groups" field.
|
||||
RestrictPublicGroups bool `json:"restrict_public_groups,omitempty"`
|
||||
// BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field.
|
||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"`
|
||||
// BalanceNotifyThresholdType holds the value of the "balance_notify_threshold_type" field.
|
||||
@@ -237,7 +239,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
|
||||
case user.FieldTotpEnabled, user.FieldRestrictPublicGroups, user.FieldBalanceNotifyEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case user.FieldBalance, user.FieldFrozenBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
@@ -381,6 +383,12 @@ func (_m *User) assignValues(columns []string, values []any) error {
|
||||
_m.LastActiveAt = new(time.Time)
|
||||
*_m.LastActiveAt = value.Time
|
||||
}
|
||||
case user.FieldRestrictPublicGroups:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field restrict_public_groups", values[i])
|
||||
} else if value.Valid {
|
||||
_m.RestrictPublicGroups = value.Bool
|
||||
}
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i])
|
||||
@@ -588,6 +596,9 @@ func (_m *User) String() string {
|
||||
builder.WriteString(v.Format(time.ANSIC))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("restrict_public_groups=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RestrictPublicGroups))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("balance_notify_enabled=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -51,6 +51,8 @@ const (
|
||||
FieldLastLoginAt = "last_login_at"
|
||||
// FieldLastActiveAt holds the string denoting the last_active_at field in the database.
|
||||
FieldLastActiveAt = "last_active_at"
|
||||
// FieldRestrictPublicGroups holds the string denoting the restrict_public_groups field in the database.
|
||||
FieldRestrictPublicGroups = "restrict_public_groups"
|
||||
// FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database.
|
||||
FieldBalanceNotifyEnabled = "balance_notify_enabled"
|
||||
// FieldBalanceNotifyThresholdType holds the string denoting the balance_notify_threshold_type field in the database.
|
||||
@@ -212,6 +214,7 @@ var Columns = []string{
|
||||
FieldSignupSource,
|
||||
FieldLastLoginAt,
|
||||
FieldLastActiveAt,
|
||||
FieldRestrictPublicGroups,
|
||||
FieldBalanceNotifyEnabled,
|
||||
FieldBalanceNotifyThresholdType,
|
||||
FieldBalanceNotifyThreshold,
|
||||
@@ -280,6 +283,8 @@ var (
|
||||
DefaultSignupSource string
|
||||
// SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
|
||||
SignupSourceValidator func(string) error
|
||||
// DefaultRestrictPublicGroups holds the default value on creation for the "restrict_public_groups" field.
|
||||
DefaultRestrictPublicGroups bool
|
||||
// DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field.
|
||||
DefaultBalanceNotifyEnabled bool
|
||||
// DefaultBalanceNotifyThresholdType holds the default value on creation for the "balance_notify_threshold_type" field.
|
||||
@@ -390,6 +395,11 @@ func ByLastActiveAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldLastActiveAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByRestrictPublicGroups orders the results by the restrict_public_groups field.
|
||||
func ByRestrictPublicGroups(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRestrictPublicGroups, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field.
|
||||
func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc()
|
||||
|
||||
@@ -145,6 +145,11 @@ func LastActiveAt(v time.Time) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldLastActiveAt, v))
|
||||
}
|
||||
|
||||
// RestrictPublicGroups applies equality check predicate on the "restrict_public_groups" field. It's identical to RestrictPublicGroupsEQ.
|
||||
func RestrictPublicGroups(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ.
|
||||
func BalanceNotifyEnabled(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
|
||||
@@ -1115,6 +1120,16 @@ func LastActiveAtNotNil() predicate.User {
|
||||
return predicate.User(sql.FieldNotNull(FieldLastActiveAt))
|
||||
}
|
||||
|
||||
// RestrictPublicGroupsEQ applies the EQ predicate on the "restrict_public_groups" field.
|
||||
func RestrictPublicGroupsEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
|
||||
}
|
||||
|
||||
// RestrictPublicGroupsNEQ applies the NEQ predicate on the "restrict_public_groups" field.
|
||||
func RestrictPublicGroupsNEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldNEQ(FieldRestrictPublicGroups, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field.
|
||||
func BalanceNotifyEnabledEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
|
||||
|
||||
@@ -270,6 +270,20 @@ func (_c *UserCreate) SetNillableLastActiveAt(v *time.Time) *UserCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (_c *UserCreate) SetRestrictPublicGroups(v bool) *UserCreate {
|
||||
_c.mutation.SetRestrictPublicGroups(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
|
||||
func (_c *UserCreate) SetNillableRestrictPublicGroups(v *bool) *UserCreate {
|
||||
if v != nil {
|
||||
_c.SetRestrictPublicGroups(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate {
|
||||
_c.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -636,6 +650,10 @@ func (_c *UserCreate) defaults() error {
|
||||
v := user.DefaultSignupSource
|
||||
_c.mutation.SetSignupSource(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
|
||||
v := user.DefaultRestrictPublicGroups
|
||||
_c.mutation.SetRestrictPublicGroups(v)
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
|
||||
v := user.DefaultBalanceNotifyEnabled
|
||||
_c.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -730,6 +748,9 @@ func (_c *UserCreate) check() error {
|
||||
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
|
||||
return &ValidationError{Name: "restrict_public_groups", err: errors.New(`ent: missing required field "User.restrict_public_groups"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
|
||||
return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)}
|
||||
}
|
||||
@@ -844,6 +865,10 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(user.FieldLastActiveAt, field.TypeTime, value)
|
||||
_node.LastActiveAt = &value
|
||||
}
|
||||
if value, ok := _c.mutation.RestrictPublicGroups(); ok {
|
||||
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
|
||||
_node.RestrictPublicGroups = value
|
||||
}
|
||||
if value, ok := _c.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
_node.BalanceNotifyEnabled = value
|
||||
@@ -1384,6 +1409,18 @@ func (u *UserUpsert) ClearLastActiveAt() *UserUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (u *UserUpsert) SetRestrictPublicGroups(v bool) *UserUpsert {
|
||||
u.Set(user.FieldRestrictPublicGroups, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
|
||||
func (u *UserUpsert) UpdateRestrictPublicGroups() *UserUpsert {
|
||||
u.SetExcluded(user.FieldRestrictPublicGroups)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert {
|
||||
u.Set(user.FieldBalanceNotifyEnabled, v)
|
||||
@@ -1819,6 +1856,20 @@ func (u *UserUpsertOne) ClearLastActiveAt() *UserUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (u *UserUpsertOne) SetRestrictPublicGroups(v bool) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetRestrictPublicGroups(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
|
||||
func (u *UserUpsertOne) UpdateRestrictPublicGroups() *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateRestrictPublicGroups()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
@@ -2436,6 +2487,20 @@ func (u *UserUpsertBulk) ClearLastActiveAt() *UserUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (u *UserUpsertBulk) SetRestrictPublicGroups(v bool) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetRestrictPublicGroups(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
|
||||
func (u *UserUpsertBulk) UpdateRestrictPublicGroups() *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateRestrictPublicGroups()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
|
||||
@@ -321,6 +321,20 @@ func (_u *UserUpdate) ClearLastActiveAt() *UserUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (_u *UserUpdate) SetRestrictPublicGroups(v bool) *UserUpdate {
|
||||
_u.mutation.SetRestrictPublicGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
|
||||
func (_u *UserUpdate) SetNillableRestrictPublicGroups(v *bool) *UserUpdate {
|
||||
if v != nil {
|
||||
_u.SetRestrictPublicGroups(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate {
|
||||
_u.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -1069,6 +1083,9 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.LastActiveAtCleared() {
|
||||
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
|
||||
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
}
|
||||
@@ -1997,6 +2014,20 @@ func (_u *UserUpdateOne) ClearLastActiveAt() *UserUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (_u *UserUpdateOne) SetRestrictPublicGroups(v bool) *UserUpdateOne {
|
||||
_u.mutation.SetRestrictPublicGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
|
||||
func (_u *UserUpdateOne) SetNillableRestrictPublicGroups(v *bool) *UserUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetRestrictPublicGroups(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne {
|
||||
_u.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -2775,6 +2806,9 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
|
||||
if _u.mutation.LastActiveAtCleared() {
|
||||
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
|
||||
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Wei-Shaw/sub2api
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
entgo.io/ent v0.14.5
|
||||
@@ -24,6 +24,8 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/go-hclog v1.6.3
|
||||
github.com/hashicorp/go-plugin v1.8.0
|
||||
github.com/imroc/req/v3 v3.59.0
|
||||
github.com/klauspost/compress v1.18.2
|
||||
github.com/lib/pq v1.10.9
|
||||
@@ -54,6 +56,8 @@ require (
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
@@ -121,12 +125,14 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
@@ -149,6 +155,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
@@ -187,10 +194,9 @@ require (
|
||||
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
@@ -200,8 +206,7 @@ require (
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -120,6 +120,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
@@ -176,6 +178,7 @@ github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@@ -232,6 +235,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -250,8 +255,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -262,6 +265,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
|
||||
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
@@ -269,6 +276,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
|
||||
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
|
||||
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
|
||||
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
||||
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo=
|
||||
@@ -281,6 +290,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
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/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
|
||||
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
@@ -307,13 +318,15 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
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/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -350,8 +363,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -386,8 +399,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -423,8 +434,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
@@ -439,6 +448,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
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.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||
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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
@@ -507,18 +517,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
@@ -608,6 +620,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -615,6 +629,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -678,29 +695,31 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -32,7 +32,7 @@ const (
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
@@ -61,6 +61,10 @@ const (
|
||||
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
|
||||
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
|
||||
|
||||
// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。
|
||||
// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。
|
||||
const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
@@ -99,6 +103,18 @@ type Config struct {
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
BatchImage BatchImageConfig `mapstructure:"batch_image"`
|
||||
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
|
||||
Plugins PluginConfig `mapstructure:"plugins"`
|
||||
}
|
||||
|
||||
// PluginConfig 控制管理员手动上传的本地进程插件。
|
||||
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
|
||||
type PluginConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
AllowUnsigned bool `mapstructure:"allow_unsigned"`
|
||||
TrustedPublishers map[string]string `mapstructure:"trusted_publishers"`
|
||||
MaxUploadBytes int64 `mapstructure:"max_upload_bytes"`
|
||||
MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"`
|
||||
StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -651,6 +667,8 @@ type PricingConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
// 回退文件路径
|
||||
FallbackFile string `mapstructure:"fallback_file"`
|
||||
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
|
||||
OverrideFile string `mapstructure:"override_file"`
|
||||
// 更新间隔(小时)
|
||||
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
|
||||
// 哈希校验间隔(分钟)
|
||||
@@ -934,6 +952,9 @@ type GatewayConfig struct {
|
||||
// OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时
|
||||
// OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。
|
||||
OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"`
|
||||
// GrokResponseHeaderTimeout bounds the pre-first-byte wait for xAI/Grok.
|
||||
// A zero value uses the provider-safe default instead of the generic gateway timeout.
|
||||
GrokResponseHeaderTimeout int `mapstructure:"grok_response_header_timeout"`
|
||||
// OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。
|
||||
OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"`
|
||||
// OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。
|
||||
@@ -945,6 +966,8 @@ type GatewayConfig struct {
|
||||
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
|
||||
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
|
||||
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
|
||||
// 上游模型列表响应体读取上限(字节)
|
||||
ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"`
|
||||
// 代理探测响应体读取上限(字节)
|
||||
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
|
||||
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
|
||||
@@ -2258,14 +2281,23 @@ func setDefaults() {
|
||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||||
|
||||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||||
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
|
||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.data_dir", "./data")
|
||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.override_file", "")
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
// 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。
|
||||
viper.SetDefault("plugins.data_dir", "")
|
||||
viper.SetDefault("plugins.allow_unsigned", false)
|
||||
viper.SetDefault("plugins.trusted_publishers", map[string]string{})
|
||||
viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024))
|
||||
viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024))
|
||||
viper.SetDefault("plugins.start_timeout_seconds", 15)
|
||||
|
||||
// Timezone (default to Asia/Shanghai for Chinese users)
|
||||
viper.SetDefault("timezone", "Asia/Shanghai")
|
||||
|
||||
@@ -2327,6 +2359,7 @@ func setDefaults() {
|
||||
// Gateway
|
||||
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
||||
viper.SetDefault("gateway.openai_response_header_timeout", 0)
|
||||
viper.SetDefault("gateway.grok_response_header_timeout", 120)
|
||||
viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.log_upstream_error_body", true)
|
||||
@@ -2432,6 +2465,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
|
||||
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
|
||||
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
|
||||
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
|
||||
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
|
||||
viper.SetDefault("gateway.gemini_debug_response_headers", false)
|
||||
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
||||
@@ -2621,6 +2655,15 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("security.proxy_probe.urls: %w", err)
|
||||
}
|
||||
c.Security.ProxyProbe.URLs = proxyProbeURLs
|
||||
if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824")
|
||||
}
|
||||
if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 {
|
||||
return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648")
|
||||
}
|
||||
if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 {
|
||||
return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120")
|
||||
}
|
||||
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
|
||||
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
|
||||
}
|
||||
@@ -3232,6 +3275,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
|
||||
}
|
||||
if c.Gateway.ModelsListReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.models_list_read_max_bytes must be positive")
|
||||
}
|
||||
if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 {
|
||||
return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive")
|
||||
}
|
||||
@@ -3241,6 +3287,9 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIResponseHeaderTimeout < 0 {
|
||||
return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative")
|
||||
}
|
||||
if c.Gateway.GrokResponseHeaderTimeout < 0 || c.Gateway.GrokResponseHeaderTimeout > 1800 {
|
||||
return fmt.Errorf("gateway.grok_response_header_timeout must be between 0-1800 seconds")
|
||||
}
|
||||
if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 ||
|
||||
(c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) {
|
||||
return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds")
|
||||
|
||||
@@ -23,6 +23,13 @@ func resetViperWithJWTSecret(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
}
|
||||
|
||||
func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes)
|
||||
}
|
||||
|
||||
func TestLoadTimezonePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -553,6 +560,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) {
|
||||
require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP)
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -1794,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
|
||||
wantErr: "gateway.text_max_body_size",
|
||||
},
|
||||
{
|
||||
name: "gateway models list read limit",
|
||||
mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 },
|
||||
wantErr: "gateway.models_list_read_max_bytes",
|
||||
},
|
||||
{
|
||||
name: "gateway response header timeout",
|
||||
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
// GroupCodexModelsManifestConfig controls which accounts fetch the Codex
|
||||
// models manifest for an OpenAI group. When enabled, the group's Codex
|
||||
// /models requests bypass the scheduler and only use the pinned accounts.
|
||||
type GroupCodexModelsManifestConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// AccountIDs is the ordered list of pinned account IDs. Order decides
|
||||
// merge precedence for duplicate model slugs.
|
||||
AccountIDs []int64 `json:"account_ids,omitempty"`
|
||||
// FallbackToScheduler controls the behavior when no pinned account is
|
||||
// usable or all upstream fetches fail: fall back to the scheduler loop
|
||||
// (true) or return the upstream error / 503 (false, default).
|
||||
FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"`
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
|
||||
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
|
||||
var DefaultAntigravityModelMapping = map[string]string{
|
||||
// Claude 白名单
|
||||
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
|
||||
"claude-fable-5": "claude-fable-5", // 官方模型
|
||||
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
|
||||
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
|
||||
@@ -105,11 +106,11 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
|
||||
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名
|
||||
// Claude 详细版本 ID 映射
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型
|
||||
// Claude Haiku → Sonnet(无 Haiku 支持)
|
||||
"claude-haiku-4-5": "claude-sonnet-4-6",
|
||||
"claude-haiku-4-5-20251001": "claude-sonnet-4-6",
|
||||
@@ -158,7 +159,8 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
|
||||
var DefaultBedrockModelMapping = map[string]string{
|
||||
// Claude Fable
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
// Claude Opus
|
||||
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
|
||||
@@ -29,8 +29,9 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"claude-fable-5-1": "claude-fable-5-1",
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultAntigravityModelMapping[from]
|
||||
@@ -43,6 +44,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
|
||||
}
|
||||
for model, want := range cases {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != want {
|
||||
t.Fatalf("expected model %q to map to %q, got %q", model, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -77,8 +93,9 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultBedrockModelMapping[from]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package domain
|
||||
|
||||
// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容,
|
||||
// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。
|
||||
type GroupModelAllowlist struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package domain
|
||||
|
||||
// GroupModelsListConfig controls the optional custom /v1/models response list.
|
||||
type GroupModelsListConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
package domain
|
||||
|
||||
const (
|
||||
ReasoningEffortMatchExact = "exact"
|
||||
ReasoningEffortMatchPrefix = "prefix"
|
||||
ReasoningEffortMatchSuffix = "suffix"
|
||||
)
|
||||
|
||||
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
|
||||
// value to another before the group ceiling is applied.
|
||||
// value to another before the group ceiling is applied. To may also be "deny"
|
||||
// to reject the request when the matching source value is present.
|
||||
//
|
||||
// Model and MatchType optionally scope the rewrite to a request model:
|
||||
// exact matches the full model id, prefix/suffix match a model-id affix.
|
||||
// Empty MatchType and empty Model mean the mapping applies to every model.
|
||||
type ReasoningEffortMapping struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
MatchType string `json:"match_type,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
@@ -64,6 +65,7 @@ type AccountHandler struct {
|
||||
grokImportProber grokImportProber
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||||
@@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct {
|
||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||
type AccountWithConcurrency struct {
|
||||
*dto.Account
|
||||
simpleMode bool `json:"-"`
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
@@ -199,6 +202,123 @@ type AccountWithConcurrency struct {
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
|
||||
}
|
||||
|
||||
// AccountListItemWithConcurrency is the compact account-list envelope used
|
||||
// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
|
||||
// so groups/account_groups never appear in the list payload.
|
||||
type AccountListItemWithConcurrency struct {
|
||||
*dto.AccountListItem
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
|
||||
ActiveSessions *int `json:"active_sessions,omitempty"`
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"`
|
||||
}
|
||||
|
||||
type simpleModeGroupReference struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type simpleModeAccountGroupReference struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Group *simpleModeGroupReference `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
|
||||
}
|
||||
|
||||
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
|
||||
hidden := make(map[int64]struct{})
|
||||
if account == nil {
|
||||
return hidden
|
||||
}
|
||||
for _, group := range account.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
hidden[group.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
hidden[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
|
||||
visible := make([]int64, 0, len(groupIDs))
|
||||
for _, groupID := range groupIDs {
|
||||
if _, ok := hidden[groupID]; !ok {
|
||||
visible = append(visible, groupID)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
|
||||
hidden := make(map[int64]struct{})
|
||||
if account == nil {
|
||||
return hidden
|
||||
}
|
||||
for _, group := range account.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
hidden[group.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
hidden[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
|
||||
type alias AccountWithConcurrency
|
||||
if !a.simpleMode || a.Account == nil {
|
||||
return json.Marshal(alias(a))
|
||||
}
|
||||
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
|
||||
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
|
||||
for _, group := range a.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
continue
|
||||
}
|
||||
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
|
||||
groups = append(groups, *ref)
|
||||
}
|
||||
}
|
||||
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
|
||||
for _, accountGroup := range a.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
continue
|
||||
}
|
||||
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
|
||||
continue
|
||||
}
|
||||
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
|
||||
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
|
||||
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||
Groups []simpleModeGroupReference `json:"groups"`
|
||||
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
|
||||
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
|
||||
}
|
||||
|
||||
type AccountSchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
StickyScore float64 `json:"sticky_score"`
|
||||
@@ -223,9 +343,25 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
|
||||
out := dto.AccountFromServiceShallow(account)
|
||||
if out != nil && account != nil {
|
||||
out.Proxy = dto.ProxyFromService(account.Proxy)
|
||||
}
|
||||
if h != nil && h.ollamaCloudUsage != nil && out != nil {
|
||||
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) isSimpleMode() bool {
|
||||
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(account),
|
||||
simpleMode: h.isSimpleMode(),
|
||||
CurrentConcurrency: 0,
|
||||
}
|
||||
if account == nil {
|
||||
@@ -650,8 +786,16 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
result := make([]AccountWithConcurrency, len(accounts))
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
accountResponse := h.accountResponseFromService(acc)
|
||||
if lite {
|
||||
accountResponse = h.accountListResponseFromService(acc)
|
||||
if h.isSimpleMode() {
|
||||
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
|
||||
}
|
||||
}
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(acc),
|
||||
Account: accountResponse,
|
||||
simpleMode: h.isSimpleMode(),
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
SchedulerScore: schedulerScores[acc.ID],
|
||||
SchedulerScores: schedulerGroupScores[acc.ID],
|
||||
@@ -683,7 +827,34 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
|
||||
h.enrichShadowParents(c.Request.Context(), result)
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
|
||||
if lite {
|
||||
compact := make([]AccountListItemWithConcurrency, len(result))
|
||||
for i := range result {
|
||||
item := result[i]
|
||||
compact[i] = AccountListItemWithConcurrency{
|
||||
AccountListItem: dto.AccountListItemFromAccount(item.Account),
|
||||
CurrentConcurrency: item.CurrentConcurrency,
|
||||
SchedulerScore: item.SchedulerScore,
|
||||
SchedulerScores: item.SchedulerScores,
|
||||
CurrentWindowCost: item.CurrentWindowCost,
|
||||
ActiveSessions: item.ActiveSessions,
|
||||
CurrentRPM: item.CurrentRPM,
|
||||
}
|
||||
}
|
||||
etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
response.Paginated(c, compact, total, page, pageSize)
|
||||
return
|
||||
}
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
@@ -696,23 +867,23 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
func buildAccountsListETag(
|
||||
items []AccountWithConcurrency,
|
||||
func buildAccountsListETag[T any](
|
||||
items []T,
|
||||
total int64,
|
||||
page, pageSize int,
|
||||
platform, accountType, status, search string,
|
||||
lite bool,
|
||||
) string {
|
||||
payload := struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Lite bool `json:"lite"`
|
||||
Items []AccountWithConcurrency `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Lite bool `json:"lite"`
|
||||
Items []T `json:"items"`
|
||||
}{
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -837,6 +1008,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -970,6 +1145,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -1424,6 +1603,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
|
||||
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
|
||||
@@ -1875,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
groupIDs := make([]int64, 0)
|
||||
for _, item := range req.Accounts {
|
||||
groupIDs = append(groupIDs, item.GroupIDs...)
|
||||
}
|
||||
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
|
||||
success := 0
|
||||
@@ -1897,6 +2088,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(item.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil {
|
||||
failed++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
|
||||
|
||||
@@ -2091,6 +2291,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -2576,6 +2780,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
|
||||
// Handle OpenAI accounts
|
||||
if account.IsOpenAI() {
|
||||
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
|
||||
// retain the legacy local catalog below so the test dialog remains usable.
|
||||
if h.accountTestService != nil {
|
||||
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
|
||||
response.Success(c, models)
|
||||
return
|
||||
}
|
||||
}
|
||||
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
|
||||
if account.IsOpenAIPassthroughEnabled() {
|
||||
response.Success(c, openai.DefaultModels)
|
||||
@@ -2614,8 +2826,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
|
||||
// Handle Gemini accounts
|
||||
if account.IsGemini() {
|
||||
// For OAuth accounts: return default Gemini models
|
||||
// Consumer Google One OAuth still uses the legacy Gemini CLI / Code
|
||||
// Assist channel. Do not advertise newer 3.x or image models that the
|
||||
// channel cannot serve.
|
||||
if account.IsOAuth() {
|
||||
if account.IsGeminiGoogleOne() {
|
||||
response.Success(c, geminicli.GoogleOneModels)
|
||||
return
|
||||
}
|
||||
response.Success(c, geminicli.DefaultModels)
|
||||
return
|
||||
}
|
||||
@@ -2768,13 +2986,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2787,29 +3007,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
|
||||
// POST /api/v1/admin/accounts/models/sync-upstream-preview
|
||||
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
var req struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
modelMapping := make(map[string]any, len(req.ModelMapping))
|
||||
for sourceModel, upstreamModel := range req.ModelMapping {
|
||||
modelMapping[sourceModel] = upstreamModel
|
||||
}
|
||||
|
||||
tempAccount := &service.Account{
|
||||
Platform: req.Platform,
|
||||
Type: req.Type,
|
||||
Credentials: map[string]any{
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"model_mapping": modelMapping,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2818,13 +3044,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2837,7 +3065,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
|
||||
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
}
|
||||
|
||||
type syncUpstreamHTTPUpstream struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
resp *http.Response
|
||||
responses []*http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
if u.err != nil {
|
||||
return nil, u.err
|
||||
}
|
||||
if len(u.responses) > 0 {
|
||||
resp := u.responses[0]
|
||||
u.responses = u.responses[1:]
|
||||
return resp, nil
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
|
||||
router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -287,6 +294,42 @@ func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(
|
||||
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 45,
|
||||
Name: "google-one",
|
||||
Platform: service.PlatformGemini,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"oauth_type": "google_one",
|
||||
},
|
||||
},
|
||||
}
|
||||
router := setupAvailableModelsRouter(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
ids := make([]string, 0, len(resp.Data))
|
||||
for _, model := range resp.Data {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
require.ElementsMatch(t, []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"}, ids)
|
||||
require.NotContains(t, ids, "gemini-3.5-flash")
|
||||
require.NotContains(t, ids, "gemini-2.5-flash-image")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -311,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
|
||||
require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"custom-thinking-model",
|
||||
"reasoning":true,
|
||||
"default_reasoning_level":"high",
|
||||
"supported_reasoning_levels":["low","high"],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":256000
|
||||
}]}`)),
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
|
||||
metadata := resp.Data.Metadata["custom-thinking-model"]
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
}
|
||||
|
||||
// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
|
||||
func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"configured-provider": {
|
||||
"api": "https://provider.example/v1",
|
||||
"models": {
|
||||
"glm-5.3": {
|
||||
"id": "glm-5.3",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","high"]}],
|
||||
"modalities": {"input":["text"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/models/sync-upstream-preview",
|
||||
strings.NewReader(`{
|
||||
"platform":"openai",
|
||||
"type":"apikey",
|
||||
"base_url":"https://provider.example/v1",
|
||||
"api_key":"key",
|
||||
"model_mapping":{"public-glm":"glm-5.3"}
|
||||
}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"glm-5.3"}, resp.Data.Models)
|
||||
require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -341,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
|
||||
require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
|
||||
require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
|
||||
}
|
||||
|
||||
// Scenario: 能力补全失败显示部分成功。
|
||||
func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 46,
|
||||
Name: "opencode-id-only-model-list",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "opencode-key",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Models []string `json:"models"`
|
||||
Warnings []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
|
||||
require.Len(t, resp.Data.Warnings, 1)
|
||||
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -13,6 +16,101 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
groupID := int64(77)
|
||||
adminSvc.accounts = []service.Account{{
|
||||
ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
|
||||
Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
|
||||
Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
|
||||
Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotEmpty(t, rec.Header().Get("ETag"))
|
||||
|
||||
var litePayload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
|
||||
require.Len(t, litePayload.Data.Items, 1)
|
||||
liteItem := litePayload.Data.Items[0]
|
||||
require.Equal(t, float64(501), liteItem["id"])
|
||||
require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
|
||||
require.Equal(t, true, liteItem["schedulable"])
|
||||
require.NotContains(t, liteItem, "groups")
|
||||
require.NotContains(t, liteItem, "account_groups")
|
||||
credentials, ok := liteItem["credentials"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "compact@example.com", credentials["email"])
|
||||
require.NotContains(t, credentials, "access_token")
|
||||
credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, credentialsStatus["has_access_token"])
|
||||
|
||||
// The ETag must represent the same compact body and return 304 on refresh.
|
||||
rec304 := httptest.NewRecorder()
|
||||
req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
|
||||
router.ServeHTTP(rec304, req304)
|
||||
require.Equal(t, http.StatusNotModified, rec304.Code)
|
||||
|
||||
// Omitting lite preserves the legacy full response shape.
|
||||
recFull := httptest.NewRecorder()
|
||||
reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
|
||||
router.ServeHTTP(recFull, reqFull)
|
||||
require.Equal(t, http.StatusOK, recFull.Code)
|
||||
var fullPayload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
|
||||
require.Contains(t, fullPayload.Data.Items[0], "groups")
|
||||
require.Contains(t, fullPayload.Data.Items[0], "account_groups")
|
||||
}
|
||||
|
||||
func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
accounts := make([]service.Account, 20)
|
||||
for i := range accounts {
|
||||
id := int64(600 + i)
|
||||
groupID := int64(800 + i)
|
||||
accounts[i] = service.Account{
|
||||
ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
|
||||
Concurrency: 4, GroupIDs: []int64{groupID},
|
||||
Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
adminSvc.accounts = accounts
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Less(t, rec.Body.Len(), 80*1024)
|
||||
|
||||
var compressed bytes.Buffer
|
||||
zw := gzip.NewWriter(&compressed)
|
||||
_, err := zw.Write(rec.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
require.Less(t, compressed.Len(), 15*1024)
|
||||
}
|
||||
|
||||
func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type simpleModeAccountService struct {
|
||||
*stubAdminService
|
||||
account service.Account
|
||||
createCalls int
|
||||
updateCalls int
|
||||
bulkCalls int
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.createCalls++
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
if input.GroupIDs != nil {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.updateCalls++
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
|
||||
if input.GroupIDs != nil {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.bulkCalls++
|
||||
return &service.BulkUpdateAccountsResult{}, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
|
||||
for _, id := range groupIDs {
|
||||
for i := range s.groups {
|
||||
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
|
||||
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
|
||||
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
|
||||
account := service.Account{
|
||||
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
GroupIDs: []int64{7, 9},
|
||||
Groups: []*service.Group{richGroup, historicalComposite},
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
|
||||
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
|
||||
},
|
||||
}
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
|
||||
svc.accounts = []service.Account{account}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.GET("/accounts", h.List)
|
||||
r.GET("/accounts/:id", h.GetByID)
|
||||
r.POST("/accounts", h.Create)
|
||||
r.PUT("/accounts/:id", h.Update)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/accounts", ""},
|
||||
{http.MethodGet, "/accounts/3", ""},
|
||||
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
|
||||
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
if items, ok := data["items"].([]any); ok {
|
||||
require.Len(t, items, 1)
|
||||
data, ok = items[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
}
|
||||
groups, ok := data["groups"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, groups, 1)
|
||||
require.Equal(t, []any{float64(7)}, data["group_ids"])
|
||||
group, ok := groups[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
|
||||
accountGroups, ok := data["account_groups"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, accountGroups, 1)
|
||||
accountGroup, ok := accountGroups[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
|
||||
nestedGroup, ok := accountGroup["group"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
|
||||
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
|
||||
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
|
||||
account := service.Account{
|
||||
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
|
||||
}
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
|
||||
svc.accounts = []service.Account{account}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.GET("/accounts", h.List)
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.NotEmpty(t, res.Header().Get("ETag"))
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
|
||||
require.NotContains(t, payload.Data.Items[0], "groups")
|
||||
require.NotContains(t, payload.Data.Items[0], "account_groups")
|
||||
|
||||
notModified := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
|
||||
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
|
||||
r.ServeHTTP(notModified, req)
|
||||
require.Equal(t, http.StatusNotModified, notModified.Code)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tt := range []struct{ name, method, path, body string }{
|
||||
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
|
||||
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
|
||||
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts", h.Create)
|
||||
r.PUT("/accounts/:id", h.Update)
|
||||
r.POST("/accounts/bulk-update", h.BulkUpdate)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
|
||||
require.Zero(t, svc.createCalls)
|
||||
require.Zero(t, svc.updateCalls)
|
||||
require.Zero(t, svc.bulkCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts", h.Create)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.Equal(t, 1, svc.createCalls)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts/batch", h.BatchCreate)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
|
||||
require.Zero(t, svc.createCalls)
|
||||
}
|
||||
|
||||
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
|
||||
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
|
||||
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(raw), `"rate_multiplier":9`)
|
||||
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// upstreamBillingRatesResponse is deliberately smaller than the regular
|
||||
// account-list response. The frontend uses the returned ID order to detect a
|
||||
// page boundary/order change and only applies snapshots when the page is
|
||||
// otherwise stable.
|
||||
type upstreamBillingRatesResponse struct {
|
||||
Items []service.UpstreamBillingRateSnapshotItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GetUpstreamBillingRates returns persisted upstream billing snapshots for the
|
||||
// current account-list page. It never probes an upstream and never loads
|
||||
// today-stats, usage, concurrency, or credentials for the response.
|
||||
//
|
||||
// GET /api/v1/admin/accounts/upstream-billing-rates
|
||||
func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) {
|
||||
if h.adminService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "account service unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
platform := c.Query("platform")
|
||||
accountType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if len(search) > 100 {
|
||||
search = search[:100]
|
||||
}
|
||||
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
|
||||
sortBy := c.DefaultQuery("sort_by", "name")
|
||||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||||
|
||||
var groupID int64
|
||||
if groupQuery := c.Query("group"); groupQuery != "" {
|
||||
if groupQuery == accountListGroupUngroupedQueryValue {
|
||||
groupID = service.AccountListGroupUngrouped
|
||||
} else {
|
||||
parsed, err := strconv.ParseInt(groupQuery, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
response.BadRequest(c, "invalid group filter")
|
||||
return
|
||||
}
|
||||
groupID = parsed
|
||||
}
|
||||
}
|
||||
|
||||
accounts, total, err := h.adminService.ListAccounts(
|
||||
c.Request.Context(), page, pageSize, platform, accountType, status,
|
||||
search, groupID, privacyMode, sortBy, sortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := upstreamBillingRatesResponse{
|
||||
Items: service.BuildUpstreamBillingRateSnapshotItems(accounts),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
etag := buildUpstreamBillingRatesETag(payload)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "\"" + hex.EncodeToString(sum[:]) + "\""
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
||||
|
||||
router.GET("/api/v1/admin/groups", groupHandler.List)
|
||||
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
|
||||
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
|
||||
router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates)
|
||||
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
|
||||
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
|
||||
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
|
||||
@@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "gpt-5.5")
|
||||
|
||||
@@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
|
||||
Rules: []dto.OpenAIFastPolicyRule{
|
||||
{ServiceTier: "priority", Action: "filter", Scope: "all"},
|
||||
{ServiceTier: "flex", Action: "block", Scope: "oauth"},
|
||||
{ServiceTier: "ultrafast", Action: "pass", Scope: "all"},
|
||||
{ServiceTier: "all", Action: "pass", Scope: "apikey"},
|
||||
},
|
||||
}
|
||||
out := openaiFastPolicySettingsFromDTO(in)
|
||||
require.Len(t, out.Rules, 3)
|
||||
require.Len(t, out.Rules, 4)
|
||||
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ type stubAdminService struct {
|
||||
boundAuthIdentity *service.AdminBindAuthIdentityInput
|
||||
boundAuthIdentityFor int64
|
||||
createdAccounts []*service.CreateAccountInput
|
||||
createdGroups []*service.CreateGroupInput
|
||||
updatedGroups []*service.UpdateGroupInput
|
||||
deletedGroupIDs []int64
|
||||
guardedDeletedGroupIDs []int64
|
||||
deleteGroupIfEmptyErr error
|
||||
advancedGroupOperationCalls int
|
||||
lastListGroupsIsExclusive *bool
|
||||
createdProxies []*service.CreateProxyInput
|
||||
updatedProxyIDs []int64
|
||||
updatedProxies []*service.UpdateProxyInput
|
||||
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
|
||||
s.lastListGroupsIsExclusive = isExclusive
|
||||
return s.groups, int64(len(s.groups)), nil
|
||||
}
|
||||
|
||||
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return []service.CompositeModelRoute{
|
||||
{
|
||||
ID: 1,
|
||||
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return &service.CompositeModelRoute{
|
||||
ID: 1,
|
||||
GroupID: groupID,
|
||||
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return &service.CompositeModelRoute{
|
||||
ID: routeID,
|
||||
GroupID: groupID,
|
||||
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
|
||||
s.createdGroups = append(s.createdGroups, input)
|
||||
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
|
||||
return &group, nil
|
||||
}
|
||||
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
|
||||
s.updatedGroups = append(s.updatedGroups, input)
|
||||
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
|
||||
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
|
||||
return s.deleteGroupIfEmptyErr
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
|
||||
|
||||
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
|
||||
return &account, nil
|
||||
|
||||
@@ -57,25 +57,28 @@ type updateChannelRequest struct {
|
||||
}
|
||||
|
||||
type channelModelPricingRequest struct {
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Periods []channelTimePricingPeriodRequest `json:"periods"`
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only"`
|
||||
Periods []channelTimePricingPeriodRequest `json:"periods"`
|
||||
}
|
||||
|
||||
type channelTimePricingPeriodRequest struct {
|
||||
@@ -91,6 +94,7 @@ type pricingIntervalRequest struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
|
||||
@@ -126,26 +130,29 @@ type channelResponse struct {
|
||||
}
|
||||
|
||||
type channelModelPricingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Periods []channelTimePricingPeriodResponse `json:"periods"`
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only"`
|
||||
Periods []channelTimePricingPeriodResponse `json:"periods"`
|
||||
}
|
||||
|
||||
type channelTimePricingPeriodResponse struct {
|
||||
@@ -162,6 +169,7 @@ type pricingIntervalResponse struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
@@ -252,21 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
intervals = append(intervals, intervalToResponse(iv))
|
||||
}
|
||||
return channelModelPricingResponse{
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +292,11 @@ func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricin
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &channelTimePricingResponse{Timezone: value.Timezone, Periods: periods}
|
||||
return &channelTimePricingResponse{
|
||||
Timezone: value.Timezone,
|
||||
WeekdaysOnly: value.WeekdaysOnly,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
@@ -294,6 +308,7 @@ func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
@@ -328,6 +343,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: inputMultiplier,
|
||||
OutputMultiplier: outputMultiplier,
|
||||
@@ -337,26 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
SortOrder: iv.SortOrder,
|
||||
})
|
||||
}
|
||||
var fastMultiplier, flexMultiplier *float64
|
||||
var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64
|
||||
if allowChannelMultipliers {
|
||||
fastMultiplier = r.FastMultiplier
|
||||
flexMultiplier = r.FlexMultiplier
|
||||
maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier
|
||||
}
|
||||
result = append(result, service.ChannelModelPricing{
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheWrite1hPrice: r.CacheWrite1hPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -374,7 +393,11 @@ func timePricingRequestToService(value *channelTimePricingRequest) *service.Chan
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &service.ChannelTimePricing{Timezone: value.Timezone, Periods: periods}
|
||||
return &service.ChannelTimePricing{
|
||||
Timezone: value.Timezone,
|
||||
WeekdaysOnly: value.WeekdaysOnly,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule {
|
||||
@@ -587,15 +610,25 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
response.Success(c, gin.H{"found": false})
|
||||
return
|
||||
}
|
||||
cacheWritePrice := pricing.CacheCreationPricePerToken
|
||||
var cacheWrite1hPrice *float64
|
||||
if pricing.SupportsCacheBreakdown {
|
||||
if pricing.CacheCreation5mPrice > 0 {
|
||||
cacheWritePrice = pricing.CacheCreation5mPrice
|
||||
}
|
||||
cacheWrite1hPrice = &pricing.CacheCreation1hPrice
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": cacheWritePrice,
|
||||
"cache_write_1h_price": cacheWrite1hPrice,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -320,15 +320,16 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
|
||||
func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
reqs := []channelModelPricingRequest{
|
||||
{
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheWrite1hPrice: float64Ptr(0.008),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +342,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), r.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.03), r.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.005), r.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.008), r.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.002), r.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.04), r.ImageOutputPrice)
|
||||
require.Equal(t, float64Ptr(0.5), r.PerRequestPrice)
|
||||
@@ -353,15 +355,16 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
BillingMode: "per_request",
|
||||
Intervals: []pricingIntervalRequest{
|
||||
{
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheWrite1hPrice: float64Ptr(0.006),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
},
|
||||
{
|
||||
MinTokens: 2000,
|
||||
@@ -384,6 +387,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), iv0.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.02), iv0.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.003), iv0.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.006), iv0.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.001), iv0.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.1), iv0.PerRequestPrice)
|
||||
require.Equal(t, 1, iv0.SortOrder)
|
||||
@@ -426,7 +430,8 @@ func TestPricingRequestToService_TimePricing(t *testing.T) {
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
TimePricing: &channelTimePricingRequest{
|
||||
Timezone: "Asia/Shanghai",
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []channelTimePricingPeriodRequest{{
|
||||
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
|
||||
}},
|
||||
@@ -435,6 +440,7 @@ func TestPricingRequestToService_TimePricing(t *testing.T) {
|
||||
|
||||
got := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone)
|
||||
require.True(t, got[0].TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
@@ -447,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
|
||||
// 避免渠道倍率意外污染账号成本口径。
|
||||
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
req := channelModelPricingRequest{
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
MaxReasoningEffortMultiplier: float64Ptr(3),
|
||||
Intervals: []pricingIntervalRequest{{
|
||||
MinTokens: 272000,
|
||||
InputMultiplier: float64Ptr(2),
|
||||
@@ -463,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
|
||||
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
|
||||
require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
|
||||
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -471,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
|
||||
require.Nil(t, dropped[0].FastMultiplier)
|
||||
require.Nil(t, dropped[0].FlexMultiplier)
|
||||
require.Nil(t, dropped[0].MaxReasoningEffortMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -483,7 +492,8 @@ func TestPricingToResponse_TimePricing(t *testing.T) {
|
||||
got := pricingToResponse(&service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
TimePricing: &service.ChannelTimePricing{
|
||||
Timezone: "Asia/Shanghai",
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []service.ChannelTimePricingPeriod{{
|
||||
StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25,
|
||||
}},
|
||||
@@ -492,6 +502,7 @@ func TestPricingToResponse_TimePricing(t *testing.T) {
|
||||
|
||||
require.NotNil(t, got.TimePricing)
|
||||
require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone)
|
||||
require.True(t, got.TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
@@ -554,3 +565,53 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
require.NotNil(t, body.Data.Models, "models must not be null for platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func setupModelDefaultPricingRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := &ChannelHandler{billingService: service.NewBillingService(nil, nil)}
|
||||
router.GET("/channels/model-pricing", h.GetModelDefaultPricing)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-fable-5-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
|
||||
require.NotNil(t, body.Data.CacheWrite1hPrice)
|
||||
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
|
||||
require.NotNil(t, body.Data.MaxReasoningEffortMultiplier)
|
||||
require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier)
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-sonnet-4", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.Nil(t, body.Data.CacheWrite1hPrice)
|
||||
}
|
||||
|
||||
@@ -262,13 +262,18 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get usage trend")
|
||||
return
|
||||
@@ -349,13 +354,18 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get model statistics")
|
||||
return
|
||||
@@ -426,13 +436,18 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group statistics")
|
||||
return
|
||||
@@ -701,6 +716,14 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
|
||||
dim.Stream = &s
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" {
|
||||
value, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
dim.NativeCompactionV2 = &value
|
||||
}
|
||||
if v := c.Query("billing_type"); v != "" {
|
||||
if bt, err := strconv.ParseInt(v, 10, 8); err == nil {
|
||||
btVal := int8(bt)
|
||||
|
||||
@@ -15,16 +15,19 @@ import (
|
||||
|
||||
type dashboardUsageRepoCapture struct {
|
||||
service.UsageLogRepository
|
||||
trendRequestType *int16
|
||||
trendStream *bool
|
||||
modelRequestType *int16
|
||||
modelStream *bool
|
||||
trendMismatch *bool
|
||||
modelMismatch *bool
|
||||
groupMismatch *bool
|
||||
rankingLimit int
|
||||
ranking []usagestats.UserSpendingRankingItem
|
||||
rankingTotal float64
|
||||
trendRequestType *int16
|
||||
trendStream *bool
|
||||
trendNativeCompaction *bool
|
||||
modelRequestType *int16
|
||||
modelStream *bool
|
||||
modelNativeCompaction *bool
|
||||
groupNativeCompaction *bool
|
||||
trendMismatch *bool
|
||||
modelMismatch *bool
|
||||
groupMismatch *bool
|
||||
rankingLimit int
|
||||
ranking []usagestats.UserSpendingRankingItem
|
||||
rankingTotal float64
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
|
||||
@@ -35,6 +38,7 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
|
||||
) ([]usagestats.TrendDataPoint, error) {
|
||||
s.trendRequestType = filters.RequestType
|
||||
s.trendStream = filters.Stream
|
||||
s.trendNativeCompaction = filters.NativeCompactionV2
|
||||
s.trendMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.TrendDataPoint{}, nil
|
||||
}
|
||||
@@ -62,6 +66,7 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
|
||||
) ([]usagestats.ModelStat, error) {
|
||||
s.modelRequestType = filters.RequestType
|
||||
s.modelStream = filters.Stream
|
||||
s.modelNativeCompaction = filters.NativeCompactionV2
|
||||
s.modelMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.ModelStat{}, nil
|
||||
}
|
||||
@@ -71,6 +76,7 @@ func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
|
||||
startTime, endTime time.Time,
|
||||
filters usagestats.UsageLogFilters,
|
||||
) ([]usagestats.GroupStat, error) {
|
||||
s.groupNativeCompaction = filters.NativeCompactionV2
|
||||
s.groupMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.GroupStat{}, nil
|
||||
}
|
||||
@@ -208,6 +214,48 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestDashboardNativeCompactionFilterPropagatesAlongsideTransport(t *testing.T) {
|
||||
resetDashboardReadCachesForTest()
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?request_type=stream&native_compaction_v2=true",
|
||||
"/admin/dashboard/models?request_type=stream&native_compaction_v2=true",
|
||||
"/admin/dashboard/groups?request_type=stream&native_compaction_v2=true",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
}
|
||||
|
||||
require.NotNil(t, repo.trendNativeCompaction)
|
||||
require.True(t, *repo.trendNativeCompaction)
|
||||
require.NotNil(t, repo.modelNativeCompaction)
|
||||
require.True(t, *repo.modelNativeCompaction)
|
||||
require.NotNil(t, repo.groupNativeCompaction)
|
||||
require.True(t, *repo.groupNativeCompaction)
|
||||
require.NotNil(t, repo.trendRequestType)
|
||||
require.Equal(t, int16(service.RequestTypeStream), *repo.trendRequestType)
|
||||
}
|
||||
|
||||
func TestDashboardNativeCompactionFilterRejectsInvalidBoolean(t *testing.T) {
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?native_compaction_v2=invalid",
|
||||
"/admin/dashboard/models?native_compaction_v2=invalid",
|
||||
"/admin/dashboard/groups?native_compaction_v2=invalid",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
|
||||
resetDashboardReadCachesForTest()
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
|
||||
@@ -28,6 +28,7 @@ type dashboardTrendCacheKey struct {
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
@@ -42,6 +43,7 @@ type dashboardModelGroupCacheKey struct {
|
||||
ModelSource string `json:"model_source,omitempty"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
@@ -85,6 +87,7 @@ func (h *DashboardHandler) getUsageTrendCached(
|
||||
model string,
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
nativeCompactionV2 *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.TrendDataPoint, bool, error) {
|
||||
@@ -99,13 +102,14 @@ func (h *DashboardHandler) getUsageTrendCached(
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
Model: model, RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
@@ -123,6 +127,7 @@ func (h *DashboardHandler) getModelStatsCached(
|
||||
modelSource string,
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
nativeCompactionV2 *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.ModelStat, bool, error) {
|
||||
@@ -136,13 +141,14 @@ func (h *DashboardHandler) getModelStatsCached(
|
||||
ModelSource: usagestats.NormalizeModelSource(modelSource),
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
}, modelSource)
|
||||
})
|
||||
@@ -159,6 +165,7 @@ func (h *DashboardHandler) getGroupStatsCached(
|
||||
userID, apiKeyID, accountID, groupID int64,
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
nativeCompactionV2 *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.GroupStat, bool, error) {
|
||||
@@ -171,13 +178,14 @@ func (h *DashboardHandler) getGroupStatsCached(
|
||||
GroupID: groupID,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ type dashboardSnapshotV2Filters struct {
|
||||
Model string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
NativeCompactionV2 *bool
|
||||
BillingType *int8
|
||||
UpstreamModelMismatch *bool
|
||||
}
|
||||
@@ -59,6 +60,7 @@ type dashboardSnapshotV2CacheKey struct {
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
IncludeStats bool `json:"include_stats"`
|
||||
@@ -105,6 +107,7 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
|
||||
Model: filters.Model,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
NativeCompactionV2: filters.NativeCompactionV2,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
IncludeStats: includeStats,
|
||||
@@ -186,6 +189,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.Model,
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.NativeCompactionV2,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
@@ -207,6 +211,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
usagestats.ModelSourceRequested,
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.NativeCompactionV2,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
@@ -227,6 +232,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.GroupID,
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.NativeCompactionV2,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
@@ -296,6 +302,14 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
|
||||
filters.Stream = &streamVal
|
||||
}
|
||||
|
||||
if nativeCompactionV2Str := strings.TrimSpace(c.Query("native_compaction_v2")); nativeCompactionV2Str != "" {
|
||||
value, err := strconv.ParseBool(nativeCompactionV2Str)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filters.NativeCompactionV2 = &value
|
||||
}
|
||||
|
||||
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
|
||||
v, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
// ProvideAccountHandler injects the Grok active prober for production while
|
||||
// keeping NewAccountHandler convenient for focused unit tests.
|
||||
func ProvideAccountHandler(
|
||||
cfg *config.Config,
|
||||
adminService service.AdminService,
|
||||
oauthService *service.OAuthService,
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
|
||||
tokenCacheInvalidator,
|
||||
)
|
||||
handler.grokImportProber = grokQuotaService
|
||||
handler.cfg = cfg
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -615,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
// ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。
|
||||
//nolint:staticcheck // SA4023
|
||||
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
if err != nil { //nolint:staticcheck // SA4023
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -24,10 +25,14 @@ type GroupHandler struct {
|
||||
adminService service.AdminService
|
||||
dashboardService *service.DashboardService
|
||||
groupCapacityService *service.GroupCapacityService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
|
||||
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
|
||||
return
|
||||
}
|
||||
err := liveattestation.NewProvider().Check(c.Request.Context())
|
||||
result := gin.H{"supported": err == nil}
|
||||
if err != nil {
|
||||
@@ -81,19 +86,100 @@ func (f optionalLimitField) ToServiceInput() *float64 {
|
||||
if f.value != nil {
|
||||
return f.value
|
||||
}
|
||||
zero := 0.0
|
||||
return &zero
|
||||
unlimited := -1.0
|
||||
return &unlimited
|
||||
}
|
||||
|
||||
// NewGroupHandler creates a new admin group handler
|
||||
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
|
||||
return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
|
||||
}
|
||||
|
||||
func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
|
||||
return &GroupHandler{
|
||||
adminService: adminService,
|
||||
dashboardService: dashboardService,
|
||||
groupCapacityService: groupCapacityService,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GroupHandler) isSimpleMode() bool {
|
||||
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
|
||||
}
|
||||
|
||||
type simpleModeGroupOperation string
|
||||
|
||||
const (
|
||||
simpleModeGroupList simpleModeGroupOperation = "list"
|
||||
simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
|
||||
simpleModeGroupGet simpleModeGroupOperation = "get"
|
||||
simpleModeGroupCreate simpleModeGroupOperation = "create"
|
||||
simpleModeGroupUpdate simpleModeGroupOperation = "update"
|
||||
simpleModeGroupDelete simpleModeGroupOperation = "delete"
|
||||
)
|
||||
|
||||
var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
|
||||
simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
|
||||
simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
|
||||
}
|
||||
|
||||
func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
|
||||
if _, allowed := simpleModeGroupOperations[operation]; allowed {
|
||||
return false
|
||||
}
|
||||
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type simpleModeGroupResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
|
||||
AccountCount int64 `json:"account_count,omitempty"`
|
||||
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
|
||||
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return &simpleModeGroupResponse{
|
||||
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
|
||||
Status: group.Status,
|
||||
AccountCount: group.AccountCount,
|
||||
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
|
||||
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
|
||||
allowed.RateMultiplier = 1
|
||||
allowed.SubscriptionType = service.SubscriptionTypeStandard
|
||||
*req = allowed
|
||||
}
|
||||
|
||||
func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
|
||||
}
|
||||
|
||||
// CreateGroupRequest represents create group request
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -147,16 +233,22 @@ type CreateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
|
||||
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。
|
||||
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
// Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// OpenAI/Codex 推理强度精确映射。
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
@@ -216,15 +308,21 @@ type UpdateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
AllowLive *bool `json:"allow_live"`
|
||||
ForceOpenAIFast *bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast *bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
|
||||
ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号 manifest 配置;nil 表示不修改。
|
||||
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
// Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
// nil 不修改,空数组清空,非空数组替换。
|
||||
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
@@ -250,6 +348,9 @@ type CompositeRoutePreviewRequest struct {
|
||||
// List handles listing all groups with pagination
|
||||
// GET /api/v1/admin/groups
|
||||
func (h *GroupHandler) List(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
|
||||
return
|
||||
}
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
platform := c.Query("platform")
|
||||
status := c.Query("status")
|
||||
@@ -264,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||||
|
||||
var isExclusive *bool
|
||||
if isExclusiveStr != "" {
|
||||
if !h.isSimpleMode() && isExclusiveStr != "" {
|
||||
val := isExclusiveStr == "true"
|
||||
isExclusive = &val
|
||||
}
|
||||
@@ -275,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
|
||||
for i := range groups {
|
||||
if service.IsGroupBindableInSimpleMode(&groups[i]) {
|
||||
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
|
||||
}
|
||||
}
|
||||
response.Paginated(c, simpleGroups, total, page, pageSize)
|
||||
return
|
||||
}
|
||||
outGroups := make([]dto.AdminGroup, 0, len(groups))
|
||||
for i := range groups {
|
||||
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
|
||||
@@ -285,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
// ListCompositeRoutes handles listing composite model routes for one group.
|
||||
// GET /api/v1/admin/groups/:id/composite-routes
|
||||
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -300,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
|
||||
// CreateCompositeRoute handles creating one composite model route.
|
||||
// POST /api/v1/admin/groups/:id/composite-routes
|
||||
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -320,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
|
||||
// UpdateCompositeRoute handles replacing one composite model route.
|
||||
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
|
||||
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -344,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
|
||||
// DeleteCompositeRoute handles deleting one composite model route.
|
||||
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
|
||||
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -362,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
|
||||
// PreviewCompositeRoute resolves a model without mutating routes.
|
||||
// POST /api/v1/admin/groups/:id/composite-routes/preview
|
||||
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -415,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
// bound to them even after the group is disabled).
|
||||
// GET /api/v1/admin/groups/all
|
||||
func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
|
||||
return
|
||||
}
|
||||
platform := c.Query("platform")
|
||||
includeInactive := c.Query("include_inactive") == "true"
|
||||
|
||||
@@ -434,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
|
||||
for i := range groups {
|
||||
if service.IsGroupBindableInSimpleMode(&groups[i]) {
|
||||
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
|
||||
}
|
||||
}
|
||||
response.Success(c, simpleGroups)
|
||||
return
|
||||
}
|
||||
outGroups := make([]dto.AdminGroup, 0, len(groups))
|
||||
for i := range groups {
|
||||
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
|
||||
@@ -444,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
// GetByID handles getting a group by ID
|
||||
// GET /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) GetByID(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -456,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list.
|
||||
// GET /api/v1/admin/groups/:id/models-list-candidates
|
||||
func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
|
||||
// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
|
||||
// GET /api/v1/admin/groups/:id/model-allowlist-candidates
|
||||
func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID < 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -484,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
|
||||
// Create handles creating a new group
|
||||
// POST /api/v1/admin/groups
|
||||
func (h *GroupHandler) Create(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
|
||||
return
|
||||
}
|
||||
var req CreateGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() && req.Platform == service.PlatformComposite {
|
||||
response.BadRequest(c, "Platform is not supported in simple mode")
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() {
|
||||
sanitizeCreateGroupRequestForSimpleMode(&req)
|
||||
}
|
||||
|
||||
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
@@ -550,13 +709,17 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
ModelAllowlist: req.ModelAllowlist,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
@@ -565,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Duplicate handles creating an inactive group copy with the source account bindings.
|
||||
// POST /api/v1/admin/groups/:id/duplicate
|
||||
func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID <= 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -618,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
// Update handles updating a group
|
||||
// PUT /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Update(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -629,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() {
|
||||
sanitizeUpdateGroupRequestForSimpleMode(&req)
|
||||
}
|
||||
|
||||
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
@@ -679,13 +855,17 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
ModelAllowlist: req.ModelAllowlist,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
@@ -694,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Delete handles deleting a group
|
||||
// DELETE /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Delete(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
|
||||
if h.isSimpleMode() {
|
||||
err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
|
||||
} else {
|
||||
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
|
||||
}
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -718,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) {
|
||||
// GetStats handles getting group statistics
|
||||
// GET /api/v1/admin/groups/:id/stats
|
||||
func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "stats") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -737,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
|
||||
// GET /api/v1/admin/groups/usage-summary
|
||||
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
todayStart := service.GroupUsageTodayStart(time.Now())
|
||||
|
||||
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
|
||||
@@ -751,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
|
||||
// GET /api/v1/admin/groups/capacity-summary
|
||||
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group capacity summary")
|
||||
@@ -762,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||
// GetGroupAPIKeys handles getting API keys in a group
|
||||
// GET /api/v1/admin/groups/:id/api-keys
|
||||
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -786,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
|
||||
// GET /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -807,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -829,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct {
|
||||
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
|
||||
// PUT /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -857,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct {
|
||||
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
|
||||
// PUT /api/v1/admin/groups/:id/rpm-overrides
|
||||
func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -880,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
|
||||
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rpm-overrides
|
||||
func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -905,6 +1123,9 @@ type UpdateSortOrderRequest struct {
|
||||
// UpdateSortOrder handles updating group sort orders
|
||||
// PUT /api/v1/admin/groups/sort-order
|
||||
func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
var req UpdateSortOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateGroupRequestLimitFieldsTriState(t *testing.T) {
|
||||
t.Run("omitted means unchanged", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &req))
|
||||
require.Nil(t, req.DailyLimitUSD.ToServiceInput())
|
||||
require.Nil(t, req.WeeklyLimitUSD.ToServiceInput())
|
||||
require.Nil(t, req.MonthlyLimitUSD.ToServiceInput())
|
||||
})
|
||||
|
||||
t.Run("null means unlimited", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"daily_limit_usd":null}`), &req))
|
||||
limit := req.DailyLimitUSD.ToServiceInput()
|
||||
require.NotNil(t, limit)
|
||||
require.Negative(t, *limit)
|
||||
})
|
||||
|
||||
t.Run("number is preserved", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"weekly_limit_usd":0,"monthly_limit_usd":42.5}`), &req))
|
||||
require.Equal(t, 0.0, *req.WeeklyLimitUSD.ToServiceInput())
|
||||
require.Equal(t, 42.5, *req.MonthlyLimitUSD.ToServiceInput())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) {
|
||||
var createReq CreateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq))
|
||||
require.True(t, createReq.ForceOpenAIFast)
|
||||
require.True(t, createReq.FreeOpenAIFast)
|
||||
|
||||
var updateReq UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq))
|
||||
require.NotNil(t, updateReq.ForceOpenAIFast)
|
||||
require.False(t, *updateReq.ForceOpenAIFast)
|
||||
require.NotNil(t, updateReq.FreeOpenAIFast)
|
||||
require.False(t, *updateReq.FreeOpenAIFast)
|
||||
|
||||
var omitted UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted))
|
||||
require.Nil(t, omitted.ForceOpenAIFast)
|
||||
require.Nil(t, omitted.FreeOpenAIFast)
|
||||
}
|
||||
@@ -29,4 +29,15 @@ func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) {
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To)
|
||||
})
|
||||
|
||||
t.Run("accepts model scoped mappings", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"low","match_type":"prefix","model":"gpt"}]}`), &req))
|
||||
require.NotNil(t, req.ReasoningEffortMappings)
|
||||
require.Len(t, *req.ReasoningEffortMappings, 1)
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "low", (*req.ReasoningEffortMappings)[0].To)
|
||||
require.Equal(t, "prefix", (*req.ReasoningEffortMappings)[0].MatchType)
|
||||
require.Equal(t, "gpt", (*req.ReasoningEffortMappings)[0].Model)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
|
||||
h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
|
||||
r := gin.New()
|
||||
r.GET("/groups", h.List)
|
||||
r.GET("/groups/all", h.GetAll)
|
||||
r.GET("/groups/live-capability", h.GetLiveCapability)
|
||||
r.GET("/groups/usage-summary", h.GetUsageSummary)
|
||||
r.GET("/groups/capacity-summary", h.GetCapacitySummary)
|
||||
r.PUT("/groups/sort-order", h.UpdateSortOrder)
|
||||
r.GET("/groups/:id", h.GetByID)
|
||||
r.GET("/groups/:id/stats", h.GetStats)
|
||||
r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
|
||||
r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
|
||||
r.POST("/groups", h.Create)
|
||||
r.PUT("/groups/:id", h.Update)
|
||||
r.POST("/groups/:id/duplicate", h.Duplicate)
|
||||
r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
|
||||
r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
|
||||
r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
|
||||
r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
|
||||
r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
|
||||
r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
|
||||
r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
|
||||
r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
|
||||
r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
|
||||
r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
|
||||
r.DELETE("/groups/:id", h.Delete)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
|
||||
create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Len(t, svc.createdGroups, 1)
|
||||
created := svc.createdGroups[0]
|
||||
require.Equal(t, "basic grouping", created.Description)
|
||||
require.Equal(t, 1.0, created.RateMultiplier)
|
||||
require.False(t, created.IsExclusive)
|
||||
require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
|
||||
require.Nil(t, created.DailyLimitUSD)
|
||||
require.False(t, created.AllowImageGeneration)
|
||||
require.False(t, created.LongContextPricingEnabled)
|
||||
require.Empty(t, created.ModelPricing)
|
||||
require.False(t, created.AllowBatchImageGeneration)
|
||||
require.Nil(t, created.VideoPrice720P)
|
||||
require.Nil(t, created.WebSearchPricePerCall)
|
||||
require.Nil(t, created.AudioRealtimePricePerMin)
|
||||
require.Zero(t, created.RPMLimit)
|
||||
|
||||
update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
|
||||
req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res = httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Len(t, svc.updatedGroups, 1)
|
||||
updated := svc.updatedGroups[0]
|
||||
require.NotNil(t, updated.Description)
|
||||
require.Equal(t, "still basic", *updated.Description)
|
||||
require.Nil(t, updated.RateMultiplier)
|
||||
require.Nil(t, updated.IsExclusive)
|
||||
require.Empty(t, updated.SubscriptionType)
|
||||
require.Nil(t, updated.DailyLimitUSD)
|
||||
require.Nil(t, updated.AllowImageGeneration)
|
||||
require.Nil(t, updated.LongContextPricingEnabled)
|
||||
require.Nil(t, updated.ModelPricing)
|
||||
require.Nil(t, updated.AllowBatchImageGeneration)
|
||||
require.Nil(t, updated.VideoPrice720P)
|
||||
require.Nil(t, updated.WebSearchPricePerCall)
|
||||
require.Nil(t, updated.AudioRealtimePricePerMin)
|
||||
require.Empty(t, updated.Status)
|
||||
require.Nil(t, updated.RPMLimit)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code)
|
||||
require.Empty(t, svc.createdGroups)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
|
||||
|
||||
require.Equal(t, http.StatusConflict, res.Code)
|
||||
require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
|
||||
require.Empty(t, svc.deletedGroupIDs)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
|
||||
require.Empty(t, svc.deletedGroupIDs)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Nil(t, svc.lastListGroupsIsExclusive)
|
||||
}
|
||||
|
||||
func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
|
||||
|
||||
func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{
|
||||
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
|
||||
AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
|
||||
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
|
||||
LongContextPricingEnabled: true,
|
||||
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
|
||||
AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
|
||||
WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
|
||||
ModelRouting: map[string][]int64{"claude": {2}},
|
||||
}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
item := payload.Data.Items[0]
|
||||
require.Equal(t, "basic", item["name"])
|
||||
require.Equal(t, "allowed", item["description"])
|
||||
require.Equal(t, "anthropic", item["platform"])
|
||||
require.Equal(t, float64(3), item["account_count"])
|
||||
require.ElementsMatch(t, []string{
|
||||
"id", "name", "description", "platform", "status", "account_count",
|
||||
"active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
|
||||
}, mapKeys(item))
|
||||
for _, forbidden := range []string{
|
||||
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
|
||||
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
|
||||
"audio_realtime_price_per_min", "model_routing",
|
||||
} {
|
||||
_, exposed := item[forbidden]
|
||||
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
for _, path := range []string{"/groups", "/groups/all"} {
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Contains(t, res.Body.String(), `"name":"basic"`)
|
||||
require.NotContains(t, res.Body.String(), `"name":"composite"`)
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{
|
||||
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
|
||||
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
|
||||
LongContextPricingEnabled: true,
|
||||
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
|
||||
}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
|
||||
assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
|
||||
t.Helper()
|
||||
for _, forbidden := range []string{
|
||||
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
|
||||
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
|
||||
"audio_realtime_price_per_min", "model_routing",
|
||||
} {
|
||||
_, exposed := item[forbidden]
|
||||
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
list bool
|
||||
}{
|
||||
{name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
|
||||
{name: "get by id", method: http.MethodGet, path: "/groups/1"},
|
||||
{name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
|
||||
{name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
var payload struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
if tt.list {
|
||||
var items []map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload.Data, &items))
|
||||
require.Len(t, items, 1)
|
||||
assertNoAdvancedFields(t, items[0])
|
||||
return
|
||||
}
|
||||
var item map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload.Data, &item))
|
||||
assertNoAdvancedFields(t, item)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/groups/1/duplicate", ""},
|
||||
{http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
|
||||
{http.MethodGet, "/groups/1/composite-routes", ""},
|
||||
{http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
|
||||
{http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
|
||||
{http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
|
||||
{http.MethodDelete, "/groups/1/composite-routes/2", ""},
|
||||
{http.MethodGet, "/groups/1/rate-multipliers", ""},
|
||||
{http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
|
||||
{http.MethodDelete, "/groups/1/rate-multipliers", ""},
|
||||
{http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
|
||||
{http.MethodDelete, "/groups/1/rpm-overrides", ""},
|
||||
{http.MethodGet, "/groups/1/stats", ""},
|
||||
{http.MethodGet, "/groups/1/api-keys", ""},
|
||||
{http.MethodGet, "/groups/live-capability", ""},
|
||||
{http.MethodGet, "/groups/usage-summary", ""},
|
||||
{http.MethodGet, "/groups/capacity-summary", ""},
|
||||
{http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusForbidden, res.Code)
|
||||
require.Zero(t, svc.advancedGroupOperationCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type OpenAIOAuthHandler struct {
|
||||
type openAIQuotaService interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
|
||||
CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error
|
||||
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
|
||||
}
|
||||
|
||||
@@ -34,12 +35,6 @@ type openAIAccountStateRecoverer interface {
|
||||
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
|
||||
}
|
||||
|
||||
const (
|
||||
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
|
||||
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
|
||||
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
|
||||
)
|
||||
|
||||
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
|
||||
// (non-refundable) reset credit has already been consumed upstream. The whole
|
||||
// request must stay comfortably inside the panel HTTP client timeout, otherwise
|
||||
@@ -493,6 +488,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
service.NotifyOpenAIAutoResetCredit(accountID)
|
||||
response.Success(c, usage)
|
||||
}
|
||||
|
||||
@@ -523,6 +519,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
|
||||
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
|
||||
return
|
||||
}
|
||||
service.NotifyOpenAIAutoResetCredit(accountID)
|
||||
|
||||
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
|
||||
// A failed snapshot write leaves the previous cache intact — report it as a
|
||||
@@ -600,54 +597,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
|
||||
defer cancelPost()
|
||||
|
||||
// Step 1 — unblocking the account is the whole point of consuming a credit
|
||||
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
|
||||
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
|
||||
if h.rateLimitService == nil {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
postResult := service.RunOpenAIQuotaResetPostProcess(
|
||||
postCtx,
|
||||
accountID,
|
||||
h.quotaService,
|
||||
h.rateLimitService,
|
||||
h.adminService.GetAccount,
|
||||
)
|
||||
resetResponse.Quota = postResult.Quota
|
||||
resetResponse.CacheRefreshed = postResult.CacheRefreshed
|
||||
resetResponse.AccountStateRecovered = postResult.AccountStateRecovered
|
||||
resetResponse.WarningCode = postResult.WarningCode
|
||||
if postResult.Account != nil {
|
||||
resetResponse.Account = dto.AccountFromService(postResult.Account)
|
||||
}
|
||||
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
|
||||
InvalidateToken: true,
|
||||
}); err != nil {
|
||||
// Recovery failures are almost always storage-level; the remaining steps
|
||||
// share that dependency, so stop here instead of compounding the failure.
|
||||
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.AccountStateRecovered = true
|
||||
|
||||
// Step 2 — refresh the reset-credit display cache. A failure here is reported
|
||||
// but must not hide the recovered account row produced by step 3.
|
||||
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
|
||||
switch {
|
||||
case usageErr != nil || usage == nil:
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
default:
|
||||
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
} else {
|
||||
resetResponse.Quota = usage
|
||||
resetResponse.CacheRefreshed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — hand back the post-recovery account row so the list drops the
|
||||
// stale rate-limit badge without waiting for the next poll.
|
||||
account, err := h.adminService.GetAccount(postCtx, accountID)
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
|
||||
if resetResponse.WarningCode == "" {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
|
||||
}
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.Account = dto.AccountFromService(account)
|
||||
response.Success(c, resetResponse)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context,
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error {
|
||||
s.cacheCalls++
|
||||
s.cacheCtxErr = ctx.Err()
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
type openAIAccountStateRecovererStub struct {
|
||||
err error
|
||||
calls int
|
||||
@@ -215,7 +221,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
@@ -237,7 +243,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.Zero(t, quota.queryCalls)
|
||||
require.Zero(t, adminService.calls)
|
||||
@@ -260,7 +266,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
@@ -285,7 +291,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
@@ -307,7 +313,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
@@ -331,7 +337,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T)
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.Nil(t, envelope.Data.Account)
|
||||
}
|
||||
@@ -435,7 +441,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const pluginUISessionTTL = 30 * time.Minute
|
||||
|
||||
// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
|
||||
type PluginHandler struct {
|
||||
manager *service.PluginManager
|
||||
}
|
||||
|
||||
func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
|
||||
return &PluginHandler{manager: manager}
|
||||
}
|
||||
|
||||
func (h *PluginHandler) List(c *gin.Context) {
|
||||
plugins, err := h.manager.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugins)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Get(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Upload(c *gin.Context) {
|
||||
maxBytes := h.manager.MaxUploadBytes()
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
|
||||
file, header, err := c.Request.FormFile("plugin")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择有效的 .s2plugin 文件")
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
|
||||
response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
|
||||
return
|
||||
}
|
||||
var installedBy *int64
|
||||
if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||||
userID := subject.UserID
|
||||
installedBy = &userID
|
||||
}
|
||||
plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Created(c, plugin)
|
||||
}
|
||||
|
||||
type pluginEnableRequest struct {
|
||||
AcceptUntested bool `json:"accept_untested"`
|
||||
RolloutPercent int `json:"rollout_percent"`
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Enable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
request := pluginEnableRequest{RolloutPercent: 100}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "启用参数无效")
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Disable(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
plugin, err := h.manager.Disable(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, plugin)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Delete(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.manager.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "插件已卸载"})
|
||||
}
|
||||
|
||||
func (h *PluginHandler) GetConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) SaveConfig(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
response.BadRequest(c, "插件配置必须是有效 JSON")
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
response.BadRequest(c, "插件配置只能包含一个 JSON 值")
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "插件配置无法序列化")
|
||||
return
|
||||
}
|
||||
saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) Test(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.manager.Test(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) CreateUISession(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
bridgeToken, err := randomPluginToken()
|
||||
if err != nil {
|
||||
response.InternalError(c, "创建插件 UI Bridge 失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
|
||||
"bridge_token": bridgeToken,
|
||||
"ui_bridge_version": 1,
|
||||
"expires_at": expires,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
|
||||
func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Param("token"))
|
||||
pluginID, err := h.manager.ResolveUIAssetToken(token)
|
||||
if err != nil {
|
||||
c.Status(http.StatusGone)
|
||||
return
|
||||
}
|
||||
relative := strings.TrimPrefix(c.Param("path"), "/")
|
||||
data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
|
||||
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
|
||||
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func pluginIDParam(c *gin.Context) (int64, bool) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "插件 ID 无效")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func randomPluginToken() (string, error) {
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
|
||||
return nil, createErr
|
||||
}
|
||||
|
||||
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
|
||||
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
|
||||
if redeemErr != nil {
|
||||
return nil, redeemErr
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
|
||||
return nil, service.ErrRedeemCodeExpired
|
||||
}
|
||||
if existing.CanUse() {
|
||||
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
|
||||
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
|
||||
if err == nil {
|
||||
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
|
||||
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
OpenAITTFTMode: settings.OpenAITTFTMode,
|
||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
EnableCCHSigning: settings.EnableCCHSigning,
|
||||
@@ -382,9 +383,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -452,6 +452,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
|
||||
changed = append(changed, "enable_fingerprint_unification")
|
||||
}
|
||||
if before.OpenAITTFTMode != after.OpenAITTFTMode {
|
||||
changed = append(changed, "openai_ttft_mode")
|
||||
}
|
||||
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
|
||||
changed = append(changed, "enable_metadata_passthrough")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ type TestSMTPRequest struct {
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUsername string `json:"smtp_username"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
SMTPUseTLS *bool `json:"smtp_use_tls"`
|
||||
}
|
||||
|
||||
func resolveSMTPUseTLS(requested *bool, savedConfig *service.SMTPConfig) bool {
|
||||
if requested != nil {
|
||||
return *requested
|
||||
}
|
||||
return savedConfig != nil && savedConfig.UseTLS
|
||||
}
|
||||
|
||||
// TestSMTPConnection 测试SMTP连接
|
||||
@@ -64,7 +71,7 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
|
||||
Port: req.SMTPPort,
|
||||
Username: req.SMTPUsername,
|
||||
Password: password,
|
||||
UseTLS: req.SMTPUseTLS,
|
||||
UseTLS: resolveSMTPUseTLS(req.SMTPUseTLS, savedConfig),
|
||||
}
|
||||
|
||||
err := h.emailService.TestSMTPConnectionWithConfig(config)
|
||||
@@ -85,7 +92,7 @@ type SendTestEmailRequest struct {
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from_email"`
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
SMTPUseTLS *bool `json:"smtp_use_tls"`
|
||||
}
|
||||
|
||||
// SendTestEmail 发送测试邮件
|
||||
@@ -142,7 +149,7 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
||||
Password: password,
|
||||
From: req.SMTPFrom,
|
||||
FromName: req.SMTPFromName,
|
||||
UseTLS: req.SMTPUseTLS,
|
||||
UseTLS: resolveSMTPUseTLS(req.SMTPUseTLS, savedConfig),
|
||||
}
|
||||
|
||||
siteName := h.settingService.GetSiteName(c.Request.Context())
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func bindSMTPRequest[T any](t *testing.T, body string) T {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = req
|
||||
|
||||
var payload T
|
||||
require.NoError(t, c.ShouldBindJSON(&payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestTestSMTPRequestUseTLSOmissionFallsBackToSavedSetting(t *testing.T) {
|
||||
req := bindSMTPRequest[TestSMTPRequest](t, `{}`)
|
||||
|
||||
require.Nil(t, req.SMTPUseTLS)
|
||||
require.True(t, resolveSMTPUseTLS(req.SMTPUseTLS, &service.SMTPConfig{UseTLS: true}))
|
||||
}
|
||||
|
||||
func TestTestSMTPRequestExplicitFalseOverridesSavedUseTLS(t *testing.T) {
|
||||
req := bindSMTPRequest[TestSMTPRequest](t, `{"smtp_use_tls":false}`)
|
||||
|
||||
require.NotNil(t, req.SMTPUseTLS)
|
||||
require.False(t, resolveSMTPUseTLS(req.SMTPUseTLS, &service.SMTPConfig{UseTLS: true}))
|
||||
}
|
||||
|
||||
func TestTestSMTPRequestExplicitTrueOverridesMissingSavedUseTLS(t *testing.T) {
|
||||
req := bindSMTPRequest[TestSMTPRequest](t, `{"smtp_use_tls":true}`)
|
||||
|
||||
require.NotNil(t, req.SMTPUseTLS)
|
||||
require.True(t, resolveSMTPUseTLS(req.SMTPUseTLS, nil))
|
||||
}
|
||||
|
||||
func TestSendTestEmailRequestPreservesUseTLSOmissionSemantics(t *testing.T) {
|
||||
omitted := bindSMTPRequest[SendTestEmailRequest](t, `{"email":"admin@example.com"}`)
|
||||
explicitFalse := bindSMTPRequest[SendTestEmailRequest](t, `{"email":"admin@example.com","smtp_use_tls":false}`)
|
||||
saved := &service.SMTPConfig{UseTLS: true}
|
||||
|
||||
require.True(t, resolveSMTPUseTLS(omitted.SMTPUseTLS, saved))
|
||||
require.False(t, resolveSMTPUseTLS(explicitFalse.SMTPUseTLS, saved))
|
||||
}
|
||||
@@ -154,6 +154,33 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SettingHandler) GetOpenAIImagesOAuthUnavailableCooldownSettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetOpenAIImagesOAuthUnavailableCooldownSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: settings.CooldownMinutes})
|
||||
}
|
||||
|
||||
type UpdateOpenAIImagesOAuthUnavailableCooldownSettingsRequest struct {
|
||||
CooldownMinutes int `json:"cooldown_minutes"`
|
||||
}
|
||||
|
||||
func (h *SettingHandler) UpdateOpenAIImagesOAuthUnavailableCooldownSettings(c *gin.Context) {
|
||||
var req UpdateOpenAIImagesOAuthUnavailableCooldownSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
settings := &service.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: req.CooldownMinutes}
|
||||
if err := h.settingService.SetOpenAIImagesOAuthUnavailableCooldownSettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: settings.CooldownMinutes})
|
||||
}
|
||||
|
||||
// GetPanelRateLimitSettings 获取面板 API 限流配置
|
||||
// GET /api/v1/admin/settings/panel-rate-limit
|
||||
func (h *SettingHandler) GetPanelRateLimitSettings(c *gin.Context) {
|
||||
|
||||
@@ -243,6 +243,7 @@ type UpdateSettingsRequest struct {
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
|
||||
// Gateway forwarding behavior
|
||||
OpenAITTFTMode *string `json:"openai_ttft_mode"`
|
||||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||||
@@ -347,6 +348,9 @@ type UpdateSettingsRequest struct {
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription *string `json:"model_plaza_description"`
|
||||
|
||||
// Plugin management menu visibility switch; plugin runtime is unaffected.
|
||||
PluginManagementEnabled *bool `json:"plugin_management_enabled"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -438,7 +442,7 @@ func buildSettingKeyByJSONName() map[string]string {
|
||||
out := make(map[string]string, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
@@ -1680,6 +1684,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.EnableFingerprintUnification
|
||||
}(),
|
||||
OpenAITTFTMode: func() string {
|
||||
if req.OpenAITTFTMode != nil {
|
||||
return *req.OpenAITTFTMode
|
||||
}
|
||||
return previousSettings.OpenAITTFTMode
|
||||
}(),
|
||||
EnableMetadataPassthrough: func() bool {
|
||||
if req.EnableMetadataPassthrough != nil {
|
||||
return *req.EnableMetadataPassthrough
|
||||
@@ -1938,6 +1948,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ModelPlazaDescription
|
||||
}(),
|
||||
PluginManagementEnabled: func() bool {
|
||||
if req.PluginManagementEnabled != nil {
|
||||
return *req.PluginManagementEnabled
|
||||
}
|
||||
return previousSettings.PluginManagementEnabled
|
||||
}(),
|
||||
AffiliateEnabled: func() bool {
|
||||
if req.AffiliateEnabled != nil {
|
||||
return *req.AffiliateEnabled
|
||||
@@ -2357,9 +2373,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
PluginManagementEnabled: updatedSettings.PluginManagementEnabled,
|
||||
|
||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
stream = &val
|
||||
}
|
||||
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
var billingType *int8
|
||||
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
@@ -192,6 +198,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
@@ -276,6 +283,12 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
stream = &val
|
||||
}
|
||||
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
var billingType *int8
|
||||
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
@@ -344,6 +357,7 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
|
||||
@@ -95,6 +95,32 @@ func TestAdminUsageListInvalidStream(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestAdminUsageListNativeCompactionFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage?request_type=stream&native_compaction_v2=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotNil(t, repo.listFilters.RequestType)
|
||||
require.Equal(t, int16(service.RequestTypeStream), *repo.listFilters.RequestType)
|
||||
require.NotNil(t, repo.listFilters.NativeCompactionV2)
|
||||
require.True(t, *repo.listFilters.NativeCompactionV2)
|
||||
}
|
||||
|
||||
func TestAdminUsageListInvalidNativeCompactionFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage?native_compaction_v2=oops", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestAdminUsageListExactTotalTrue(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
@@ -144,6 +170,19 @@ func TestAdminUsageStatsRequestTypePriority(t *testing.T) {
|
||||
require.Nil(t, repo.statsFilters.Stream)
|
||||
}
|
||||
|
||||
func TestAdminUsageStatsNativeCompactionFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage/stats?native_compaction_v2=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotNil(t, repo.statsFilters.NativeCompactionV2)
|
||||
require.True(t, *repo.statsFilters.NativeCompactionV2)
|
||||
}
|
||||
|
||||
func TestAdminUsageStatsUsesRequestedModelForDisplayModelFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
@@ -21,6 +21,7 @@ type usageStatsCacheKeyData struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
@@ -45,6 +46,7 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
NativeCompactionV2: filters.NativeCompactionV2,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
})
|
||||
|
||||
@@ -59,30 +59,32 @@ func NewUserHandler(
|
||||
|
||||
// CreateUserRequest represents admin create user request
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username"`
|
||||
Notes string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
AllowedGroups []int64 `json:"allowed_groups"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username"`
|
||||
Notes string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
AllowedGroups []int64 `json:"allowed_groups"`
|
||||
RestrictPublicGroups bool `json:"restrict_public_groups"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest represents admin update user request
|
||||
// 使用指针类型来区分"未提供"和"设置为0"
|
||||
type UpdateUserRequest struct {
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
Username *string `json:"username"`
|
||||
Notes *string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
AllowedGroups *[]int64 `json:"allowed_groups"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
Username *string `json:"username"`
|
||||
Notes *string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
AllowedGroups *[]int64 `json:"allowed_groups"`
|
||||
RestrictPublicGroups *bool `json:"restrict_public_groups"`
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
// map[groupID]*rate,nil 表示删除该分组的专属倍率
|
||||
GroupRates map[int64]*float64 `json:"group_rates"`
|
||||
@@ -284,16 +286,17 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
RestrictPublicGroups: req.RestrictPublicGroups,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -342,18 +345,19 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
|
||||
// 使用指针类型直接传递,nil 表示未提供该字段
|
||||
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
Status: req.Status,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
GroupRates: req.GroupRates,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
Status: req.Status,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
RestrictPublicGroups: req.RestrictPublicGroups,
|
||||
GroupRates: req.GroupRates,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
|
||||
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
||||
AvatarSource: "remote_url",
|
||||
},
|
||||
identities: []service.UserAuthIdentityRecord{
|
||||
{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "linuxdo-subject-31",
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{
|
||||
"username": "linuxdo-handle",
|
||||
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||
},
|
||||
identities: []service.UserAuthIdentityRecord{
|
||||
{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "linuxdo-subject-31",
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{
|
||||
"username": "linuxdo-handle",
|
||||
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
handler := &AuthHandler{
|
||||
userService: service.NewUserService(repo, nil, nil, nil),
|
||||
|
||||
@@ -1144,10 +1144,10 @@ func (k oidcJWK) publicKey() (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ec y: %w", err)
|
||||
}
|
||||
if !curve.IsOnCurve(x, y) {
|
||||
if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移
|
||||
return nil, errors.New("ec point is not on curve")
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
|
||||
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty)
|
||||
}
|
||||
|
||||
@@ -66,27 +66,34 @@ type userAvailableGroup struct {
|
||||
|
||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||
type userSupportedModelPricing struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
}
|
||||
|
||||
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
|
||||
type userPricingIntervalDTO struct {
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
|
||||
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// userSupportedModel 用户可见的支持模型条目。
|
||||
@@ -284,37 +291,57 @@ func toUserSupportedModels(
|
||||
return out
|
||||
}
|
||||
|
||||
// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。
|
||||
func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
CacheWriteMultiplier: iv.CacheWriteMultiplier,
|
||||
CacheReadMultiplier: iv.CacheReadMultiplier,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
}
|
||||
|
||||
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
|
||||
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals))
|
||||
for _, iv := range p.Intervals {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
intervals := toUserPricingIntervals(p.Intervals)
|
||||
if intervals == nil {
|
||||
// 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。
|
||||
intervals = []userPricingIntervalDTO{}
|
||||
}
|
||||
billingMode := string(p.BillingMode)
|
||||
if billingMode == "" {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
}
|
||||
return &userSupportedModelPricing{
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
}
|
||||
|
||||
// pricing interval 白名单:不应暴露 id / sort_order。
|
||||
inputMultiplier := 2.0
|
||||
outputMultiplier := 1.5
|
||||
cacheWriteMultiplier := 2.0
|
||||
cacheReadMultiplier := 2.0
|
||||
pricing := toUserPricing(&service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
Intervals: []service.PricingInterval{
|
||||
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
|
||||
{
|
||||
ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
|
||||
InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
|
||||
CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NotNil(t, pricing)
|
||||
@@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
_, exists := ivDecoded[key]
|
||||
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
|
||||
}
|
||||
for key, want := range map[string]float64{
|
||||
"input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
|
||||
"cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
|
||||
} {
|
||||
got, exists := ivDecoded[key]
|
||||
require.Truef(t, exists, "user pricing interval must expose %q", key)
|
||||
require.InDelta(t, want, got.(float64), 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
|
||||
|
||||
@@ -139,9 +139,28 @@ func (h *BatchImageHandler) Models(c *gin.Context) {
|
||||
batchImageError(c, err)
|
||||
return
|
||||
}
|
||||
// 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。
|
||||
if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil {
|
||||
got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist)
|
||||
}
|
||||
c.JSON(http.StatusOK, got)
|
||||
}
|
||||
|
||||
// filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。
|
||||
// 白名单未开启时原样返回。
|
||||
func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel {
|
||||
if !allowlist.Enabled {
|
||||
return models
|
||||
}
|
||||
filtered := make([]service.BatchImagePublicModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if allowlist.Allows(model.ID) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *BatchImageHandler) Items(c *gin.Context) {
|
||||
owner, ok := batchImageOwnerFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -13,11 +14,16 @@ import (
|
||||
)
|
||||
|
||||
type ChannelMonitorV2Handler struct {
|
||||
service *service.ChannelMonitorV2Service
|
||||
service *service.ChannelMonitorV2Service
|
||||
apiKeyService channelMonitorV2GroupAuthorizer
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler {
|
||||
return &ChannelMonitorV2Handler{service: svc}
|
||||
type channelMonitorV2GroupAuthorizer interface {
|
||||
GetAvailableGroups(ctx context.Context, userID int64) ([]service.Group, error)
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service, apiKeyService *service.APIKeyService) *ChannelMonitorV2Handler {
|
||||
return &ChannelMonitorV2Handler{service: svc, apiKeyService: apiKeyService}
|
||||
}
|
||||
|
||||
// channelMonitorV2IsAdmin is true when the request already passed admin auth
|
||||
@@ -68,13 +74,17 @@ func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
admin := channelMonitorV2IsAdmin(c)
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Dimensions(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
// Admin and user share this handler; only non-admin responses strip volume.
|
||||
if !channelMonitorV2IsAdmin(c) {
|
||||
if !admin {
|
||||
service.RedactChannelMonitorV2Dimensions(result)
|
||||
}
|
||||
response.Success(c, result)
|
||||
@@ -94,6 +104,9 @@ func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Snapshot(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -107,6 +120,9 @@ func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Models(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -125,6 +141,9 @@ func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -138,7 +157,11 @@ func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c))
|
||||
admin := channelMonitorV2IsAdmin(c)
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -156,6 +179,9 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
|
||||
response.Error(c, http.StatusUnauthorized, "user not found in context")
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -164,6 +190,32 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) scopeFilter(c *gin.Context, filter *service.ChannelMonitorV2Filter, admin bool) bool {
|
||||
if admin {
|
||||
return true
|
||||
}
|
||||
if h.apiKeyService == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "channel monitor group authorization unavailable")
|
||||
return false
|
||||
}
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
response.Unauthorized(c, "user not found in context")
|
||||
return false
|
||||
}
|
||||
groups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
filter.RestrictGroups = true
|
||||
filter.AllowedGroupIDs = make([]int64, 0, len(groups))
|
||||
for i := range groups {
|
||||
filter.AllowedGroupIDs = append(filter.AllowedGroupIDs, groups[i].ID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) {
|
||||
groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type channelMonitorV2GroupAuthorizerStub struct {
|
||||
groups []service.Group
|
||||
err error
|
||||
calls []int64
|
||||
}
|
||||
|
||||
func (s *channelMonitorV2GroupAuthorizerStub) GetAvailableGroups(_ context.Context, userID int64) ([]service.Group, error) {
|
||||
s.calls = append(s.calls, userID)
|
||||
return s.groups, s.err
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil)
|
||||
@@ -29,7 +42,46 @@ func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil)
|
||||
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil))
|
||||
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil), nil)
|
||||
h.Matrix(c)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ScopeFilterUsesAvailableGroupsForOrdinaryUser(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42})
|
||||
authorizer := &channelMonitorV2GroupAuthorizerStub{groups: []service.Group{{ID: 3}, {ID: 7}}}
|
||||
h := &ChannelMonitorV2Handler{apiKeyService: authorizer}
|
||||
filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{7, 9}}
|
||||
|
||||
require.True(t, h.scopeFilter(c, &filter, false))
|
||||
require.True(t, filter.RestrictGroups)
|
||||
require.Equal(t, []int64{3, 7}, filter.AllowedGroupIDs)
|
||||
require.Equal(t, []int64{42}, authorizer.calls)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ScopeFilterPreservesEmptyOrdinaryUserScope(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42})
|
||||
h := &ChannelMonitorV2Handler{apiKeyService: &channelMonitorV2GroupAuthorizerStub{}}
|
||||
filter := service.ChannelMonitorV2Filter{}
|
||||
|
||||
require.True(t, h.scopeFilter(c, &filter, false))
|
||||
require.True(t, filter.RestrictGroups)
|
||||
require.Empty(t, filter.AllowedGroupIDs)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ScopeFilterLeavesAdminUnrestricted(t *testing.T) {
|
||||
authorizer := &channelMonitorV2GroupAuthorizerStub{}
|
||||
h := &ChannelMonitorV2Handler{apiKeyService: authorizer}
|
||||
filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{9}}
|
||||
|
||||
require.True(t, h.scopeFilter(nil, &filter, true))
|
||||
require.False(t, filter.RestrictGroups)
|
||||
require.Nil(t, filter.AllowedGroupIDs)
|
||||
require.Empty(t, authorizer.calls)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -59,31 +60,109 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings
|
||||
if effectivePlatform == service.PlatformAnthropic {
|
||||
maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings)
|
||||
}
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic {
|
||||
return "", nil, "", false
|
||||
}
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings)
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) {
|
||||
if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" {
|
||||
maxEffort = "low"
|
||||
}
|
||||
normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...)
|
||||
for i := range normalizedMappings {
|
||||
if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" {
|
||||
normalizedMappings[i].To = "low"
|
||||
}
|
||||
}
|
||||
return maxEffort, normalizedMappings
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
effort := service.CanonicalRequestedReasoningEffort(body, model)
|
||||
if effort == nil {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithRequestedReasoningEffort(c.Request.Context(), *effort))
|
||||
}
|
||||
|
||||
func stampOpenAIRequestedReasoningEffort(result *service.OpenAIForwardResult, c *gin.Context) {
|
||||
if result == nil || result.RequestedReasoningEffort != nil {
|
||||
return
|
||||
}
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
result.RequestedReasoningEffort = service.RequestedReasoningEffortFromContext(c.Request.Context())
|
||||
}
|
||||
|
||||
func stampForwardRequestedReasoningEffort(result *service.ForwardResult, requested *string) {
|
||||
if result == nil || result.RequestedReasoningEffort != nil {
|
||||
return
|
||||
}
|
||||
result.RequestedReasoningEffort = requested
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
|
||||
if c == nil || err == nil || write == nil {
|
||||
return
|
||||
}
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
// The Messages bridge synthesizes a default OpenAI effort when
|
||||
// output_config.effort is omitted. Bind the group policy only for an
|
||||
// explicit client value so the ceiling does not alter that default.
|
||||
@@ -91,9 +170,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
|
||||
@@ -31,6 +32,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
|
||||
}{
|
||||
{model: "grok-4.3", platform: service.PlatformGrok},
|
||||
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
|
||||
{model: "k3", platform: service.PlatformKimi},
|
||||
{model: "glm-5.2", platform: service.PlatformZhipu},
|
||||
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
|
||||
}
|
||||
@@ -116,12 +118,17 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
|
||||
require.NotNil(t, requested)
|
||||
require.Equal(t, "max", *requested)
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
@@ -129,18 +136,117 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
denyGroup := *group
|
||||
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
denyAPIKey := &service.APIKey{Group: &denyGroup}
|
||||
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
|
||||
mappingDenyGroup := *group
|
||||
mappingDenyGroup.MaxReasoningEffort = ""
|
||||
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
|
||||
{From: "max", To: service.ReasoningEffortMappingDeny},
|
||||
}
|
||||
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
|
||||
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
var mappingDenied *service.ReasoningEffortMappingDeniedError
|
||||
require.ErrorAs(t, err, &mappingDenied)
|
||||
require.Equal(t, "max", mappingDenied.Requested)
|
||||
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{
|
||||
Platform: service.PlatformAnthropic,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
}}
|
||||
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
|
||||
policy := func(platform string) *service.APIKey {
|
||||
return &service.APIKey{Group: &service.Group{
|
||||
Platform: platform,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
ReasoningEffortMappings: []service.ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
|
||||
|
||||
denyMax := policy(service.PlatformAnthropic)
|
||||
denyMax.Group.ReasoningEffortMappings = nil
|
||||
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
require.Equal(t, "max", overLimit.Requested)
|
||||
require.Equal(t, "xhigh", overLimit.Max)
|
||||
|
||||
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
|
||||
|
||||
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
|
||||
require.Equal(t, "low", maxEffort)
|
||||
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
|
||||
}
|
||||
|
||||
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -9,10 +9,15 @@ import (
|
||||
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
|
||||
const (
|
||||
gatewayQueueFullCode = "gateway_queue_full"
|
||||
gatewayConcurrencyLimitCode = "gateway_concurrency_limit"
|
||||
)
|
||||
|
||||
func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) {
|
||||
var waitQueueFullErr *WaitQueueFullError
|
||||
if errors.As(err, &waitQueueFullErr) {
|
||||
return http.StatusTooManyRequests, "rate_limit_error",
|
||||
return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode,
|
||||
"Too many pending requests, please retry later"
|
||||
}
|
||||
|
||||
@@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string)
|
||||
if concurrencyErr.SlotType != "" {
|
||||
slotType = concurrencyErr.SlotType
|
||||
}
|
||||
return http.StatusTooManyRequests, "rate_limit_error",
|
||||
return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode,
|
||||
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType)
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return statusClientClosedRequest, "api_error", "context canceled"
|
||||
return statusClientClosedRequest, "api_error", "", "context canceled"
|
||||
}
|
||||
|
||||
return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later"
|
||||
return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later"
|
||||
}
|
||||
|
||||